串行無鎖化之我見


加鎖是為了避免在並發環境下,同時訪問共享資源產生的風險問題。那么,在並發環境下,是否必須加鎖?答案是否定的。並非所有的並發都需要加鎖。適當地降低鎖的粒度,甚至采用無鎖化的設計,更能提升並發能力。

 

比如,JDK中的ConcurrentHashMap,巧妙地采用了桶粒度的鎖,避免了put和get中對整個map的鎖定,尤其在get中,只對一個HashEntry做鎖定操作,性能提升是顯而易見的。
又比如,在程序中可以合理考慮業務數據的隔離性,實現無鎖化的並發。例如,程序中預計會有兩個並發任務,每個任務可以對所需要處理的數據進行分組。任務1去處理尾數為0到4的業務數據,任務2處理尾數為5到9的業務數據。那么,這兩個並發任務所要處理的數據天然是隔離的,也就不需要加鎖。

阿里有一道筆試題如下

無鎖化編程有哪些常見方法?

 

  • 針對計數器,可以使用原子加
  • 只有一個生產者和一個消費者,那么就可以做到免鎖訪問環形緩沖區(Ring Buffer)
  • RCU(Read-Copy-Update),新舊副本切換機制,對於舊副本可以采用延遲釋放的做法
  • CAS(Compare-and-Swap),如無鎖棧,無鎖隊列等待

這四種都是,

A 原子操作是匯編級別支持的指令lock xadd,java中有AutomicInteger都是對其的封裝。簡單變量的線程同步用這種方式效率最高。
B 多個生產者和多個消費者,一樣可以做到免鎖訪問,但要使用原子操作。這里的意思應該是不用原子操作級別的免鎖,理由也很簡單,生產者和消費者需要修改的位置是分開的(生產者加在尾部,消費者從頭部消費),且只有一個讀一個寫,不會發生沖突。所以只有一點需要關注,就是尾部指針和頭部指針每次需要比較以避免生產溢出或者過度消費,而簡單變量的讀操作都是原子的。
C 類似的一個概念叫CopyOnWrite,復制一份,修改完后,替換回去時只需替換一個指針或引用,鎖住的粒度非常小。但有可能還有線程持有的是舊的指針,因此舊的副本需要延遲釋放。
D 匯編級別支持的指令cmpxchg,鎖定內存地址,比較地址中修改前的內容是否與修改時的值一致,如果不一致就說明有其他線程改動,需要重新做。如,內存地址0x123456中原來存放的是10101010,但CPU執行到cmpxchg指令時,發現內存中變成了11111111,那么就認為其他線程已經修改了這個地址的值,需要重新讀取0x123456中的值11111111,再做一次cmpxchg,如果這次發現內存中仍然是11111111,那么cmpxchg就會把新的值寫入到0x123456中去。這里面有個ABA問題,就是有線程改了2次從11111111 -> 10111111 -> 11111111,那么CAS操作是識別不了的,需要從業務層去避免,如果直接在0x123456再放一個地址值,而地址值如果不先釋放再重新申請內存,就不會出現重復值。
 
其中,串行無鎖化就是選項B采用的思想,大名鼎鼎的協程其實也是這種思想的應用,線程切換的時候,會保存在CPU的寄存器里面, 協程切換的時候,卻都是由用戶自己的實現的。協程擁有自己的寄存器上下文和棧。
 
在netty中,也采用了這樣的思想,  NioEventLoop維護了一個任務隊列,隊列在創建NioEventLoop時被初始化,是用來實現串行無鎖化的載體。 NioEventLoop封裝了一個線程,用來處理客戶端的連接事件,讀寫事件,以及處理任務隊列中的任務。
  NioEventLoop繼承SingleThreadEventLoop,SingleThreadEventLoop繼承SingleThreadEventExecutor。其中這個線程在SingleThreadEventExecutor中定義:
private volatile Thread thread;

任務隊列則是這樣定義:

//SingleThreadEventExecutor類
this.taskQueue = this.newTaskQueue(this.maxPendingTasks);
//NioEventLoop類
protected Queue<Runnable> newTaskQueue(int maxPendingTasks) {
        return maxPendingTasks == 2147483647 ? PlatformDependent.newMpscQueue() : PlatformDependent.newMpscQueue(maxPendingTasks);
    }

上面任務隊列的實現就是調用inEventLoop()先通過thread來判斷當前線程是否是創建NioEventLoop時綁定的線程,如果是就直接執行讀寫操作,如果不是就說明是其他線程,把讀寫操作封裝成任務放在任務隊列中。inEventLoop源碼:

 

//SingleThreadEventExecutor

private volatile Thread thread;

public boolean inEventLoop(Thread thread){

    return thread = this.thread
}

NioEventLoop封裝的線程在SingleThreadEventExecutor內定義,並在創建的時候初始化。

 

 這樣一次完整的工作流程就這樣完成,然而這樣設計CPU利用率其實並不高,並發程度不夠。但這種設計是線程安全的,Netty線程間不需要做同步控制,Netty可以通過調整NIO線程池的線程參數,可以同時啟動多個串行化的線程並行運行,這樣性能就提升了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM