這是多線程、并發(fā)控制系列文章第二篇,本文內(nèi)容主要來自Introduction to thread synchronization,并做了部分補充。
上一篇多線程簡述提到編寫并發(fā)代碼很棘手,可能出現(xiàn)以下兩個問題:
- 數(shù)據(jù)爭用 Data Race:一個線程修改數(shù)據(jù)時,另一個線程正在讀取數(shù)據(jù)。如果寫入還沒有完成,就會讀取到損壞的數(shù)據(jù)。
- 競爭條件 Race Condition:系統(tǒng)或進程的輸出依賴不受控制的事件出現(xiàn)順序或出現(xiàn)時機,而實際上應(yīng)按照一定順序執(zhí)行,這一點比 data race 更微妙。即使已經(jīng)避免了 data race,也可能觸發(fā) race condition。
有多種方案應(yīng)對以上問題,這篇文章介紹常用方案之一:同步(synchronization)。
1. 什么是同步 synchronization
Synchronization 是一整套技巧,用以確保多個線程按照預(yù)定規(guī)則執(zhí)行。更具體的說,synchronization 將幫助在多線程程序中實現(xiàn)以下功能:
- 原子性 atomicity:如果代碼包含對多個線程共享數(shù)據(jù)操作的指令,則對該共享數(shù)據(jù)不加限制的并發(fā)操作會造成數(shù)據(jù)爭用,包含這些指令的代碼段稱為臨界區(qū)塊(critical section)。需要確保 critical section 代碼是原子執(zhí)行,atomic operation 不能分解成更小的操作,因此在線程執(zhí)行時其他線程無法插入。
- 排序:有時希望多個線程按照指定順序執(zhí)行,或限制同時訪問資源的線程數(shù)量。通常,無法控制這些行為,這也是 race condition 的根本原因。通過 synchronization,可以協(xié)調(diào)線程按照計劃執(zhí)行。
同步是通過操作系統(tǒng)、支持線程的語言提供的同步原語(synchronization primitive)實現(xiàn)的。使用 synchronization primitive 可以解決多線程中的 data race、race condition 問題。
Synchronization 發(fā)生在硬件和軟件中,也發(fā)生在線程與操作系統(tǒng)進程之間。這篇文章只涉及軟件線程間同步。
2. 常見 synchronization primitive
最常用的 synchronization primitive 包括互斥鎖(mutex)、信號量(semaphore)和條件變量(condition variable)。這些術(shù)語沒有官方定義,不同的實現(xiàn)會形成稍有不同的特征。
操作系統(tǒng)原生的提供了這些工具。例如,Linux 和 macOS 支持 POSIX Threads,也稱為 pthreads。pthreads 是 POSIX 的線程標(biāo)準(zhǔn),定義了創(chuàng)建和操作線程的一套 API,為編寫線程安全的多線程程序提供支持。Windows 在 C Run-Time Libraries(CRT)有一套 synchronization 工具,在概念上類似于 POSIX 線程函數(shù),只是名稱不同。
除非編寫的代碼很底層,否則一般選用編程語言的 synchronization primitive。每種語言都有自己的 synchronization primitive 工具箱,以及其他管理線程的函數(shù)。例如,Java 提供 java.util.concurrent 包,現(xiàn)代 C++ 有自己的線程庫,C# 提供 System.Threading 名稱空間等。當(dāng)然,這些函數(shù)和對象都基于操作系統(tǒng) primitive。
此外,還有其他 synchronization 工具,但這篇文章只涉及上面提到的互斥鎖、信號量和條件變量。因為它們通常用于構(gòu)建更為復(fù)雜的實體。
2.1 互斥鎖 Mutex
互斥鎖(mutual exclusion,簡寫 mutex)是一種同步原語,它在臨界區(qū)域周圍添加限制,以防止 data race。互斥鎖限制一次僅有一個線程訪問 critical section,以此保證原子性。
互斥鎖是 app 中的全局對象,在線程間共享。它提供lock
和unlock
功能,線程即將進入臨界區(qū)域時調(diào)調(diào)用lock
鎖定互斥鎖,執(zhí)行完畢后同一線程調(diào)用unlock
解除鎖定。mutex 的重要特點就是只有鎖定 mutex 的線程可以解鎖。
如果另一個線程執(zhí)行到了臨界區(qū)域,嘗試鎖定已鎖定的互斥鎖,操作系統(tǒng)會將其置于睡眠狀態(tài),直到之前線程執(zhí)行完畢并解除該互斥鎖。因此,只有一個線程可以訪問 critical section,其他線程必須等待。mutex 也稱為鎖定機制(locking mechanism)。
使用 mutex 可以保護一些簡單操作,如并發(fā)線程讀寫共享數(shù)據(jù),以及一次必須由一個線程執(zhí)行的更大、更復(fù)雜的操作。例如,寫入日志文件、修改數(shù)據(jù)庫等。mutex 的 lock、unlock 始終與臨界區(qū)域的邊界匹配。
2.1.1 遞歸互斥鎖 Recursive Mutex
在常規(guī)互斥鎖中,線程鎖定互斥鎖兩次會導(dǎo)致錯誤,但遞歸互斥鎖允許鎖定多次。線程可以連續(xù)鎖定 recursive mutex 多次,而無需先解鎖。但只有第一個鎖定遞歸互斥鎖的線程解除所有鎖定后,其他線程才可以鎖定該遞歸互斥鎖。遞歸互斥鎖也稱為可重入互斥鎖(reentrant mutex)。可重入即在先前調(diào)用結(jié)束前,多次調(diào)用同一個函數(shù)。
Recursive mutex 很難使用,并且容易出錯。必須記錄哪個線程已鎖定互斥鎖多少次,并使用同一線程解鎖對應(yīng)次數(shù)。沒有完全解除遞歸互斥鎖,會導(dǎo)致其他問題。通常,普通互斥鎖能夠解決大部分問題。
2.1.2 讀寫互斥鎖 Reader/Writer Mutex
只要不修改共享資源,并發(fā)的讀取資源不會產(chǎn)生任何問題。如果讀取概率遠(yuǎn)遠(yuǎn)大于寫入,為何要使用互斥鎖?例如,一個經(jīng)常被很多線程訪問的并發(fā)數(shù)據(jù)庫,通過另一線程偶爾寫入,很多線程經(jīng)常讀取。如果使用互斥鎖保護讀寫操作,大部分情況下只會鎖定讀取操作,從而阻止其他線程進行讀取。
Reader/Writer mutex 允許從多個線程并發(fā)讀取,寫入時鎖定資源,禁止其他線程進入,這樣可以將資源鎖定為讀取或?qū)懭肽J健R薷馁Y源,線程必須先獲取 exclusive write lock,而 exclusive write lock 必須等所有 read lock 釋放后才可以進入。
2.2 信號量 Semaphore
信號量(semaphore)是用于編排線程的 synchronization primitive。例如,哪個線程先運行,最多多少線程可以同時訪問資源。就像信號燈控制交通,編程中的信號量控制多線程流。因此,semaphore 也稱為信號機制(signaling mechanism)。因為其同時控制順序和原子性,可以將其視為互斥鎖的進化。稍后會說明將信號量僅用于原子性不是一個好主意。
信號量是 app 中的全局對象,在線程間共享。它包含一個由兩個函數(shù)管理的計數(shù)器,一個增加計數(shù)器、一個減少計數(shù)器,曾被稱為P
和V
,現(xiàn)在使用更易于理解的名稱:acquire
和release
。
信號量控制對共享資源的訪問,計數(shù)器確定同時訪問共享資源的線程數(shù)。初始化信號量時設(shè)定允許的最大線程數(shù),隨后需要訪問共享資源的線程調(diào)用acquire
:
- 如果計數(shù)器大于零,線程可以繼續(xù)。計數(shù)器立即減一,隨后線程執(zhí)行任務(wù)。執(zhí)行完畢后,調(diào)用
release
,計數(shù)器加一。 - 如果計數(shù)器等于零,線程不可以繼續(xù),表示其他線程已經(jīng)占完最大可進入線程數(shù)。操作系統(tǒng)將當(dāng)前線程設(shè)置為休眠,并在計數(shù)器大于零時(即其他線程調(diào)用
release
)喚醒該線程。
與互斥鎖不同,任何線程都可以release
信號量,不僅僅是第一個acquire
信號量的線程。
單個信號量可以限制訪問共享資源最大線程數(shù)。例如,限制多線程數(shù)據(jù)庫連接數(shù)量,其中每個線程都是由連接到服務(wù)器的客戶端觸發(fā)的。
多個信號量組合在一起,可以解決線程排序問題。例如,瀏覽器中的渲染線程必須在下載 HTML 文件的線程完成后才開始。即線程A完成工作后通知線程B,線程B蘇醒后執(zhí)行工作,這也就是著名的生產(chǎn)者消費者問題(Producer-Consumer problem)。
生產(chǎn)者消費者問題也稱為有限緩沖問題(Bounded-buffer problem),是多進程同步問題的經(jīng)典案例。該問題描述了共享固定大小緩沖區(qū)的兩個進程(即所謂的生產(chǎn)者和消費者)在實際運行時會發(fā)生的問題。生產(chǎn)者的作用是生產(chǎn)一定量的數(shù)據(jù)放到緩沖區(qū),然后重復(fù)此過程。與此同時,消費者也在緩沖區(qū)消耗這些數(shù)據(jù)。問題的關(guān)鍵在于要保證生產(chǎn)者在緩沖區(qū)滿時不會繼續(xù)加入數(shù)據(jù),消費者在緩沖區(qū)空時不會消耗數(shù)據(jù)。
要解決該問題,必須讓生產(chǎn)者在緩沖區(qū)滿時休眠,等下次消費者消耗緩沖區(qū)數(shù)據(jù)時才喚醒;在緩沖區(qū)空時讓消費者進入休眠,等下次生產(chǎn)者向緩沖區(qū)添加數(shù)據(jù)后喚醒消費者。常用進程間通信解決該問題,具體方法有信號量等。如果解決方案有問題容易導(dǎo)致死鎖,兩個線程均休眠等待對方喚醒自己。
2.2.1 二進制信號量 Binary Semaphore
計數(shù)器限制為0和1的信號量稱為二進制信號量(binary semaphore)。binary semaphore 一次只能有一個線程訪問共享資源,這一點與互斥鎖相同。事實上,可以使用 binary semaphore 實現(xiàn)類似互斥鎖的行為,但需注意以下兩點:
- 在互斥鎖中,只有加鎖的線程可以解鎖。在信號量中任何線程都可以
release
。如果你想要的只是鎖定機制,使用信號量可能會產(chǎn)生微妙的 bug。 - Semaphore 是協(xié)調(diào)線程的信號機制,mutex 是保護共享資源的鎖定機制。不要使用 semaphore 保護共享資源,也不要使用 mutex 協(xié)調(diào)線程,否則代碼會難以維護。
2.3 條件變量 Condition Variable
條件變量(conditional variable)是另一個用于排序的 synchronization primitive,用于在不同線程間發(fā)送喚醒信號。條件變量總是與互斥鎖并存,單獨使用沒有意義。
Conditional variable 是 app 中的全局對象,線程之間共享。它提供三個函數(shù):wait
、notify_one
、notify_all
,以及為其傳遞互斥鎖的機制。
對條件變量調(diào)用wait
的線程被操作系統(tǒng)置于休眠狀態(tài),其他線程想要喚醒它時調(diào)用notify_one
或notify_all
。notify_one
喚醒一個線程,notify_all
喚醒所有因wait
條件變量而休眠的線程。互斥鎖在內(nèi)部提供休眠、喚醒機制。
條件變量可以在線程之間發(fā)送信號,這是單獨使用互斥鎖無法實現(xiàn)的。使用 conditional variable 可以解決 Producer-Consumer 問題。
3. Synchronization 常見問題
這篇文章中介紹的所有 synchronization primitive 有一個共同點:使線程進入休眠,因此也稱為阻止機制(blocking mechanism)。Blocking mechanism 可以很好的防止并發(fā)線程同時訪問共享資源帶來的 data race、race condition 問題。睡眠的線程不會產(chǎn)生危害,但可能產(chǎn)生其他副作用。
3.1 死鎖 Deadlock
線程A正在等待線程B持有的變量,線程B正在等待線程A持有的變量,這時就會產(chǎn)生死鎖(deadlock)。通常在使用多個互斥鎖時發(fā)生死鎖。
3.2 資源匱乏 Starvation
在計算機科學(xué)中,資源匱乏(resource starvation,也稱線程饑餓)是并發(fā)計算中的問題。在該問題中,永久性地拒絕了一個線程訪問其工作所必須的資源,導(dǎo)致一直處于休眠狀態(tài)。Starvation 可能是調(diào)度算法、互斥算法的問題,也可能是資源泄漏引起的。
3.3 虛假喚醒 Spurious Wake-up
虛假喚醒(Spurious Wake-up)是一個很微妙的問題,源于操作系統(tǒng)如何實現(xiàn) conditional variable。在 spurious wake-up 中,即使沒有通過條件變量發(fā)出信號,線程也會喚醒。這也就是為何大多數(shù) synchronization primitive 提供了檢查喚醒信號是否來自于線程正在等待的條件變量。
3.4 優(yōu)先級倒置 Priority Inversion
優(yōu)先級倒置(priority inversion)又稱為優(yōu)先級反轉(zhuǎn)、優(yōu)先級逆轉(zhuǎn)、優(yōu)先級翻轉(zhuǎn),是一種不希望發(fā)生的任務(wù)調(diào)度狀態(tài)。在該種狀態(tài)下,一個高優(yōu)先級的任務(wù)被一個低優(yōu)先級任務(wù)所搶先,兩個任務(wù)相對優(yōu)先級倒置。
這種情況往往出現(xiàn)在一個高優(yōu)先級任務(wù)等待訪問一個低優(yōu)先級任務(wù)正在使用的臨界資源,從而堵塞了高優(yōu)先級任務(wù);同時,該低優(yōu)先級任務(wù)被一個次高優(yōu)先級任務(wù)搶先,從而無法及時釋放該臨界資源。例如,將音頻輸出到聲卡的線程(高優(yōu)先級)被顯示界面的線程(低優(yōu)先級)堵塞時,會導(dǎo)致?lián)P聲器出現(xiàn)故障。Priority inversion 并不一定產(chǎn)生危害,有時高優(yōu)先級任務(wù)延遲運行并不會被察覺。
總結(jié)
所有這些 synchronization 問題都已經(jīng)研究了多年,并提供了多種解決方案,包括技術(shù)上的和架構(gòu)上的。精心設(shè)計結(jié)合過往經(jīng)驗可以避免出現(xiàn)這些問題。鑒于多線程程序的不確定性,人們開發(fā)了一些工具檢測并發(fā)代碼中的錯誤和潛在陷進,如Helgrind、TSan等。
有時想要避免堵塞機制,采取其他模式。這意味著進入非堵塞領(lǐng)域:一個非常底層的領(lǐng)域。在該領(lǐng)域中,線程永遠(yuǎn)不會被操作系統(tǒng)休眠,并發(fā)通過原子性和不可變數(shù)據(jù)結(jié)構(gòu)來控制。這是一個充滿挑戰(zhàn)的領(lǐng)域,并非總是必要的。無鎖編程可以提高軟件速度,也可以造成嚴(yán)重破壞。下一篇文章并發(fā)控制之無鎖編程將介紹相關(guān)部分內(nèi)容。
上一篇:多線程簡述
下一篇:并發(fā)控制之無鎖編程