引言
前一篇介紹了進程和線程調度,知道線程和進程在系統中是併發執行,這將會引發出一些問題。接下來從一個簡單的生產者和消費者例子說起,從前有兩個進程,一個進程負責往一個buffer裏寫數據,我們把它叫做生產者;一個進程負責消費數據也就是取走buffer裏的數據,我們把它叫做消費者。因爲我們要記錄寫到哪裏,所以需要一個變量in來記錄,還需要一個變量記錄消費到哪裏了out,現在我們假設核心的生產者代碼段。
while (true) {
while (count == BUFFER SIZE)
; /* 忙等待,等到buffer有空間寫入 */
buffer[in] = next produced;
in = (in + 1) % BUFFER SIZE; //構建一個環形數組
count++;
}
消費者核心代碼
while (true) {
while (count == 0)
; /* 忙等待,等到buffer有數據消費 */
next consumed = buffer[out];
out = (out + 1) % BUFFER SIZE;
count--;
}
如果併發執行這個代碼,肯定會有問題?因爲cpu在修改count時,要先將count讀入寄存器,然後在寫入內存。假設count此時爲4,生產者和消費者分別併發寫入和消費,count的結果可能會爲3、4、5,如果生產者和消費者分別將4讀入寄存器,那麼count的值將取決於誰後寫回內存覆蓋之前的值,所以值可能爲3、5,這個結果顯然是有問題的。將接下來將學習解決之道。
競態條件&臨界區
在學習進程&線程同步之前得先理解競態條件和臨界區的概念。
競態條件
多個進程或線程併發訪問或操作同一數據,其運行結果依賴於其訪問發生的特定順序
臨界區
- 定義:對共享內存訪問的程序片段被稱爲臨界區,就像引言例子中外層while循環中的代碼片段
- 臨界區互斥性示意圖
解決競態條件方法滿足的條件
- 互斥性,任何兩個進程不能同時處於臨界區
- 不應對CPU的速度和數量做任何假設
- 臨界區外的進程不能阻塞其他進程
- 不得使進程無限期的等待進入臨界區
簡單解決辦法
- 屏蔽中斷
- 實現:在進入臨界區後立即屏蔽中斷,在快要離開時開啓中斷。
- 缺點
- 把屏蔽中斷的權利交給用戶進程是不明智的
- 如果是多CPU,只會屏蔽當前CPU有效
- Peterson解法
- 實現
while判斷分析:如果turn的值爲當前進程號,並且interested[other]爲true,說明此時兩個進程都對該條件感興趣,並且另一個進程先來,所以turn的值纔會爲當前進程的值,它覆蓋了另一個進程寫入的值,所以另一個進程已經進入臨界區,所以當前進程需要等待另一個進程離開臨界區。#define FALSE 0 #define TRUE 1 #define N 2 /* 進程數 */ int turn; /* whose turn is it? */ int interested[N]; /* 哪個進程對條件感興趣,初始化爲0 */ void enter region(int process); /* 因爲只有兩個進程,所以process爲0或者1 */ { int other; /* 另一個進程編號 */ other = 1 −process; /* 另一個進程編號 */ interested[process] = TRUE; /* 表示當前進程對該條件感興趣 */ turn =process; /* 設值,注意這裏哪個進程後設置,該值爲它的進程編號 */ while (turn == process && interested[other] == TRUE) /* 空語句 */ ; } void leave region(int process) /* 進程離開臨界區 */ { interested[process] = FALSE; /* 將進程對條件感興趣的標誌設置爲false */ }
- 缺點:爲了提高性能,現代處理器或者編譯器有指令重排序,可能是導致該算法無法保證有效。雖然Peterson的解法在指令重排序下有問題,但是它卻提供了同步解決的基礎。
- 實現
同步工具的硬件支持
在介紹同步工具之前,先講一下硬件支持,比如內存屏障、CAS。
內存屏障
- 內存模型
- Strongly ordered:一個處理器對內存的修改立刻對其他處理器可見
- Weakly ordered:一個處理器的內存的修改不能保證立刻對其他處理器可見
- 原理:硬件提供的指令,通過強制將對內存的修改傳播到其他處理器,從而確保其他處理器對內存的修改可見,在之後講內存管理的時候細說
TSL指令
- 原理:設置存儲器某個地址,測試並上鎖。
TSL RX,LOCK
- 內存字LOCK讀入寄存器RX,讀字和寫字硬件保證原子的
- 執行TSL指令鎖住內存總線,禁止其他CPU在該指令結束前訪問內存
enter region: TSL REGISTER,LOCK | 複製鎖到寄存器並將鎖設置爲1 CMP REGISTER,#0 | 鎖是0嗎 JNE enter region | 如果鎖不是0,說明鎖已經被搶了,循環 RET | 返回,進入臨界區 leave region: MOVE LOCK,#0 | 在鎖存入0 RET | 返回調用者
XCHG指令
- 原理:原子交換兩個操作數,是TSL的一種替代指令
enter region: MOVE REGISTER,#1 | 將1放入寄存器 XCHG REGISTER,LOCK | 交換寄存器和鎖的內容 CMP REGISTER,#0 | 鎖是0嗎 JNE enter region | 如果鎖不是0,說明鎖已經被搶了,循環 RET | 返回調用者,進入臨界區 leave region: MOVE LOCK,#0 | 在鎖存入0 RET | 返回調用者
原子操作CAS(比較並交換)
- 原理:原理接受三個參數(old,ptr,new),底層是依賴CPMXCHG,注意CPMXCHG本身並不是atomic的,它的實現是在單處理器系統上通過禁止中斷來達到原子性,如果是多處理器,則需要在CPMXCHG加前綴LOCK。ptr是該值存儲地址,指令會將old加載到寄存器EAX,將ptr的值加載到EBX,將new加載到ECX,如果EAX等於EBX,將ECX存入EBX並且標誌寄存器FZ位置爲1;否則EBX存入EAX,將FZ位清0
同步工具
原子變量(Atomic)
- 原理:利用CAS實現的
信號量(Semaphore)
- 原理:提供了一對操作wait(Dijkstra的P)、signal(Dijkstra的V)原子操作。內部提供一個計數器和一個等待隊列,這個隊列可以是FIFO或者優先級隊列。wait操作申請鎖,初始定義資源數(當資源只有一個時,其實作用是一個互斥量),wait申請資源時,假設資源數還有剩餘,那麼扣減資源數,進程進入臨界區,執行完臨界區代碼後,遞增資源數,並且喚醒一個進程;如果沒有資源可用,那麼進程加入信號量的等待隊列。當然具體細節各個操作系統實現可能不一樣
- 如何保證wait、signal的原子性
- 單核處理器:禁用中斷,禁止搶佔
- 多核處理器:可以在信號量中定義一個互斥量,通過CAS和自旋來互斥進入wait和signal的臨界區
- linux實現,linux實現沒有叫wait和signal,它們叫down和up分別對應wait、signal,只是名字不同它們的語義是一樣的
struct semaphore {//數據結構 spinlock_t lock; unsigned int count; struct list_head wait_list; }; //Dijkstra的P void down(struct semaphore *sem) { unsigned long flags; raw_spin_lock_irqsave(&sem->lock, flags); // 這裏面禁止了搶佔 if (likely(sem->count > 0)) sem->count--; else __down(sem); // 這裏睡眠,重新調度 raw_spin_unlock_irqrestore(&sem->lock, flags); } //Dijkstra的V void up(struct semaphore *sem) { unsigned long flags; spin_lock_irqsave(&sem->lock, flags); if (likely(list_empty(&sem->wait_list))) sem->count++; else __up(sem); spin_unlock_irqrestore(&sem->lock, flags); }
互斥量(mutex)
- 原理:定義一個變量作爲鎖,利用處理器提供CAS原子修改變量的值來加鎖和釋放鎖,如果鎖不可用,進程自旋等待
- futex(快速用戶區互斥量)
- 原理:實現了基本的鎖,但是避免了陷入內核,除非不得不爲之。包含兩部分:一個內核服務和一個用戶庫,內核服務提供一個等待隊列,沒有競爭時,futex完全在用戶空間工作。用戶態定義一個共享原子變量來標識當前鎖是否被其他進程持有,初始值爲1表示鎖可用,當進程申請鎖時,先原子遞減該值,如果結果爲0,說明沒有其他進程持有鎖,進程可以進入臨界區,否則,進程不會自旋,而是調用系統調用加入內核的等待隊列
管程(Mointor)
- 信號量存在的問題:信號量能正確運行的前提條件是wait、signal嚴格的執行順序,如果這啷個操作執行順序有問題,那麼可能會導致2個進程同時進入臨界區
- 假設wait、signal順序反了,可能會有多個進程進入臨界區
signal(mutex); ... critical section ... wait(mutex);
- 假設signal被錯誤寫成wait,這種情況,進程會永久阻塞
wait(mutex); ... critical section ... wait(mutex);
- 假設wait、signal順序反了,可能會有多個進程進入臨界區
- Monitor出現的背景只是爲了方便開發人員使用,減少直接使用信號量、互斥量容易帶來的各種問題(信號量和互斥量本身沒有任何問題,是使用過程中開發人員不正確使用帶來的),因此對信號量、互斥量做了一層封裝
- Monitor語義結構
monitor monitor name { /* shared variable declarations */ function P1 ( . . . ) { . . . } function P2 ( . . . ) { . . . } . . . function Pn ( . . . ) { . . . } initialization code ( . . . ) { . . . } }
- Monitor的結構
- Monitor特性
- 同一時刻,只能有一個活躍進程。
- 只是一種編程語言概念,具體實現依靠特定語言,其內部可能是用互斥量實現
- 依靠編譯器識別管程,進入管程的互斥依靠編譯器
- Monitor中有條件變量時,等待在條件變量的線程Q被線程P喚醒時,P的行爲
- 喚醒並等待:P將等待Q離開Monitor或者等待在其他條件上
- 喚醒並繼續:Q將等待P離開Monitor或者等待在其他條件上,這種看起來更合理,但是當P離開Monitor時,可能Q等待的條件已經變得不滿足
- 用信號量實現Monitor
- 首先需要一個互斥量來控制能否進入Monitor,然後需要一個信號量來控制當線程喚醒等待條件變量的線程時,需要自己掛起,Monitor中的F函數被封裝。mutex是進入Monitor的互斥量,next是線程喚醒等待條件變量時,因爲採用的是喚醒並等待,所以需要等待被喚醒線程執行,而自己卻要掛起,next_count是掛起的線程數
wait(mutex); ... body of F ... if (next count > 0) signal(next); else signal(mutex);
- Monitor中條件變量實現,x_sem是信號量,x_count是等待的線程數
- wait方法實現
x count++; if (next count > 0) signal(next); else signal(mutex); wait(x sem); x count--;
- signal方法實現
if (x count > 0) { next count++; signal(x sem); wait(next); next count--; }
- wait方法實現
- 首先需要一個互斥量來控制能否進入Monitor,然後需要一個信號量來控制當線程喚醒等待條件變量的線程時,需要自己掛起,Monitor中的F函數被封裝。mutex是進入Monitor的互斥量,next是線程喚醒等待條件變量時,因爲採用的是喚醒並等待,所以需要等待被喚醒線程執行,而自己卻要掛起,next_count是掛起的線程數
屏障(Barrier)
- 原理:屏障像一道大門,當所有人都到了的時候,門就開了。進程運行到一個點時,會阻塞等待所有的進程都到達這個點,當所有進程都達到這個點時,所有的進程都將被喚醒,如下圖所示
- 解決的問題:主要是解決一些程序,程序是分階段運行,要求所有的進程到處於ready狀態時才能進行下一階段。打個比方,LOL這種競技遊戲,要等待大家都加載好了才能進遊戲。
寫時複製(Read-Copy-Update)
- 原理:最快的鎖就是不用鎖,爲了找到一種辦法對共享數據讀和寫可以併發執行,通常情況下是不可能的。但是在某些情況下,一些數據結構可以提供一種對讀操作的保證,就是讀操作要麼讀取舊數據,要麼讀取最新數據,但是不會讀取新舊數組的組合。當新增和刪除節點時,會先初始化需要操作的節點,這時候是不影響讀的,讀的還是舊的數據,當更改已經初始化完畢時通過CAS原子操作,修改爲新的,這樣就可以避免在寫數據時加鎖影響讀
- 存在問題:比如上圖的d中,刪除了B,D節點,但是不知道是否有讀在使用B、D節點,因此無法確定何時釋放B、D節點。RCU設置了一個讀持有引用的最大時間
總結
筆記很多地方沒有詳細的說明,如果要了解詳細的細節東西,推薦看看《MODERN OPERATING SYSTEMS》和《Operating System Concepts》。