問題的復雜性產生的根本原因在于,如 2.2 節所述,共享變量的訪問始終是“單向信息流”。也就是說,一個進程可以分配新值或檢查當前值,但這種檢查不會為其他進程留下任何痕跡。結果是,當一個進程想要對共享變量的當前值作出反應時,在檢查和隨后執行反應之間,該值可能已被其他進程更改。換句話說,現有的通信機制對于手頭的問題來說是不充分的,我們需要尋找更合適的替代方案。
這種替代方案通過以下方式引入:
a) 在共享變量中引入特殊用途的整數,我們稱之為“信號量(semaphores)”。
b) 在構成各個進程的動作集合中添加兩種新的基本操作,我們分別稱之為“P 操作”和“V 操作”。這些操作始終作用于信號量,并代表并發進程訪問信號量的唯一方式。信號量本質上是非負整數。如果僅用于解決互斥(Mutual Exclusion)問題,則其值的范圍甚至可以限制為“0”和“1”。荷蘭物理學家兼計算機設計師 C.S. Scholten 博士證明了信號量在取更大值時具有廣泛的應用價值。需要區分時,我們會將它們分別稱為“二進制信號量(binary semaphores)”和“通用信號量(general semaphores)”。我接下來給出的 P 操作和 V 操作的定義不受這種區別的影響。
——Dijkstra 的筆記 EWD 123
引言
事實上,信號量是一個難以理解的概念。它是同步問題的核心,與互斥鎖一起是最先學習的概念之一,但初次接觸時往往難以理解。
通常,信號量可以用以下方式總結:
“信號量是一種通過 P 和 V 這兩個特殊的原子操作來操作表示資源可用性的計數器,從而解決因共享資源并發訪問而導致同步無法保證的問題的技術。”
但是,共享資源到底是什么?原子操作又是什么?資源的可用性、P 和 V 又是什么?
為了回答這些問題,我寫下了這篇文章。
本文的目標是幫助理解并發編程的基礎以及信號量的概念。
什么是共享資源?
當程序并行執行時,多個線程或進程同時訪問的數據被稱為“共享資源(shared resource)”。
- 示例:內存中的變量、文件句柄、網絡端口、隊列等。
共享資源具有狀態(state)。如果同時對這個狀態進行訪問和修改,可能會引發意外錯誤。
用打印機比喻來理解共享資源
假設有一臺打印機。想象一下,兩個人同時嘗試使用這臺打印機時會發生什么:
- 如果 A 正在打印,而 B 在中途開始打印會怎樣?
- 可能會導致打印中斷、輸出重疊,或者打印出空白頁。
這表明對共享資源的并發訪問會引起沖突。
為什么需要原子操作?
那么,如何解決這個問題呢?
核心在于,即使多個線程同時訪問,也要確保狀態的一致性,即保證“原子性(Atomicity)”。
例如,將變量 x
加 1 的操作 x = x + 1
實際上可以分解為以下三個步驟:
- 讀取
x
的當前值。 - 將其加 1。
- 將結果寫回
x
。
即使是這種看似簡單的操作,如果有其他進程在中間介入,結果可能會被破壞。例如,兩個線程同時執行 x = x + 1
時,最終結果可能只增加 1 而不是預期的 2,甚至可能出現更少的增量。
這種互相競爭修改值的情況導致了意想不到的狀態,這種情況被稱為競態條件(Race Condition) 。
即使是“看起來像一行代碼的操作”,實際上也可能分為多個步驟執行,因此需要一種防止中間介入的手段。
這就是原子操作 的作用。原子操作是不可分割的單位操作,在執行過程中不允許任何外部干預。
再次用打印機比喻來理解
- 當 A 向打印機發送打印請求時,只有在打印完全結束、失敗或中止后,B 的請求才能開始處理。
- 如果在 A 打印過程中 B 插入并開始打印,可能會導致輸出混合或部分丟失的錯誤。
- 因此,只有在“A 的打印狀態”明確結束后,才能處理下一個請求(B),從而保證輸出狀態的一致性。
這就是原子性的要求條件。
在打印過程中,打印機的狀態(State)應處于“使用中”的鎖定(lock)狀態,
只有在完全結束后才能進行下一個任務。
為了同時管理程序中的基于狀態的資源 ,需要一個無法被中途打斷的原子控制機制 。
好了,那么原子性是如何保證的呢?這就是我們今天的主題。
Dijkstra 的提議:什么是信號量?
特殊用途的整數(special-purpose integers)
正如之前打印機的例子所示,我們需要能夠從外部明確控制和監控“正在使用中”的狀態。然而,僅靠普通變量無法安全地管理這種狀態。
原因如下:
- 任何人都可以讀取和寫入變量。
- 某個進程讀取的值可能在下一刻被其他進程更改。
- 換句話說,在讀取值并對其做出反應的時間間隔內,狀態可能會發生變化,而這種變化不會被反映出來。
因此,現有的方式是一種容易中斷的“檢查-決定-執行”流程。
為了克服這種結構性限制,Edsger W. Dijkstra 提出了一個新的解決方案。
引入一種特殊用途的整數作為共享變量,我們稱之為“信號量(semaphores)”。
——Dijkstra, EWD 123
什么是信號量?
信號量(semaphore)不僅僅是一個簡單的整數。
它是一個外部訪問受到控制的、具有特殊用途的狀態值。
其核心在于:“禁止直接訪問,只能通過兩種操作(P/V)間接控制。”
在構成各個進程的動作集合中,添加兩種新的基本操作,我們分別稱之為“P 操作”和“V 操作”。這些操作始終作用于信號量,并代表并發進程訪問信號量的唯一方式。
——Dijkstra, EWD 123
這個特殊的整數只能通過以下兩個操作進行操作:
- P 操作 (Proberen,意為“測試”)
- V 操作 (Verhogen,意為“增加”)
這兩個操作遵循以下規則:
操作 | 含義 | 效果 |
---|---|---|
P(s) | wait / acquire | 如果信號量值為正,則減 1 并通過;如果為 0,則等待。 |
V(s) | signal / release | 將信號量值增加 1。 |
這些操作始終以原子性 方式執行,即它們不會被任何中斷或干擾打斷。
這正是我們一直在尋找的“防止中途介入的機制”。
這就是信號量。
P/V 操作的定義
一個簡單的實現如下:
// 信號量用整數值 s 表示
P(s): // waitwhile (s <= 0) wait;s = s - 1;V(s): // signals = s + 1; // 在此之后,如果有等待中的進程,需要喚醒它們
現代操作系統中,這一過程通過 futex、spinlock、sleep queue 等方式實現。
需要注意的是,在 P/V 操作偽代碼中,s = s - 1
、s = s + 1
和 while (s <= 0) wait;
條件檢查部分必須作為不可分割的原子操作 執行。
如果 P(s)
的 while (s <= 0) wait;
部分是通過持續占用 CPU 并反復檢查條件是否滿足的方式(即忙等待(busy-waiting 或 spin-waiting) ),那么這將非常低效地使用 CPU 資源。這是在浪費本可以用于其他有用任務的 CPU 時間。
因此,現代操作系統采用以下機制來更高效地處理這種“等待”過程,這些方法可以看作是現代異步處理的核心技術:
睡眠隊列(Sleep Queue / Wait Queue)、上下文切換(Context Switching)、自旋鎖(Spinlock)、以及 Futex(快速用戶空間互斥鎖) 。
這些方法的整體理論基礎正是上述簡單的操作。
總結一下,在現代操作系統中,信號量操作可以描述如下:
- 1. 基本的等待/喚醒: 使用睡眠隊列 高效地掛起和喚醒進程(避免忙等待)。
- 2. 內部原子性保障: 在 P/V 操作短暫的執行期間,為了安全地修改信號量變量(如
s
)或睡眠隊列,內核內部可能會使用自旋鎖 等機制。 - 3. 性能優化(尤其是 Linux): 利用Futex ,當沒有資源競爭時在用戶空間快速處理,只有在發生競爭時才使用內核的睡眠隊列功能,從而減少系統調用開銷。
二進制信號量 vs 通用信號量
區分 | 含義 | 使用場景 |
---|---|---|
二進制信號量 | 值:0 或 1 | 等同于互斥鎖(Mutex) |
通用信號量 | 值:0 或更大 | 有限資源(例如:數據庫連接池) |
-
C.S. Scholten 的通用信號量概念擴展。
-
與互斥鎖的區別: 擁有權限的概念 vs 狀態驅動模型
讓我們回到打印機的例子,使用信號量控制打印機。
在打印機示例中應用二進制信號量的過程如下:
- 用一個初始值為
s = 1
的信號量表示打印機的狀態。 - 用戶 A 調用
P(s)
,將s
減為0
并開始打印。 - 在用戶 A 打印過程中,用戶 B 調用
P(s)
,但由于s <= 0
,用戶 B 進入等待狀態。 - 用戶 A 完成打印后調用
V(s)
,使s = 1
,用戶 B 隨即可以開始打印。
通過這種方式,信號量將資源的“狀態”抽象為數字,并通過原子操作改變該數值,從而實現對并發的控制。
前面提到的二進制信號量 適用于辦公室只有一臺打印機的情況(互斥訪問)。
s=1
表示“打印機可用”,s=0
表示“打印機正在使用”。
但是,如果辦公室有多個相同性能的打印機(例如:3 臺)該怎么辦呢?
在這種情況下,雖然允許多個人同時使用打印機,但必須確保使用的打印機數量不會超過可用的數量。
這正是**通用信號量(General Semaphore)或 計數信號量(Counting Semaphore)**發揮作用的時候。
通用信號量 s
是一個非負整數值,用于表示可用資源的數量。
使用通用信號量控制 3 臺打印機的示例
-
初始狀態:
- 辦公室有 3 臺打印機,因此信號量
s
的初始值設置為3
(s = 3
)。 - 這表示“當前有 3 臺可用的打印機”。
- 辦公室有 3 臺打印機,因此信號量
-
用戶 A 請求使用打印機(執行
P(s)
操作):- 調用
P(s)
。 - 當前
s
的值(3)大于 0,因此將s
減 1(s = 2
)。 - 用戶 A 分配到一臺打印機并開始打印。(現在可用的打印機數量為 2)
- 調用
-
用戶 B 請求使用打印機(執行
P(s)
操作):- 調用
P(s)
。 - 當前
s
的值(2)大于 0,因此將s
減 1(s = 1
)。 - 用戶 B 也分配到一臺打印機并開始打印。(現在可用的打印機數量為 1)
- 調用
-
用戶 C 請求使用打印機(執行
P(s)
操作):- 調用
P(s)
。 - 當前
s
的值(1)大于 0,因此將s
減 1(s = 0
)。 - 用戶 C 分配到一臺打印機并開始打印。(現在可用的打印機數量為 0)
- 調用
-
用戶 D 請求使用打印機(執行
P(s)
操作):- 調用
P(s)
。 - 當前
s
的值(0)小于或等于 0,因此用戶 D 會在P(s)
操作中的while (s <= 0) wait;
條件下進入等待狀態 ,直到有打印機可用。
- 調用
-
用戶 A 打印完成并歸還打印機(執行
V(s)
操作):- 用戶 A 完成打印后調用
V(s)
。 - 將
s
增加 1(s = 1
)。(現在有 1 臺打印機可用) V(s)
操作完成后,之前在P(s)
操作中等待的用戶 D 被喚醒,并重新檢查s
的值。由于s
現在為 1,用戶 D 將s
設置為 0 并開始使用打印機。
- 用戶 A 完成打印后調用
通用信號量的核心作用:
如上所述,通用信號量不僅僅是一個簡單的二進制“鎖定/解鎖”機制,它還可以精確管理有限資源池的并發訪問 ,并準確跟蹤可用資源的數量 。
結語
信號量仍然是內核級同步的核心工具,同時也是基于狀態的訪問模型的典型代表。
信號量看似只是一個簡單的整數,
但它卻是系統中用于準確表示資源狀態 、
安全控制資源 、以及以可預測的方式共享資源的唯一抽象手段 。
我們必須牢記,在這個小小的整數背后,
承載著無數進程的秩序、沖突避免和系統穩定性 。