java併發之非阻塞算法介紹

在併發上下文中,非阻塞算法是一種容許線程在阻塞其餘線程的狀況下訪問共享狀態的算法。在絕大多數項目中,在算法中若是一個線程的掛起沒有致使其它的線程掛起,咱們就說這個算法是非阻塞的。html

爲了更好的理解阻塞算法和非阻塞算法之間的區別,我會先講解阻塞算法而後再講解非阻塞算法。java

阻塞併發算法

一個阻塞併發算法通常分下面兩步:算法

  • 執行線程請求的操做
  • 阻塞線程直到能夠安全地執行操做

不少算法和併發數據結構都是阻塞的。例如,java.util.concurrent.BlockingQueue的不一樣實現都是阻塞數據結構。若是一個線程要往一個阻塞隊列中插入一個元素,隊列中沒有足夠的空間,執行插入操做的線程就會阻塞直到隊列中有了能夠存放插入元素的空間。緩存

下圖演示了一個阻塞算法保證一個共享數據結構的行爲:安全

concurrency

非阻塞併發算法

一個非阻塞併發算法通常包含下面兩步:數據結構

  • 執行線程請求的操做
  • 通知請求線程操做不能被執行

Java也包含幾個非阻塞數據結構。AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference都是非阻塞數據結構的例子。多線程

下圖演示了一個非阻塞算法保證一個共享數據結構的行爲:併發

Non-concurrency

非阻塞算法 vs 阻塞算法

阻塞算法和非阻塞算法的主要不一樣在於上面兩部分描述的它們的行爲的第二步。換句話說,它們之間的不一樣在於當請求操做不可以執行時阻塞算法和非阻塞算法會怎麼作。ide

阻塞算法會阻塞線程知道請求操做能夠被執行。非阻塞算法會通知請求線程操做不可以被執行,並返回。性能

一個使用了阻塞算法的線程可能會阻塞直到有可能去處理請求。一般,其它線程的動做使第一個線程執行請求的動做成爲了可能。 若是,因爲某些緣由線程被阻塞在程序某處,所以不能讓第一個線程的請求動做被執行,第一個線程會阻塞——可能一直阻塞或者直到其餘線程執行完必要的動做。

例如,若是一個線程產生往一個已經滿了的阻塞隊列裏插入一個元素,這個線程就會阻塞,直到其餘線程從這個阻塞隊列中取走了一些元素。若是因爲某些緣由,從阻塞隊列中取元素的線程假定被阻塞在了程序的某處,那麼,嘗試往阻塞隊列中添加新元素的線程就會阻塞,要麼一直阻塞下去,要麼知道從阻塞隊列中取元素的線程最終從阻塞隊列中取走了一個元素。

非阻塞併發數據結構

在一個多線程系統中,線程間一般經過一些數據結構」交流「。例如能夠是任何的數據結構,從變量到更高級的俄數據結構(隊列,棧等)。爲了確保正確,併發線程在訪問這些數據結構的時候,這些數據結構必須由一些併發算法來保證。這些併發算法讓這些數據結構成爲併發數據結構

若是某個算法確保一個併發數據結構是阻塞的,它就被稱爲是一個阻塞算法。這個數據結構也被稱爲是一個阻塞,併發數據結構

若是某個算法確保一個併發數據結構是非阻塞的,它就被稱爲是一個非阻塞算法。這個數據結構也被稱爲是一個非阻塞,併發數據結構

每一個併發數據結構被設計用來支持一個特定的通訊方法。使用哪一種併發數據結構取決於你的通訊須要。在接下里的部分,我會引入一些非阻塞併發數據結構,並講解它們各自的適用場景。經過這些併發數據結構工做原理的講解應該能在非阻塞數據結構的設計和實現上一些啓發。

Volatile 變量

Java中的volatile變量是直接從主存中讀取值的變量。當一個新的值賦給一個volatile變量時,這個值老是會被當即寫回到主存中去。這樣就確保了,一個volatile變量最新的值老是對跑在其餘CPU上的線程可見。其餘線程每次會從主存中讀取變量的值,而不是好比線程所運行CPU的CPU緩存中。

colatile變量是非阻塞的。修改一個volatile變量的值是一耳光原子操做。它不可以被中斷。不過,在一個volatile變量上的一個 read-update-write 順序的操做不是原子的。所以,下面的代碼若是由多個線程執行可能致使競態條件

1 volatile myVar = 0;
2 ...
3 int temp = myVar;
4 temp++;
5 myVar = temp;

首先,myVar這個volatile變量的值被從主存中讀出來賦給了temp變量。而後,temp變量自增1。而後,temp變量的值又賦給了myVar這個volatile變量這意味着它會被寫回到主存中。

若是兩個線程執行這段代碼,而後它們都讀取myVar的值,加1後,把它的值寫回到主存。這樣就存在myVar僅被加1,而沒有被加2的風險。

你可能認爲你不會寫像上面這樣的代碼,可是在實踐中上面的代碼等同於以下的代碼:

1 myVar++;

執行上面的代碼時,myVar的值讀到一個CPU寄存器或者一個本地CPU緩存中,myVar加1,而後這個CPU寄存器或者CPU緩存中的值被寫回到主存中。

單個寫線程的情景

在一些場景下,你僅有一個線程在向一個共享變量寫,多個線程在讀這個變量。當僅有一個線程在更新一個變量,無論有多少個線程在讀這個變量,都不會發生競態條件。所以,不管時候當僅有一個線程在寫一個共享變量時,你能夠把這個變量聲明爲volatile

當多個線程在一個共享變量上執行一個 read-update-write 的順序操做時纔會發生競態條件。若是你只有一個線程在執行一個 raed-update-write 的順序操做,其餘線程都在執行讀操做,將不會發生競態條件。

下面是一個單個寫線程的例子,它沒有采起同步手段但任然是併發的。

 1 public class SingleWriterCounter{
 2     private volatile long count = 0;
 3 
 4     /**
 5      *Only one thread may ever call this method
 6      *or it will lead to race conditions
 7      */
 8      public void inc(){
 9          this.count++;
10      }
11 
12      /**
13       *Many reading threads may call this method
14       *@return
15       */
16       public long count(){
17           return this.count;
18       }
19 }

多個線程訪問同一個Counter實例,只要僅有一個線程調用inc()方法,這裏,我不是說在某一時刻一個線程,個人意思是,僅有相同的,單個的線程被容許去調用inc()>方法。多個線程能夠調用count()方法。這樣的場景將不會發生任何競態條件。

下圖,說明了線程是如何訪問count這個volatile變量的。

single_writer

基於volatile變量更高級的數據結構

使用多個volatile變量去建立數據結構是能夠的,構建出的數據結構中每個volatile變量僅被一個單個的線程寫,被多個線程讀。每一個volatile變量可能被一個不一樣的線程寫(但僅有一個)。使用像這樣的數據結構多個線程可使用這些volatile變量以一個非阻塞的方法彼此發送信息。

下面是一個簡單的例子:

 1 public class DoubleWriterCounter{
 2     private volatile long countA = 0;
 3     private volatile long countB = 0;
 4 
 5     /**
 6      *Only one (and the same from thereon) thread may ever call this method,
 7      *or it will lead to race conditions.
 8      */
 9      public void incA(){
10          this.countA++;
11      }
12 
13      /**
14       *Only one (and the same from thereon) thread may ever call this method, 
15       *or it will  lead to race conditions.
16       */
17       public void incB(){
18           this.countB++;
19       }
20 
21       /**
22        *Many reading threads may call this method
23        */
24       public long countA(){
25           return this.countA;
26       }
27 
28      /**
29       *Many reading threads may call this method
30       */
31       public long countB(){
32           return this.countB;
33       }
34 }

如你所見,DoubleWriterCoounter如今包含兩個volatile變量以及兩對自增和讀方法。在某一時刻,僅有一個單個的線程能夠調用inc(),僅有一個單個的線程能夠訪問incB()。不過不一樣的線程能夠同時調用incA()incB()countA()countB()能夠被多個線程調用。這將不會引起競態條件。

DoubleWriterCoounter能夠被用來好比線程間通訊。countA和countB能夠分別用來存儲生產的任務數和消費的任務數。下圖,展現了兩個線程經過相似於上面的一個數據結構進行通訊的。

volatile

聰明的讀者應該已經意識到使用兩個SingleWriterCounter能夠達到使用DoubleWriterCoounter的效果。若是須要,你甚至可使用多個線程和SingleWriterCounter實例。

使用CAS的樂觀鎖

變量是不合適的。你將會須要一些類型的排它鎖(悲觀鎖)訪問這個變量。下面代碼演示了使用Java中的同步塊進行排他訪問的。若是你確實須要多個線程區寫同一個共享變量,volatile
 1 public class SynchronizedCounter{
 2     long count = 0;
 3 
 4     public void inc(){
 5         synchronized(this){
 6             count++;
 7         }
 8     }
 9 
10     public long count(){
11         synchronized(this){
12             return this.count;
13         }
14     }
15 }

注意,,inc()count()方法都包含一個同步塊。這也是咱們像避免的東西——同步塊和 wait()-notify 調用等。

咱們可使用一種Java的原子變量來代替這兩個同步塊。在這個例子是AtomicLong。下面是SynchronizedCounter類的AtomicLong實現版本。

 1 import java.util.concurrent.atomic.AtomicLong;
 2 
 3 public class AtomicLong{
 4     private AtomicLong count = new AtomicLong(0);
 5 
 6     public void inc(){
 7         boolean updated = false;
 8         while(!updated){
 9             long prevCount = this.count.get();
10             updated = this.count.compareAndSet(prevCount, prevCount + 1);
11         }
12     }
13 
14     public long count(){
15         return this.count.get();
16     }
17 }
方法的實現。方法中再也不含有一個同步塊。而是被下面這些代碼替代:這個版本僅僅是上一個版本的線程安全版本。這一版咱們感興趣的是inc()inc()
1 boolean updated = false;
2 while(!updated){
3     long prevCount = this.count.get();
4     updated = this.count.compareAndSet(prevCount, prevCount + 1);
5 }

上面這些代碼並非一個原子操做。也就是說,對於兩個不一樣的線程去調用inc()方法,而後執行long prevCount = this.count.get()語句,所以得到了這個計數器的上一個count。可是,上面的代碼並無包含任何的競態條件。

祕密就在於while循環裏的第二行代碼。compareAndSet()方法調用是一個原子操做。它用一個指望值和AtomicLong 內部的值去比較,若是這兩個值相等,就把AtomicLong內部值替換爲一個新值。compareAndSet()一般被CPU中的compare-and-swap指令直接支持。所以,不須要去同步,也不須要去掛起線程。

假設,這個AtomicLong的內部值是20,。而後,兩個線程去讀這個值,都嘗試調用compareAndSet(20, 20 + 1)。儘管compareAndSet()是一個原子操做,這個方法也會被這兩個線程相繼執行(某一個時刻只有一個)。

第一個線程會使用指望值20(這個計數器的上一個值)與AtomicLong的內部值進行比較。因爲兩個值是相等的,AtomicLong會更新它的內部值至21(20 + 1 )。變量updated被修改成true,while循環結束。

如今,第二個線程調用compareAndSet(20, 20 + 1)。因爲AtomicLong的內部值再也不是20,這個調用將不會成功。AtomicLong的值不會再被修改成21。變量,updated被修改成false,線程將會再次在while循環外自旋。這段時間,它會讀到值21並企圖把值更新爲22。若是在此期間沒有其它線程調用inc()。第二次迭代將會成功更新AtomicLong的內部值到22。

爲何稱它爲樂觀鎖

上一部分展示的代碼被稱爲樂觀鎖(optimistic locking)。樂觀鎖區別於傳統的鎖,有時也被稱爲悲觀鎖。傳統的鎖會使用同步塊或其餘類型的鎖阻塞對臨界區域的訪問。一個同步塊或鎖可能會致使線程掛起。

樂觀鎖容許全部的線程在不發生阻塞的狀況下建立一份共享內存的拷貝。這些線程接下來可能會對它們的拷貝進行修改,並企圖把它們修改後的版本寫回到共享內存中。若是沒有其它線程對共享內存作任何修改, CAS操做就容許線程將它的變化寫回到共享內存中去。若是,另外一個線程已經修改了共享內存,這個線程將不得再也不次得到一個新的拷貝,在新的拷貝上作出修改,並嘗試再次把它們寫回到共享內存中去。

稱之爲「樂觀鎖」的緣由就是,線程得到它們想修改的數據的拷貝並作出修改,在樂觀的假在此期間沒有線程對共享內存作出修改的狀況下。當這個樂觀假設成立時,這個線程僅僅在無鎖的狀況下完成共享內存的更新。當這個假設不成立時,線程所作的工做就會被丟棄,但任然不使用鎖。

樂觀鎖使用於共享內存競用不是很是高的狀況。若是共享內存上的內容很是多,僅僅由於更新共享內存失敗,就用浪費大量的CPU週期用在拷貝和修改上。可是,若是砸共享內存上有大量的內容,不管如何,你都要把你的代碼設計的產生的爭用更低。

樂觀鎖是非阻塞的

咱們這裏提到的樂觀鎖機制是非阻塞的。若是一個線程得到了一份共享內存的拷貝,當嘗試修改時,發生了阻塞,其它線程去訪問這塊內存區域不會發生阻塞。

對於一個傳統的加鎖/解鎖模式,當一個線程持有一個鎖時,其它全部的線程都會一直阻塞直到持有鎖的線程再次釋放掉這個鎖。若是持有鎖的這個線程被阻塞在某處,這個鎖將很長一段時間不能被釋放,甚至可能一直不能被釋放。

不可替換的數據結構

簡單的CAS樂觀鎖能夠用於共享數據結果,這樣一來,整個數據結構均可以經過一個單個的CAS操做被替換成爲一個新的數據結構。儘管,使用一個修改後的拷貝來替換真個數據結構並不老是可行的。

假設,這個共享數據結構是隊列。每當線程嘗試從向隊列中插入或從隊列中取出元素時,都必須拷貝這個隊列而後在拷貝上作出指望的修改。咱們能夠經過使用一個AtomicReference來達到一樣的目的。拷貝引用,拷貝和修改隊列,嘗試替換在AtomicReference中的引用讓它指向新建立的隊列。

然而,一個大的數據結構可能會須要大量的內存和CPU週期來複制。這會使你的程序佔用大量的內存和浪費大量的時間再拷貝操做上。這將會下降你的程序的性能,特別是這個數據結構的競用很是高狀況下。更進一步說,一個線程花費在拷貝和修改這個數據結構上的時間越長,其它線程在此期間修改這個數據結構的可能性就越大。如你所知,若是另外一個線程修改了這個數據結構在它被拷貝後,其它全部的線程都不等再也不次執行 拷貝-修改 操做。這將會增大性能影響和內存浪費,甚至更多。

接下來的部分將會講解一種實現非阻塞數據結構的方法,這種數據結構能夠被併發修改,而不只僅是拷貝和修改。

共享預期的修改

用來替換拷貝和修改整個數據結構,一個線程能夠共享它們對共享數據結構預期的修改。一個線程向對修改某個數據結構的過程變成了下面這樣:

  • 檢查是否另外一個線程已經提交了對這個數據結構提交了修改
  • 若是沒有其餘線程提交了一個預期的修改,建立一個預期的修改,而後向這個數據結構提交預期的修改
  • 執行對共享數據結構的修改
  • 移除對這個預期的修改的引用,向其它線程發送信號,告訴它們這個預期的修改已經被執行

如你所見,第二步能夠阻塞其餘線程提交一個預期的修改。所以,第二步實際的工做是做爲這個數據結構的一個鎖。若是一個線程已經成功提交了一個預期的修改,其餘線程就不能夠再提交一個預期的修改直到第一個預期的修改執行完畢。

若是一個線程提交了一個預期的修改,而後作一些其它的工做時發生阻塞,這時候,這個共享數據結構其實是被鎖住的。其它線程能夠檢測到它們不可以提交一個預期的修改,而後回去作一些其它的事情。很明顯,咱們須要解決這個問題。

可完成的預期修改

爲了不一個已經提交的預期修改能夠鎖住共享數據結構,一個已經提交的預期修改必須包含足夠的信息讓其餘線程來完成此次修改。所以,若是一個提交了預期修改的線程從未完成此次修改,其餘線程能夠在它的支持下完成此次修改,保證這個共享數據結構對其餘線程可用。

下圖說明了上面描述的非阻塞算法的藍圖:

non-blocking

修改必須被當作一個或多個CAS操做來執行。所以,若是兩個線程嘗試去完成同一個預期修改,僅有一個線程能夠全部的CAS操做。一旦一條CAS操做完成後,再次企圖完成這個CAS操做都不會「得逞」。

A-B-A問題

上面演示的算法能夠稱之爲A-B-A問題。A-B-A問題指的是一個變量被從A修改到了B,而後又被修改回A的一種情景。其餘線程對於這種狀況卻一無所知。

若是線程A檢查正在進行的數據更新,拷貝,被線程調度器掛起,一個線程B在此期可能能夠訪問這個共享數據結構。若是線程對這個數據結構執行了所有的更新,移除了它的預期修改,這樣看起來,好像線程A自從拷貝了這個數據結構以來沒有對它作任何的修改。然而,一個修改確實已經發生了。當線程A繼續基於如今已通過期的數據拷貝執行它的更新時,這個數據修改已經被線程B的修改破壞。

下圖說明了上面提到的A-B-A問題:

a-b-a

A-B-A問題的解決方案

A-B-A一般的解決方法就是再也不僅僅替換指向一個預期修改對象的指針,而是指針結合一個計數器,而後使用一個單個的CAS操做來替換指針 + 計數器。這在支持指針的語言像C和C++中是可行的。所以,儘管當前修改指針被設置回指向 「不是正在進行的修改」(no ongoing modification),指針 + 計數器的計數器部分將會被自增,使修改對其它線程是可見的。

在Java中,你不能將一個引用和一個計數器歸併在一塊兒造成一個單個的變量。不過Java提供了AtomicStampedReference類,利用這個類可使用一個CAS操做自動的替換一個引用和一個標記(stamp)。

一個非阻塞算法模板

下面的代碼意在在如何實現非阻塞算法上一些啓發。這個模板基於這篇教程所講的東西。

注意:在非阻塞算法方面,我並非一位專家,因此,下面的模板可能錯誤。不要基於我提供的模板實現本身的非阻塞算法。這個模板意在給你一個關於非阻塞算法大體是什麼樣子的一個idea。若是,你想實現本身的非阻塞算法,首先學習一些實際的工業水平的非阻塞算法的時間,在實踐中學習更多關於非阻塞算法實現的知識。

 1 import java.util.concurrent.atomic.AtomicBoolean;
 2 import java.util.concurrent.atomic.AtomicStampedReference;
 3 
 4 public class NonblockingTemplate{
 5     public static class IntendedModification{
 6         public AtomicBoolean completed = new AtomicBoolean(false);
 7     }
 8 
 9     private AtomicStampedReference<IntendedModification> ongoinMod = new AtomicStampedReference<IntendedModification>(null, 0);
10     //declare the state of the data structure here.
11 
12     public void modify(){
13         while(!attemptModifyASR());
14     }
15 
16 
17     public boolean attemptModifyASR(){
18         boolean modified = false;
19 
20         IntendedMOdification currentlyOngoingMod = ongoingMod.getReference();
21         int stamp = ongoingMod.getStamp();
22 
23         if(currentlyOngoingMod == null){
24             //copy data structure - for use
25             //in intended modification
26 
27             //prepare intended modification
28             IntendedModification newMod = new IntendModification();
29 
30             boolean modSubmitted = ongoingMod.compareAndSet(null, newMod, stamp, stamp + 1);
31 
32             if(modSubmitted){
33                  //complete modification via a series of compare-and-swap operations.
34                 //note: other threads may assist in completing the compare-and-swap
35                 // operations, so some CAS may fail
36                 modified = true;
37             }
38         }else{
39              //attempt to complete ongoing modification, so the data structure is freed up
40             //to allow access from this thread.
41             modified = false;
42         }
43 
44         return modified;
45     }
46 }

非阻塞算法是不容易實現的

正確的設計和實現非阻塞算法是不容易的。在嘗試設計你的非阻塞算法以前,看一看是否已經有人設計了一種非阻塞算法正知足你的需求。

Java已經提供了一些非阻塞實現(好比 ConcurrentLinkedQueue),相信在Java將來的版本中會帶來更多的非阻塞算法的實現。

除了Java內置非阻塞數據結構還有不少開源的非阻塞數據結構可使用。例如,LAMX Disrupter和Cliff Click實現的非阻塞 HashMap。查看個人Java concurrency references page查看更多的資源。

使用非阻塞算法的好處

非阻塞算法和阻塞算法相比有幾個好處。下面讓咱們分別看一下:

選擇

非阻塞算法的第一個好處是,給了線程一個選擇當它們請求的動做不可以被執行時作些什麼。再也不是被阻塞在那,請求線程關於作什麼有了一個選擇。有時候,一個線程什麼也不能作。在這種狀況下,它能夠選擇阻塞或自我等待,像這樣把CPU的使用權讓給其它的任務。不過至少給了請求線程一個選擇的機會。

在一個單個的CPU系統可能會掛起一個不能執行請求動做的線程,這樣可讓其它線程得到CPU的使用權。不過即便在一個單個的CPU系統阻塞可能致使死鎖,線程飢餓等併發問題。

沒有死鎖

非阻塞算法的第二個好處是,一個線程的掛起不能致使其它線程掛起。這也意味着不會發生死鎖。兩個線程不能互相彼此等待來得到被對方持有的鎖。由於線程不會阻塞當它們不能執行它們的請求動做時,它們不能阻塞互相等待。非阻塞算法任然可能產生活鎖(live lock),兩個線程一直請求一些動做,但一直被告知不可以被執行(由於其餘線程的動做)。

沒有線程掛起

掛起和恢復一個線程的代價是昂貴的。沒錯,隨着時間的推移,操做系統和線程庫已經愈來愈高效,線程掛起和恢復的成本也不斷下降。不過,線程的掛起和戶對任然須要付出很高的代價。

不管何時,一個線程阻塞,就會被掛起。所以,引發了線程掛起和恢復過載。因爲使用非阻塞算法線程不會被掛起,這種過載就不會發生。這就意味着CPU有可能花更多時間在執行實際的業務邏輯上而不是上下文切換。

在一個多個CPU的系統上,阻塞算法會對阻塞算法產生重要的影響。運行在CPUA上的一個線程阻塞等待運行在CPU B上的一個線程。這就下降了程序天生就具有的並行水平。固然,CPU A能夠調度其餘線程去運行,可是掛起和激活線程(上下文切換)的代價是昂貴的。須要掛起的線程越少越好。

下降線程延遲

在這裏咱們提到的延遲指的是一個請求產生到線程實際的執行它之間的時間。由於在非阻塞算法中線程不會被掛起,它們就不須要付昂貴的,緩慢的線程激活成本。這就意味着當一個請求執行時能夠獲得更快的響應,減小它們的響應延遲。

非阻塞算法一般忙等待直到請求動做能夠被執行來下降延遲。固然,在一個非阻塞數據數據結構有着很高的線程爭用的系統中,CPU可能在它們忙等待期間中止消耗大量的CPU週期。這一點須要緊緊記住。非阻塞算法可能不是最好的選擇若是你的數據結構哦有着很高的線程爭用。不過,也經常存在經過重構你的程序來達到更低的線程爭用。

相關文章
相關標籤/搜索