天天看點

并發程式設計之可變狀态

熟悉Java或如C#等使用共享記憶體模型作為并發實作的人都比較清楚,編寫線程安全的代碼很關鍵的一點就是要控制好可變狀态,對于Java開發者來說可能用記憶體可見性更容易了解,在各種關于并發的書籍中都是處理好記憶體可見性問題編寫線程安全的代碼就成功了一半了,但我認為“記憶體可見性”太過于抽象、底層,使開發者不容易了解;

  多線程之間通過共享記憶體進行通訊這句話可能很多人都比較清楚,我認為也可以這麼說多線程間通過共享可變狀态進行通訊,本篇文章讨論的是指令式程式設計并發中的可變狀态與為什麼函數式程式設計更容易寫出并發程式;

可變狀态與不可變狀态

從字面上了解,狀态:某一事務所處的狀況;可變:可以變化的;

那麼可變狀态可以了解成事務的狀況是可以變化的,如從固态到液态或到氣态;

可變狀态

那麼在程式中可變狀态是怎樣的呢,請閱讀下面代碼:

public class VariableState { 
private int variableInterval=5; 
public int  increment(int x){
   variableInterval=x+variableInterval;
   return variableInterval;
} 

public static void main(String[] args) { 
    VariableState variable=new VariableState();
    variable.increment(5);          //print 10
    //variable.variableInterval=6; 
    variable.increment(5);          //print 15 去掉注釋時 print 11
 }
}
           

在這段代碼中函數increment的輸出結果會随着可變狀态variableInterval的變化而變化;

不可變狀态

有可變的就會有不可變的,繼續看不可變狀态在代碼中是怎樣的:

public class InvariableState {
private final int invariableInterval=5; 
public int increment(int x){
    x=x+invariableInterval;
    return x;
 }
public static void main(String[] args){
    InvariableState invariable=new InvariableState();
    System.out.println(invariable.increment(5));   //print 10
    System.out.println(invariable.increment(5));   //print 10
 }
}  
           

這段代碼中了invariableInterval就是不可變的狀态,不管調多少次increment函數的輸出結果都是一樣的;雖然程式中是存在着可變和不可變狀态,但是着又有什麼關系呢?

  答案是如果你的程式隻是在單線程中運作那麼可變、不可變狀态對你沒有一點影響,但請注意如果你的程式是多線程程式(并發)那麼該可變狀态程式運作一定會出現異常結果(不是每次都會出現,也許運作100才會有5次異常);

拿剛剛上面有可變狀态的代碼來說,如果那段代碼是在多線程中執行那麼就會可能出現異常結果:

public static void main(String[] args) throws InterruptedException {
    VariableState variable=new VariableState();
	Thread [] runnables=new Thread[2];
    for (int i = 0; i < 2; i++) {
        final int finalI = i;
        runnables[i]=new Thread() {
            @Override
            public void run() {
                System.out.println(" i=" + finalI +"  "+variable.increment(5));
            }
        };
    }
    runnables[0].start();
    runnables[1].start();
    runnables[0].join();
    runnables[1].join();
}  
           

輸出結果:

并發程式設計之可變狀态
并發程式設計之可變狀态

  請看上面的示例,運作這段代碼程式會輸出兩個結果,也就是說出現了異常情況,可能大家也都知道出現問題的原因在哪,異常時因為兩個線程同時執行了variableInterval=x+variableInterval,一個線程進來執行了x+variableInterval還沒有寫回variableInterval另一個線程就進來執行x+variableInterval了,接着兩個線程都把各自的結果寫回到variableInterval中,是以就都是10;

  既然在多線程程式存在可變狀态就可能會出現異常結果那我們該怎麼處理呢?不急,請繼續往下看;

在指令式語言中

在指令式程式設計語言中,如Java、C#等,像Python、Golang可以說是指令式與函數式混合型的,雖然Java、C#也都加入了Lambda表達式的支援向函數式程式設計靠攏,但畢竟他的主流還是指令式程式設計;

下面看看在Java中是如何處理可變狀态在多線程中的異常情況的;

public synchronized int increment(int x) {
    variableInterval = x + variableInterval;
    return variableInterval;
}  
           

  還是剛剛那個示例,隻是在方法上添加了synchronized關鍵字,相信很多Java都清楚這是什麼意思,這指的是在increment函數上添加了一個對象鎖,當一個線程進入該函數時必須擷取該對象鎖才能進入,每次隻能一個線程進入線程退出後就會釋放該鎖。在Java中還可以把synchronized當代碼塊、ReentrantLock、Lock等或使用不可變狀态來解決該問題;

  你可能會覺得這麼簡單的問題還需要談論麼,其實多線程與鎖問題一點都不簡單,隻是這裡的示例比較簡單這裡隻是簡單對象的可變狀态,如果是個複雜的對象存在可變狀态呢,如:DataParser或自己寫的複雜對象;在Java中編寫并發程式通常都會用到鎖、原子變量、不可變變量、volatile等,可變狀态是非常常見的等你使用鎖解決後又會出現死鎖問題,等解決了死鎖還存子資源競争又可能會出現性能問題,因為線程(Thread)、鎖(Lock)用不好都會影響性能,這時候你還會覺得簡單麼;

在函數式語言中

  那麼在函數式語言中可變狀态又是怎麼處理呢?答案是你不用處理,因為在函數式語言中沒有可變狀态,不存在可變狀态也就不會遇到可變狀态帶來的各種問題;

  這裡使用同樣是運作在JVM上的函數式語言Clojure來說明不可變狀态,在Clojure中對象是不可變的沒有可變狀态也就不存在Java中的可變狀态問題;

Java的可變狀态示例:

int total=0;
public int sum(int[] numbsers){
    for(int n: numbers){
        total +=n;
    }
  return total;
}  
           

  在上面的代碼中total是狀态可變的,在for循環的過程中不斷的更新狀态,接下來看Clojure中狀态不可變實作方式:

(defn sum[numbers]
  (if (empty? numbers)
    0  
    (+ (first numbers) (sum(rest numbers))) 
  )
)
運作:    
user=> (sumfn[1,2,3,4])
10  
           

  你可能會說這隻是一個遞歸的實作在java中也能夠實作,沒錯這隻是遞歸,但Clojure還有更簡單的實作:

(defn sum [numbers]  
(reduce + numbers))  
           

這夠簡單了吧,抛棄的可變狀态而且代碼更短了,實作并發的時候也不存在可變狀态問題;

  這裡也不是比較說哪種更好,在合适的地方使用合适的方法最好;指令式程式設計與函數式程式設計根本的差別在于:指令式程式設計代碼使用一系列改變狀态的語句組成,而函數式程式設計把數學函數作為第一類對象,将計算過程抽象為表達式求值表達式由純數學函數構成;

文章首發位址:Solinx

http://www.solinx.co/archives/464