操作系統筆記-5-進程&線程同步

引言

前一篇介紹了進程和線程調度,知道線程和進程在系統中是併發執行,這將會引發出一些問題。接下來從一個簡單的生產者和消費者例子說起,從前有兩個進程,一個進程負責往一個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的速度和數量做任何假設
  • 臨界區外的進程不能阻塞其他進程
  • 不得使進程無限期的等待進入臨界區
簡單解決辦法
  • 屏蔽中斷
    • 實現:在進入臨界區後立即屏蔽中斷,在快要離開時開啓中斷。
    • 缺點
      1. 把屏蔽中斷的權利交給用戶進程是不明智的
      2. 如果是多CPU,只會屏蔽當前CPU有效
  • Peterson解法
    • 實現
          #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 */
          }
      
      while判斷分析:如果turn的值爲當前進程號,並且interested[other]爲true,說明此時兩個進程都對該條件感興趣,並且另一個進程先來,所以turn的值纔會爲當前進程的值,它覆蓋了另一個進程寫入的值,所以另一個進程已經進入臨界區,所以當前進程需要等待另一個進程離開臨界區。
    • 缺點:爲了提高性能,現代處理器或者編譯器有指令重排序,可能是導致該算法無法保證有效。雖然Peterson的解法在指令重排序下有問題,但是它卻提供了同步解決的基礎。

同步工具的硬件支持

在介紹同步工具之前,先講一下硬件支持,比如內存屏障、CAS。

內存屏障
  • 內存模型
    • Strongly ordered:一個處理器對內存的修改立刻對其他處理器可見
    • Weakly ordered:一個處理器的內存的修改不能保證立刻對其他處理器可見
  • 原理:硬件提供的指令,通過強制將對內存的修改傳播到其他處理器,從而確保其他處理器對內存的修改可見,在之後講內存管理的時候細說
TSL指令
  • 原理:設置存儲器某個地址,測試並上鎖。
    TSL RX,LOCK 
    
    1. 內存字LOCK讀入寄存器RX,讀字和寫字硬件保證原子的
    2. 執行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的原子性
    1. 單核處理器:禁用中斷,禁止搶佔
    2. 多核處理器:可以在信號量中定義一個互斥量,通過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個進程同時進入臨界區
    1. 假設wait、signal順序反了,可能會有多個進程進入臨界區
       signal(mutex);
       ...
       critical section
       ...
       wait(mutex);
      
    2. 假設signal被錯誤寫成wait,這種情況,進程會永久阻塞
       wait(mutex);
       ...
       critical section
       ...
       wait(mutex);
      
  • Monitor出現的背景只是爲了方便開發人員使用,減少直接使用信號量、互斥量容易帶來的各種問題(信號量和互斥量本身沒有任何問題,是使用過程中開發人員不正確使用帶來的),因此對信號量、互斥量做了一層封裝
  • Monitor語義結構
     monitor monitor name
     {
         /* shared variable declarations */
         function P1 ( . . . ) {
         . . .
         }
         function P2 ( . . . ) {
         . . .
         }
         .
         .
         .
         function Pn ( . . . ) {
         . . .
         }
         initialization code ( . . . ) {
         . . .
         }
     }
    
  • Monitor的結構
    在這裏插入圖片描述
  • Monitor特性
    1. 同一時刻,只能有一個活躍進程。
    2. 只是一種編程語言概念,具體實現依靠特定語言,其內部可能是用互斥量實現
    3. 依靠編譯器識別管程,進入管程的互斥依靠編譯器
  • Monitor中有條件變量時,等待在條件變量的線程Q被線程P喚醒時,P的行爲
    1. 喚醒並等待:P將等待Q離開Monitor或者等待在其他條件上
    2. 喚醒並繼續:Q將等待P離開Monitor或者等待在其他條件上,這種看起來更合理,但是當P離開Monitor時,可能Q等待的條件已經變得不滿足
  • 用信號量實現Monitor
    1. 首先需要一個互斥量來控制能否進入Monitor,然後需要一個信號量來控制當線程喚醒等待條件變量的線程時,需要自己掛起,Monitor中的F函數被封裝。mutex是進入Monitor的互斥量,next是線程喚醒等待條件變量時,因爲採用的是喚醒並等待,所以需要等待被喚醒線程執行,而自己卻要掛起,next_count是掛起的線程數
       wait(mutex);
       		...
       	body of F
       		...
       if (next count > 0)
       	signal(next);
       else
       	signal(mutex);
      
    2. Monitor中條件變量實現,x_sem是信號量,x_count是等待的線程數
      1. wait方法實現
         x count++;
         if (next count > 0)
         	signal(next);
         else
         	signal(mutex);
         wait(x sem);
         x count--;
        
      2. signal方法實現
         if (x count > 0) {
             next count++;
             signal(x sem);
             wait(next);
             next count--;
         }
        
屏障(Barrier)
  • 原理:屏障像一道大門,當所有人都到了的時候,門就開了。進程運行到一個點時,會阻塞等待所有的進程都到達這個點,當所有進程都達到這個點時,所有的進程都將被喚醒,如下圖所示
    在這裏插入圖片描述
  • 解決的問題:主要是解決一些程序,程序是分階段運行,要求所有的進程到處於ready狀態時才能進行下一階段。打個比方,LOL這種競技遊戲,要等待大家都加載好了才能進遊戲。
寫時複製(Read-Copy-Update)
  • 原理:最快的鎖就是不用鎖,爲了找到一種辦法對共享數據讀和寫可以併發執行,通常情況下是不可能的。但是在某些情況下,一些數據結構可以提供一種對讀操作的保證,就是讀操作要麼讀取舊數據,要麼讀取最新數據,但是不會讀取新舊數組的組合。當新增和刪除節點時,會先初始化需要操作的節點,這時候是不影響讀的,讀的還是舊的數據,當更改已經初始化完畢時通過CAS原子操作,修改爲新的,這樣就可以避免在寫數據時加鎖影響讀
    在這裏插入圖片描述
  • 存在問題:比如上圖的d中,刪除了B,D節點,但是不知道是否有讀在使用B、D節點,因此無法確定何時釋放B、D節點。RCU設置了一個讀持有引用的最大時間

總結

筆記很多地方沒有詳細的說明,如果要了解詳細的細節東西,推薦看看《MODERN OPERATING SYSTEMS》和《Operating System Concepts》。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章