理解常見的執行緒同步設施

taney發表於2016-06-09

理解常見的執行緒同步設施

前言

為了便於後面的演示,首先定義一些輔助工具

  • ThreadQueue,用於管理執行緒的掛起與恢復,虛擬碼如下:
  • DisableInterrupts(),禁用硬體中斷,使CPU不會發生上下文切換,避免併發(當然這是針對單處理器而言的)
  • RestoreInterrupts(),恢復硬體中斷

訊號量(Semaphore)

這個概念是由荷蘭電腦科學家Dijkstra(迪傑斯特拉)發明的,他的靈感來自鐵路上的訊號燈。在鐵路上,訊號燈的一個用途是標識前方路段是否有火車,後車根據訊號燈的狀態決定是停車等待還是繼續行進。如下圖所示,鐵路被分為很多段,每段同時只能有一列火車,當火車進入到某段時,該段的訊號燈切換為紅燈,示意後方火車停車等待,當火車離開該段時,訊號燈切換為綠燈,示意後方列車可以進入該段。

鐵路訊號燈示意圖

訊號量和上面所描述的鐵路訊號燈是一樣的,但有兩種形式: binary semaphore(二元訊號量)和 counting semaphore(計數訊號量),二元訊號量相當於每段只能容納一列火車,計數訊號量相當於每段能容納一定數量的火車。和現實中的訊號燈一樣,訊號量具有兩個操作:P(嘗試進入)和V(離開)。(注:P和V是荷蘭語單詞的首字母)

訊號量實現的虛擬碼

應用

二元訊號量可用於保護監界區(critical section),實現互斥訪問。計數訊號量可用來控制多個執行緒對資源的訪問。

生產者 – 消費者問題

這個問題描述的是多個生產者和多個消費者共同操作同一個固定大小資料緩衝區的場景

  • 資料緩衝區有固定的容量
  • 當緩衝區沒有滿時生產者可以往裡面放資料,否則等待
  • 當緩衝區不為空時,消費者可以從裡面取資料,否則等待

解決這個問題需要3個訊號量,一個二元訊號量,用於避免緩衝區被多個執行緒併發操作,一個計數訊號量,表示緩衝區剩餘空間,用於控制生產者的生產與等待,一個計數訊號量,表示緩衝區現有資料的數量,用於控制消費者的消費與等待。Java示例:

鎖(Lock)

鎖也叫 mutex(mutual exclusion的合成詞,互斥量),虛擬碼:

鎖 與 二元訊號量 的區別

上面的虛擬碼是對鎖的一個基本的實現,所以看起來和二元訊號量沒有區別,雖然從用途上看它們也確實是一樣的,都是用於實現互斥,保護臨界區的,但在具體實現中,鎖是有所有權的概念的,也就是鎖會關聯到某個執行緒,鎖的釋放只能由獲取它的執行緒執行,而訊號量則沒有限制,而且鎖可以實現為“可重入”,也就是同一個執行緒可以獲取多次,但訊號量則不行。

應用

取款操作前需要檢查餘額是否夠,如果夠才執行扣款操作,虛擬碼:

問題:如果餘額正好為amount,這時多個執行緒同時執行到判斷語句,這時條件為真,然後都同時執行了扣款操作,則餘額就被扣成負的了,這裡判斷、扣款操作其實構成了一個臨界區,需要實現互斥,所以需要加鎖進行保護:

條件變數(Condition variable)

條件變數是用來和鎖配合使用的物件,每個條件變數都會關聯到一個鎖,只有擁有對應鎖的執行緒才能使用與之關聯的條件變數,虛擬碼:

條件變數的作用

回顧前面用訊號量解決的“生產者 – 消費者”問題,用鎖同樣也能解決:把二元訊號量換鎖,去掉另外兩個計數訊號量,通過迴圈實現等待。

上面思路是在迴圈裡臨時釋放鎖,讓出佔有權,再獲取鎖,再判斷,直到滿足條件才往下繼續。但這種方法會產生很多次不必要的迴圈,嚴重浪費CPU資源,當然可以在釋放鎖讓執行緒睡眠一段時間,但睡多長時間又不好確定。

這種情況條件變數就派上用場了,條件變數的作用就是在臨界區中臨時釋放鎖並讓當前執行緒進入等待狀態。用條件變數解決“生產者 – 消費者”問題:

監視器(Monitor)

監視器呢就是一種既支援互斥操作,又具有讓執行緒進行條件等待的功能的同步設施,所以 監視器 = 鎖+條件變數。

監視器的語義是這樣的:

  • 同時只有一個執行緒在執行
  • 當有執行緒在執行時,新進來的執行緒會被阻塞
  • 正在執行的執行緒需要檢查某個條件
    • 如果不滿足則進入等待狀態,讓出監視器的所有權,讓其它執行緒執行
    • 如果滿足則繼續執行,執行完的發出訊號,通知其它執行緒進入監視器

如下圖所示,Entry Set表示等待進入的執行緒集合,The Owner表示監視器的所有者,即當前正在執行的執行緒,Wait Set表示等待條件的執行緒集合

Monitor示意圖

其實上面用一個鎖 + 兩個條件變數 解決“生產者 – 消費者”問題的程式碼就構成了一個監視器。

在Java中,方法可以加synchronized關鍵字實現互斥,頂層類Object具有wait、notify、notifyAll方法,所以每個Java物件其實都可以是一個監視器。

兩種風格的監視器:Hoare-style 與 Mesa-style

如果你仔細分析上面的條件變數示例程式碼時,你肯定有個疑問:為什麼要把await放在while迴圈裡,用if不行嗎

這是因為Java中的條件變數是Mesa-style的(大多數條件變數的實現都是Mesa-style的),如果是Hoare-style的話就可以用if了,這兩種風格的區別是

  • Mesa:當前正在執行的執行緒發出訊號時,所有等待的執行緒都被喚醒,所以每個執行緒都不一定能得到立即執行,如果它沒有搶到鎖,則還要進入等待狀態,所以當某個執行緒實際執行時,條件可能已經變了,所以需要用while迴圈重複檢查
  • Hoare:當前正在執行的執行緒發出訊號時,只有一個執行緒會被喚醒,並且鎖的所有權會轉交給它,所以不需要重複檢查條件

應用

用監視器實現訊號量

虛擬碼

變數的同步操作

在編譯器對目的碼進行優化時,可能會為了提高變數的訪問速度,在修改變數值後並沒有把值重新寫回記憶體,而是放在CPU暫存器裡,這在多執行緒環境中就可能會出現一個執行緒更新了變數的值,但另一個執行緒並不能及時讀取到最新的值。C#和Java中的volatile關鍵字正是解決這個問題的,當一個變數由volatile修飾後,編譯器便不會對它進行優化,所以能保證執行緒讀取某個變數的值時讀取的是最新的值。

再看這條語句++i;,它不是執行緒安全的,雖然只有一條語句,但編譯為CPU指令實際上是三個步驟:讀到暫存器、計算、寫回記憶體,這三個步驟就可能被多個執行緒交錯地執行,比如:i最初為0,如果兩個執行緒a和b都執行了該語句,則理應i的值變為2,但可能a執行到���算時,b執行到讀取,因為a還未將新值寫入記憶體,所以b讀取的是0,當a和b都完成寫入後,i的值最終為1,對於這種情況大多數平臺都提供了原子性操作變數的設施,比如.NET的Interlocked和Java中java.util.concurrent.atomic包下的類。

參考

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

理解常見的執行緒同步設施 理解常見的執行緒同步設施

相關文章