[萬字長文] 操做系統如何解決併發問題?

在以前的文章中咱們講到過引發多線程 bug 的三大源頭問題(可見性,原子性,有序性問題),java 的內存模型(Java Memory Model)能夠解決可見性和有序性問題,可是原子性問題如何解決呢?java

public class Counter {
  volatile int count;   public void add10K() {  for(int i = 0; i < 10000; i++) {  count++;  }  }   public int get() {  return count;  }   public static void main(String[] args) throws InterruptedException {   Counter counter = new Counter();   Thread t1 = new Thread(() -> counter.add10K());   Thread t2 = new Thread(() -> counter.add10K());   t1.start();  t2.start();   t1.join();  t2.join();   System.out.println(counter.count);  }  } 複製代碼

上面的代碼咱們的指望結果是 20000,能夠運行結果確實小於 20000 的。是由於 count 這個共享變量同時被兩個線程在修改,而 count++這條語句在 cpu 中實際對應三條指令程序員

  1. 讀取 count 的值
  2. count + 1
  3. 將 count+1 的值從新複製給 count

而線程切換就可能發生在執行完任意一個指令的時候,好比 如今 count 的值爲 100, 線程 1 讀取到了以後加 1 完成,可是尚未將新的值賦值給 count,此時讓出 cpu,而後線程 2 執行,線程 2 讀取到的值也爲 100,執行後 count++以後,才讓出 cpu,此時線程 1 獲取到執行資格,將 count 設置爲 101。web

相似上面的狀況,兩個或多個線程讀寫某些共享數據,而最後的結果取決於線程運行的精準時序,稱爲競爭條件。 那麼如何避免競爭條件呢?實際上凡是涉及共享內存、共享文件以及共享任何資源的狀況都會引起與前面相似的錯誤,要避免這種錯誤,關鍵是要找出某種途徑來阻止多個線程同時讀寫共享的數據。算法

換言之,咱們須要的是互斥,即以某種手段確保當一個進程在使用另外一個共享變量或文件時,其餘進程不能作一樣的操做。編程

上面例子的癥結在於,線程 1 對共享變量的使用未結束以前線程 2 就使用它了。緩存

線程的存在的問題,進程也一樣存在。因此咱們接下來看看操做系統是如何處理的。數據結構

一個進程的一部分時間作內部計算或另一些不會引起競爭條件的操做。在某些時候進程可能須要訪問共享內存或文件,或執行另一些會致使競爭的操做。多線程

咱們把對共享內存進行訪問的程序片斷稱做臨界區域或臨界區。若是咱們可以使得兩個進程不一樣時處於臨界區中,就能避免競爭。併發

爲了保證使用共享數據的併發進程可以正確和高效地進行寫做,一個好的解決方案須要知足如下 4 個條件。編程語言

  1. 任何兩個進程不能同時處於其臨界區
  2. 不該對 CPU 的速度和數量作任何假設
  3. 臨界區之外運行的進程不着阻塞其餘進程
  4. 不得使進程無限期等待進入臨界區
臨界區
臨界區

比較指望的進程行爲如上圖所示。進程 A 在 T1 時刻進入臨界區。稍後,在 T2 時刻進程 B 試圖進入臨界區,可是失敗了,由於另外一個進程已經在臨界區內,而一個時刻只容許一個進程在臨界區內。因此 B 被暫時掛起知道 T3 時刻 A 離開臨界區爲止,從而容許 B 當即進入。最後,在 B 離開(在時刻 T4),回到了在臨界區中沒有進程的原始狀態。

互斥

爲了保證同一時刻只能有一個進程進入臨界區,咱們須要讓進程在臨界區保持互斥,這樣當一個進程在臨界區更新共享內存時,其餘進程將不會進入臨界區,以避免帶來不可預見的問題。

接下來會討論下關於互斥的幾種方案。

屏蔽中斷

單處理器系統中比較簡單的作法就是在進入臨界區以後當即屏蔽全部中斷,並在要離開以前在打開中斷。屏蔽中斷後,時鐘中斷也被屏蔽,因爲 cpu 只有發生時鐘中斷或其餘中斷時纔會進行進程切換,這樣在屏蔽中斷後 CPU 不會被切換到其餘進程,檢查和修改共享內存的時候就不用擔憂其餘進程介入。

中斷信號致使 CPU 中止當前正在作的工做而且開始作其餘的事情

但是若是把屏蔽中斷的能力交給進程,若是某個進程屏蔽以後再也不打開中斷怎麼辦? 整個系統可能會所以終止。多核 CPU 可能要好點,可能只有其中一個 cpu 收到影響,其餘 cpu 仍然能正常工做。

鎖變量

鎖變量實際是一種軟件解決方案,設想存在一個共享鎖變量,初始值爲 0,當進程想要進入臨界區的時候,會執行下面代碼相似的判斷

if( lock == 0) {  lock = 1;   // 臨界區  critical_region(); } else {  wait lock == 0; }  複製代碼

看上去很好,但是會出現和文章開頭演示代碼一樣的問題。進程 A 讀取鎖變量 lock 的值發現爲 0,而在剛好設置爲 1 以前,另外一個進程被調度運行,將鎖變量設置爲 1。 當第一個進程再次運行時,一樣也將該鎖設置爲 1,則此時同時有兩個進程進入臨界區中。

一樣的即便在改變值以前再檢查一次也是無濟於事,若是第二個進程剛好在第一個進程完成第二次檢查以後修改了鎖變量的值,則一樣會發生競爭條件。

嚴格輪換法

這種方法設計到兩個線程執行不一樣的方法

線程 1:

while(true) {   while(turn != 0) {  // 空等  }  // 臨界區  critical_region();   turn = 1;  // 非臨界區  noncritical_region(); }   複製代碼

線程 2:

while(true) {
  while(turn != 1) {  // 空等  }  // 臨界區  critical_region();   turn = 0;   noncritical_region(); }  複製代碼

turn 初始值爲 0,用於記錄輪到哪一個線程進入臨界區。

開始時,進程 1 檢查 turn,發現其值爲 0,因而進入臨界區。進程 2 也發現值爲 0,因而一直在一個等待循環中不停的測試 turn 的值。

連續測試一個變量直到某個值出現爲止,稱爲忙等待。因爲這種方式浪費 CPU 時間,因此一般應該避免。只有在有理由認爲等待時間是很是短的狀況下,才使用忙等待。用於忙等待的鎖,稱爲自旋鎖。

看上去是否是很完美,其實否則。

當進程 2 執行得比較慢還在執行非臨界區代碼,此時 turn = 1,而進程 1 又回到了循環的開始,可是它不能進入臨界區,它只能在 while 循環中一直忙等待。

這種狀況違反了前面敘述的條件 3:進程不能被一個臨界區外的進程阻塞。這也說明了在一個進程比另外一個慢了許多的狀況下,輪流進入臨界區並非一個好辦法。

Peterson 算法

1981 年發明的互斥算法其基本原理以下

int turn;
boolean[] interested = new boolean[2];  // 進程號是0或者1 void enterRegion(int process) {   // 其餘進程  int other = 1 - process;   // 記錄哪一個進程有興趣進入臨界區  interested[process] = true;   // 設置標誌  turn = process;   // 若是不知足進入條件,則空等待  while(turn == process && interested[other] == true) {   } }  void leaveRegion(int process) {  interested[process] = false; } 複製代碼

當進程想要進入臨界區的時候就會調用 enterRegion 方法,該方法在須要時將使得進程等待,在完成對共享變量的操做後,就會調用 leaveRegion 離開臨界區。

這種算法雖然不會嚴格要求進程的執行順序,可是仍然會形成忙等待。

睡眠與喚醒

忙等待的缺點如何克服呢?

忙等待的本質是因爲當一個進程想要進入臨界區時,先檢查是否容許進入,如不容許,則該進程在原地一直等待,知道容許爲止

是否存在一個命令當沒法進入臨界區的時候就阻塞,而不是忙等待呢?

進程通訊有兩個原語 sleep 和 wakeup 就知足這個需求,sleep 是一個將引發調用進程阻塞的系統調用,它會使得進程被掛起直到另一個進程將其喚醒。

而 wakeup 有一個參數,即被須要喚醒的線程。這裏咱們來考慮一個生產者消費者的例子。

生產者往緩衝區中寫入數據,消費者從緩衝區中取出數據。可是這裏有兩個隱藏的條件,當緩衝區爲空的時候消費者不能取出數據, 當緩衝區滿了的時候生產者不能生產數據。

int MAX = 100;
int count = 0;  void producer() {   int item;   while(true) {  iterm = produce_item();  // 隊列滿了就要睡眠  if(count == MAX) {  sleep();  }   insert_item(item);   count = count + 1;   if(count == 1) {  wakeup(consumer);  }   } }  void consumer() {  int item;   while(true) {  if(count == 0) {  sleep();  }   item = remove_item();   count = count -1;   // 隊列不滿,則喚醒producer  if(count == N -1) {  wakeup(producer);  }   consume_item(item);  } }  複製代碼

乍看之下沒有問題,其實仍是存在問題的,那就是對 count(隊列當前數據個數)的訪問未加限制。

隊列爲空,當 consumer 在執行到 if(count == 0)的時候,若是恰好發生了進程切換,producer 執行,此時插入一個數據,而後發現 count == 1,就會喚醒 consumer(可是實際上 consumer 並無睡眠),consumer 接着剛纔的代碼執行會發現 count = 0,因而睡眠。生產者早晚會填滿整個隊列,從而使得兩個進程都陷入睡眠狀態。

快速的彌補方法就是加一個喚醒等待位,當一個 wakeup 信號發送給一個清醒的進程時,將該標誌位置爲 1,隨後,當該進程要睡眠時,若是喚醒等待位爲 1,則將該標誌位清除,同時進程保持爲清醒狀態。

可是這個辦法仍是治標不治本,若是有三個進程甚至更多的進程那麼就會須要更多的標誌位。

信號量

信號量是 E.W.Dijkstra 在 1965 年提出的一種方法,它使用一個整型變量來累計喚醒次數,供之後使用。 在他的建議中引入了一個新的變量類型,稱做信號量(semaphore),一個信號量的取值能夠爲 0(表示沒有喚醒操做)或者爲正值(表示有一個或多個喚醒操做)。 Dijkstra 建議設立兩種操做:down 和 up(分別爲通常化後的 sleep 和 wakeup)

void down() {  if(semaphore > 0) {  semaphore = semaphore -1;  } else {  sleep();  } } 複製代碼

檢査數值、修改變量值以及可能發生的睡眠操做均做爲一個單一的、不可分割的原子操做完成,保證一旦一個信號量操做開始,則在該操做完成或阻塞以前,其餘進程均不容許訪問該信號量。

所謂原子操做,是指一組相關聯的操做要麼都不間斷地執行,要麼都不執行。

void up() {
  semaphore = semaphore + 1;   wakeup(); }  複製代碼

up 操做對信號量的操做增長 1,一樣信號量的值增長 1 和喚醒一個進程一樣是不可分割的一個操做。

那麼是如何保證 down()和 up()的操做是原子性的呢?其實是經過硬件提供的 TSL 或 XCHG 指令來完成的。

執行 TSL 或者 XCHG 指令的 CPU 將鎖住內存總線,以禁止其餘 CPU 在本指令結束以前訪問內存。

來看看如何使用信號量的方式來解決生產者消費者問題。

在下面的實現方案中使用了 3 個信號量

  1. full: 記錄充滿的緩衝槽數目
  2. empty: 記錄空的緩衝槽數目
  3. mutex: 供兩個或多個進程使用的信號量,初始值爲 1,表示只有一個進程能夠進入臨界區
 #define N 100 // 緩衝區中槽數目  typedef int semaphore; // 信號量是一種特殊的整型數據  semaphore mutex = 1; // 控制對臨界區的訪問 semaphore empty = N; //緩衝區的空槽數目 semaphore full = 0; // 緩衝區的滿槽數目 void producer(void) {  int item;  while (TRUE) {   // 產生放在緩衝區中的一些數據  item = produce_item();  // 將空槽數目減1  down(&empty);  // 進入臨界區  down(&mutex);   // 將新數據項放到緩衝區中  insert_item(item);  // 離開臨界區  up(&mutex);   // 將滿槽的數目加1  up(&full);  } }  void consumer(void) {   int item;  while (TRUE) {  // 將滿槽數目減1  down(&full);  // 進入臨界區  down(&mutex);  // 從緩衝區中取出數據項  item = remove_item();  // 離開臨界區  up(&mutex);  // 將空槽數目加1  up(&empty);  // 處理數據項  consume_item(item);  } } 複製代碼

empty 這個信號量保證了緩衝區滿(empty=0)的時候生產者中止運行,full 這個信號量保證了緩衝區空的時候消費者中止運行。

互斥量

互斥量是信號量的簡化版本,互斥量僅僅適用於管理共享資源或一小段代碼,因爲互斥量在實現時即容易又有效,這使得互斥量在實現用戶空間線程包時很是有用。

互斥量是一個能夠處於兩態之一的變量:解鎖和加鎖。這樣,只須要一個二進制位表示它,不過實際上,經常使用一個整型量,0 表示解鎖,而其餘全部的值則表示加鎖。

當一個線程(或進程)須要訪問臨界區時,它調用 mutex_lock,若是該互斥量當前是解鎖的(即臨界區可用),此調用成功,調用線程能夠自由進入該臨界區。

另外一方面,若是該互斥量已經加鎖,調用線程被阻塞,直到在臨界區中的線程完成並調用 mutex_unlock。若是多個線程被阻塞在該互斥量上,將隨機選擇一個線程並容許它得到鎖。 因爲互斥量很是簡單,因此若是有可用的 TSL 或 XCHG 指令,就能夠很容易地在用戶空間中實現它們。

mutex_lock:
 // 將互斥信號量複製到寄存器,而且將互斥信號量置爲 1  TSL REGISTER,MUTEX  // 互斥信號是0嗎  CMP REGISTER,#O  // 若是互斥信號量爲0,它被解鎖,因此返回  JZE ok  // 互斥信號量忙,調度另外一個線程  CALL thread_yield  // 稍後再試  JMP mutex_lock ok: RET // 返回調用者,進入臨界區  mutex_unlock:  // 將mutex置爲0  MOVE MUTEX,#0  // 返回調用者  RET 1 複製代碼

mutex_lock 的方法和 enter_region 的方法有點相似,可是有一個很關鍵的區別,enter_region 進入臨界區失敗,會忙等待(實際上因爲 CPU 時鐘超時,會調度其餘進程運行)。

因爲 thread_yield 只是在用戶空間中對線程調度程序的一個調用,因此它的運行很是快。這樣,mutex_lock 利 mutex_unlock 都不須要任何內核調用。

線程中的互斥

爲實現可移植的線程程序,IEEE 標準 1003.1c 中定義了線程的標準,它定義的線程包叫作 Pthread,大部分 UNIX 系統都支持這個標準。

Pthread 提供許多能夠用來同步線程的函數。其基本機制是使用一個能夠被鎖定和解鎖的互斥量來保護每一個臨界區。

與互斥量相關的主要函數調用以下所示:

線程調用 描 述
pthread_mutex_init 建立一個互斥量
pthread_mutex destroy 撤銷一個已存在的互斥量
pthread_mutex_lock 得到一個鎖或阻塞
pthread_mutex_trylock 嘗試得到一個鎖或失敗
pthread_mutex_unlock 釋放一個鎖

除互斥量以外,pthread 提供了另外一種同步機制:條件變量。

**互斥量在容許或阻塞對臨界區的訪問上是頗有用的,條件變量則容許線程因爲一些未達到的條件而阻塞,絕大都分情況下這兩種方法是一塊兒使用的。**如今讓咱們進一步地研究線程、互斥量、條件變量之間的關聯。

再考慮一下生產者一消費者問題:一個線程將產品放在一個緩衝區內,而另外一個線程將它們取出。若是生產者發現緩衝區中沒有空槽可使用了,它不得不阻塞起來直到有一個空槽可使用。生產者使用互斥量能夠進行原子性檢查,而不受其餘線程干擾。可是當發現緩存區已經滿了之後,生產者須要一種方法來阻塞本身並在之後被喚醒,這即是條件變量作的事了.

與條件變量相關的 pthread 調用以下:

線程調用 描 述
pthread_condinit 建立一個條件變量
pthread_cond_destroy 撤銷一個條件變量
pthread_cond_wait 阻塞以等待一個信號
pthread_cond_signal 向另外一個線程發信號來喚醒它
pthread_cond_broadcast 向多個線程發信號來讓它們所有喚醒

與條件變量相關的最重要的兩個操做是 pthread_cond_wait 和 pthread_cond_signal,前者阻塞調用線程直到其餘線程給它發信號(pthread_cond_signal 通知其餘進程)。當有多個線程被阻塞在同一個信號時,可使用 pthread_cond_broadcast 調用。

條件變量與互斥量常常一塊兒使用,這種模式用於讓一個線程鎖住一個互斥量,而後當它不能得到它期待的結果時等待一個條件變量。最後另外一個線程會向它發信號,使它能夠繼續執行。 pthread_cond_wait 原子性地調用並解鎖它持有的互斥量,因爲這個緣由,互斥量是參數之一。

#include <stdio.h>
#include <pthread.h> #define MAX 1000000000 pthread_mutex_t the_mutex; pthread_cond_t condc, condp;  // 緩衝區 int buffer = 0; void producer(void *ptr){  int i;  for (i =1; i <= MAX; i++) {  // 互斥使用緩衝區  pthread_mutex_lck(&the_mutex);  while (buffer != 0) {  pthread_cond_wait(&condp, &the_mutex);  }  // 將數據放入緩衝區  buffer = i;  // 喚醒消費者  pthread_cond_signal(&condc);  // 釋放緩衝區  pthread_mutex_unlock(&the_mutex);  }   pthread _exit(O); }  void consumer(void *ptr) {  int i;  for (i = 1; i <= MAX; i++) {  // 互斥使用緩衝區  pthread_mutex_lock(&the_mutex);  while (buffer == 0) {  pthread_cond_wait(&condc, &the_mutex);  }  // 從緩衝區中取出數據  buffer = 0;  // 喚醒生產者  pthread_cond_signal(&condp);  // 釋放緩衝區  pthread_.mutex_unlock(&the_mutex);  }  pthread_exit(O); }   int main(int argc, char** argv) {   // 初始化  pthread_t pro, con;  pthread_mutex_init(&the_mutex, 0);  pthread_cond _init(&condc, 0);  pthread_cond_init(&condp, 0);   // 建立一個線程(pthread提供的方法)  pthread_create(&con, 0, consumer, 0);  pthread_create(&pro, 0, producer, 0);   // 等待線程執行完成  pthread_join(pro, 0);  pthread_join(con, 0);   pthread_cond_destroy(&condc);  pthread_cond_destroy(&condp);  pthread_mutex_destroy(&the_mutex); }  複製代碼

這個例子雖然簡單,可是卻說明了基本的機制。 同時使一個線程睡眠的語句應該總要檢查這個條件,以保證線程在繼續執行前知足條件,由於線程可能已經由於一個 UNIX 信號或其餘緣由被喚醒。

管程

有了信號量和互斥量以後,進程間通訊看起來就容易了,是這樣嗎?

固然不是,能夠再看看信號量中的 producer(),若是將代碼中的兩個 down 操做交換下次序,這使得 mutex 的值在 empty 以前減 1,若是緩衝區徹底滿了,生產者將阻塞,此時 mutex 爲 0,

當消費者下一次訪問緩衝區時,消費者也就被阻塞,這種狀況叫作死鎖。

void producer(void) {
 int item;  while (TRUE) {   // 產生放在緩衝區中的一些數據  item = produce_item();   // 下面的代碼交換了順序   // 進入臨界區  down(&mutex);  // 將空槽數目減1  down(&empty);   // 將新數據項放到緩衝區中  insert_item(item);  // 離開臨界區  up(&mutex);   // 將滿槽的數目加1  up(&full);  } } 複製代碼

這個問題告訴咱們使用信號量要很當心。

爲了更易於編寫正確的程序,Brinch Hansen(1973)和 Hoare(1974)提出了一種高級同步原語,稱爲管程(monitor)。 在下面的介紹中咱們會發現,他們兩人提出的方案略有不一樣。

一個管程是一個由過程、變量及數據結構等組成的一個集合,它們組成一個特殊的模塊或軟件包。

下面的代碼是類 Pascal 語法,procedure 你能夠認爲是一個函數。

monitor example:
 interger i;  condition c;  procedure producer();  begin  ...  end;   procedure consumer();  begin  ...  end; end monitor; 複製代碼

管程有一個很重要的特性,即任一時刻管程中只能有一個活躍進程,這一特性使管程能有效地完成互斥。

管程是一個語言概念

管程是編程語言的組成部分,編譯器知道它們的特殊性,所以能夠採用與其餘過程調用不一樣的方法來處理對管程的調用。

當一個進程調用管程過程時,該過程當中的前幾條指令將檢査在管程中是否有其餘的活躍進程。若是有,調用進程將被掛起,直到另外一個進程離開管程將其喚醒。若是沒有活躍進程在使用管程,則該調用進程能夠進入。

進入管程時的互斥由編譯器負責,但一般的作法是用一個互斥量或信號量。由於是由編譯器而非程序員來安排互斥,因此出錯的可能性要小得多。在任一時刻,寫管程的人無須關心編譯器是如何實現互斥的。他只需知道將全部的臨界區轉換成管程過程便可,決不會有兩個進程同時執行臨界區中的代碼。

管程仍然須要一種方法使得進程在沒法繼續運行時被阻塞,其解決辦法就是條件變量,以及相關的兩個操做:wait 和 signal.

當一個管程發現他沒法運行時(好比生產者沒法緩衝區滿了),它會在某個條件變量執行 wait 操做,該操做致使調用進程自身阻塞,而且還將另外一個之前在管程外的進程調入管程,另外一個進程好比消費者能夠喚醒生產者,經過對生產者正在等待的條件變量執行 signal。

signal 喚醒策略

爲了不管程中同時有兩個活躍進程,咱們須要一個規則來經過在 signal 以後怎麼作。

Hoare 建議讓喚起的新線程執行,而掛起另外一個線程。

Brinch Hansen 則建議執行 signal 的進程必須當即退出管程,即 signal 語句只能做爲一個管程過程的最後一個語句。

第三種方法是讓發信號者繼續運行,而且只有在發信號者退出管程後,才容許等待的進程開始運行。而 java 使用的就是該種方案。

須要注意的是不管是信號量仍是管程在分佈式系統中都是不起做用的,至於爲何我相信你們內心確定有了答案。

你的點贊關注是對我最大的支持

相關文章
相關標籤/搜索