Concurrency(二十三: 非阻塞演算法下)

MenfreXu發表於2019-04-14

共享預期修改

一個執行緒能夠共享它的預期修改來替代拷貝和修改記憶體中的整個資料結構.一個執行緒需要進行如下幾個操作來實現對一個共享資料的預期修改:

  1. 檢查是否有其他執行緒已經提交一個預期修改到資料結構了
  2. 如果其他執行緒還沒有提交一個預期修改,那就建立一個預期修改(通常是一個物件)並提交它到資料結構(通過一個cas操作).
  3. 對共享資料結構執行預期修改.
  4. 移除預期修改的引用來傳送訊號給其他執行緒通知它們預期修改已經被執行了.

如你所見,第二個操作提交一個預期修改會阻塞其他執行緒.因為第二個操作實際上等同於作用在共享資料結構上的鎖.如果一個執行緒成功提交一個預期修改,那麼其他執行緒將無法提交預期修改,直到上一個提交的預期修改被執行為止.

如果一個執行緒提交一個預期修改後因為執行其他任務而發生阻塞,那麼共享資料結構等同於鎖死.共享資料結構並不會直接阻塞其他執行緒來使用它.其他執行緒能夠檢測到無法提交預期修改然後再決定做些什麼.很明顯,我們需要解決這種情況.

可完善的預期修改

為了解決提交一個預期修改會鎖住共享資料結構的問題,一個提交的預期修改物件需要包含足夠的資訊以讓其他執行緒可以繼續完成這些修改.這樣,當執行緒提交一個預期修改後無法完成它時,其他執行緒可以通過它自己的方式來完成這次修改,同時讓共享資料結構能繼續被其他執行緒使用.

下圖描述了上文給出的非阻塞演算法設想:

Concurrency(二十三: 非阻塞演算法下)

修改必須通過一到多次cas操作來進行.因此,如果有兩個執行緒同時嘗試完成預期修改,只會有一個執行緒能夠成功完成.

ABA問題

上文描述的演算法中會遇到ABA問題.ABA問題是指一個變數從A更改到B,再從B被更改到A的時候,其他執行緒無法檢測到變數實際上已經被修改過了.

如果一個執行緒A先檢查是否有正在進行的更改,然後拷貝資料再然後就被執行緒排程器掛起了,此時執行緒B在同一時間訪問共享資料結構.此時如果執行緒B對共享資料執行了一個完整的修改,然後移除預期修改物件的引用,那麼對於執行緒A來說,它會誤以為自從拷貝資料結構後預期修改並沒有被替換過.然而,預期修改的的確確已經被替換過了.當執行緒A基於過期的資料結構副本進行修改時,實際上記憶體中的資料結構已經被執行緒B的修改替換了.

下圖描述了上文討論的ABA問題的場景:

Concurrency(二十三: 非阻塞演算法下)

ABA解決方案

一個通用的解決方案,不單單只是替換掉預期修改物件的指標,同時需要更新一個計數器,且替換預期修改物件指標和更新計數器需要在一個cas操作中完成.這在C和C++的指標中是可行的.因此,即使當前預期修改物件的指標被設定為"沒有進行中的預期修改"的狀態,仍然會有一個計數器來記錄預期修改被更新的次數,以保障更新對其他執行緒可見.

在Java中不能合併一個引用和計數器到一個變數中.但Java中提供了一個AtomicStampedReference物件用於完成在一個cas操作中同時替換引用和一個標記.

一個非阻塞演算法的模版

下面提供了一個程式碼模版,這個模版提供了一個實現非阻塞演算法的思路.這個模版基於上文給出的思路實現.

需要注意的是: 這份模版的作者並不是一個專業非阻塞演算法工程師,模版中可能會有幾處錯誤.所以告誡我們千萬不要基於這個模版去實現自己的非阻塞演算法.這個模版只是示例了非阻塞演算法的實現程式碼的思路.如果你需要實現一個非阻塞演算法,那麼你需要研讀其他更專業的書籍.需要了解一個非阻塞演算法是如何實現和工作的,以及如何在實踐中編碼實現它.(如作者所說,他只是提供了一個思路,筆者在學習完這篇博文後總覺得作者提供非阻塞演算法思路並不完整,所以當作入門資料是可以,但要真正掌握非阻塞演算法還有很長的路要走.)

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicStampedReference;

public class NonblockingTemplate {

    public static class IntendedModification {
        public AtomicBoolean completed =
                new AtomicBoolean(false);
    }

    private AtomicStampedReference<IntendedModification> ongoingMod = new AtomicStampedReference<IntendedModification>(null, 0);

    //declare the state of the data structure here.

    public void modify() {
        while(!attemptModifyASR());
    }

    public boolean attemptModifyASR(){
        boolean modified = false;
    
        IntendedModification currentlyOngoingMod = ongoingMod.getReference();
        int stamp = ongoingMod.getStamp();
    
        if(currentlyOngoingMod == null){
            //copy data structure state - for use
            //in intended modification
        
            //prepare intended modification
            IntendedModification newMod = new IntendedModification();
        
            boolean modSubmitted = ongoingMod.compareAndSet(null, newMod, stamp, stamp + 1);
        
            if(modSubmitted){       
                //complete modification via a series of compare-and-swap operations.
                //note: other threads may assist in completing the compare-and-swap
                // operations, so some CAS may fail
            
                modified = true;
            }
        } else {
            //attempt to complete ongoing modification, so the data structure is freed up
            //to allow access from this thread.
        
            modified = false;
        }    
        return modified;
    }
}
複製程式碼

非阻塞演算法非常難實現

非阻塞演算法很難被正確的設計和實現.在嘗試實現你自己的非阻塞演算法前,不妨檢視一下有沒有人已經實現過了.

Java中已經實現了一小部分非阻塞演算法(例如ConcurrentLinkedQueue)且在未來會有更多的非阻塞演算法實現加入到Java版本中.

除了Java中內建的一些非阻塞演算法實現外,還有一些開源的資料結構可選.例如,LMAX Disrupter(一個類似佇列的資料結構)和由Cliff Click實現的非阻塞版本的HashMap.

非阻塞演算法帶來的好處

對比阻塞演算法,非阻塞演算法能夠給我們帶來諸多好處.以下列出詳細的說明:

可選的

非阻塞演算法帶來的第一個好處是: 執行緒的請求操作被拒絕時可以選擇做些什麼而不是直接被阻塞掉.有時候執行緒的請求操作被拒絕後確實不知道應該做什麼.這個時候可以選擇阻塞或是掛起來讓出CPU執行時間片去做其他任務.但這至少給予了請求執行緒一次選擇的機會.

在單CPU的系統上,當執行緒的請求操作無法被執行時將會被掛起以騰出CPU執行時間片來做其他事情.但是,即使在單CPU的系統上,阻塞演算法仍然會帶來死鎖,飢餓和其他併發問題.

沒有死鎖

第二個好處是: 一個執行緒的掛起不會導致其他執行緒的掛起.這意味著不會有死鎖發生.兩個執行緒不會互相等待對方釋放自己所需要的鎖.執行緒的請求操作不能被執行時不會發生阻塞,因此它們不需要阻塞以相互等待對方執行完成.非阻塞演算法雖然不會發生死鎖,但會發生活鎖,兩個執行緒都在嘗試執行操作,但一直被告知這些操作不能執行(因為其他執行緒正在操作的過程中, 理論上是有可能發生的).

沒有執行緒被掛起

掛起和恢復一個執行緒的效能消耗是十分昂貴的.即使在作業系統和執行緒工具已經非常高效的情況下,掛起和恢復執行緒對效能的消耗已經很小了.但是我們仍然需要記住掛起和恢復一個執行緒是一筆不小的效能消耗(能避免則避免).

當一個執行緒被阻塞掛起時,需要消耗而外的效能來恢復它們.而在非阻塞演算法中,執行緒不會掛起,這些效能消耗就不會發生.這意味著CPU有更多的執行時間片來執行真正的業務邏輯而不是執行緒的上下文切換.

在多執行緒系統中,阻塞演算法會對程式的執行效率產生嚴重的影響.在CPU A上執行的執行緒可能會被阻塞以等待CPU B上執行的執行緒.這會降低應用程式的併發性.即使讓CPU A切換另外一個執行緒來執行,執行緒間的上下文切換仍然是十分昂貴的.越少執行緒被掛起越好.

降低執行緒的延遲

延遲在這裡是指一個執行緒發起請求操作到真正被執行的所經過的時間.執行緒在非阻塞演算法中不會被掛起,因此它們沒有昂貴的恢復成本.這意味著當一個執行緒的請求操作能夠被執行時,執行緒可以快速響應從而最大程度的減少它們的響應延遲.

非阻塞演算法通常可以在請求操作真正能夠被執行時通過繁忙等待的方式來取得最小的響應延遲.當然,如果一個執行緒在非阻塞資料結構上的競爭情況比較激烈的話,那麼CPU會花費大量的執行時間片在繁忙等待上.所以我們需要謹記,多個執行緒在資料結構上競爭情況比較激烈的情況下,非阻塞演算法就顯得不是那麼合適了.然而,比較常見的做法是重構我們的應用,讓執行緒儘量少的爭奪記憶體中的資料結構.

該系列博文為筆者複習基礎所著譯文或理解後的產物,複習原文來自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial

相關文章