探索併發編程(一)------操作系統篇

在多線程、多處理器甚至是分佈式環境的編程時代,併發是一個不可迴避的問題,很多程序員一碰到併發二字頭皮就發麻,也包括我。既然併發問題擺在面前一個到無法迴避的坎,倒不如擁抱它,把它搞清楚,決心花一定的時間從操作系統底層原理到Java的基礎編程再到分佈式環境等幾個方面深入探索併發問題。先就從原理開始吧。

併發產生的原因

雖然從直觀效果上,處理器是並行處理多項任務,但本質上一個處理器在某個時間點只能處理一個任務,屬於串行執行。在單處理器的情況下,併發問題源於多道程序設計系統的一個基本特性:進程的相對執行速度不可預測,它取決於其他進程的活動、操作系統處理中斷的方式以及操作系統的調度策略。在分佈式環境下,併發產生的可能性就更大了,只要大家有依賴的共享資源,就有併發問題的出現,因爲互相調用次序更加沒法控制。

併發帶來的問題

  • 全局資源的共享充滿了危險。不同任務對同一個共享資源的讀寫順序非常關鍵
  • 操作系統很難對分配資源進行最優化管理。掛起的線程佔有了其他活動線程需要的資源
  • 定位錯誤非常困難。這種問題來源和觸發的不確定性,導致定位問題非常困難
  • 限制分佈式系統橫向擴展能力

進程的交互

進程的交互方式決定了併發問題產生的上下文,解決併發問題也需根據進程交互方式的不同而不同對待。一般進程交互分爲以下三種:

1)進程間相互獨立

這種情況下雖然進程間沒有數據共享,所做事情也互不聯繫,但它們存在競爭關係。計算機中有些臨界資源比如I/O設備、存儲器、CPU時間和時鐘等等都需要通過競爭得到,你佔用的時候就得保證別人沒法佔用,因此首先得解決這種互斥的需求。另外,要處理好這種臨界資源的調度策略,處理不當就有可能發生死鎖和飢餓

2)進程間通過共享合作

這種情況下進程間雖然執行的過程是相互獨立的,互不知道對方的執行情況,但互相之間有共享的數據。因此除了有以上互斥需求和死鎖飢餓的可能,另外還會有數據一致性的問題。當多個進程非原子性操作同一個數據時候,互相之間操作時序不當就有可能造成數據不一致

3)進程間通過通信合作

這種情況下進程間通過消息互相通信,知曉各自的執行情況,不共享任何資源,因此就可以避免互斥和數據不一致問題,但仍然存在死鎖和飢餓的問題

併發問題的解決辦法

操作系統解決併發問題一般通過互斥,爲了提供互斥的支持,需要滿足以下需求:

  • 一次只允許一個進程進入臨界區
  • 一個非臨界區停止的進程必須不干涉其他進程
  • 不允許出現一個需要訪問臨界區的進程被無限延遲
  • 一個進程駐留在臨界區中的時間必須是有限的
  • 臨界區空閒時,任何需要進入臨界區的進程必須能夠立即進入

滿足互斥的解決方案:

1)硬件支持

  • 中斷禁用
    中斷禁用簡單說來就是在某一進程在臨界區執行過程中禁用中斷,不允許其他進程通過中斷打斷其執行。雖然這種方式可以保證互斥,但代價非常高,處理器被限制於只能交替執行程序,效率降低。另外不適用於多處理器環境。
  • 專用機器指令
    從硬件的角度提供一些機器指令,用於保證多個動作的原子性,通過適用這些具有原子性的指令來控制臨界區的訪問。比如提供符合以下邏輯的原子性指令:
    1. boolean testset(int i){  
    2.     if(i==0){  
    3.         i=1;  
    4.         return true;  
    5.     }else{  
    6.         return false;  
    7.     }  
    8. }  

    在控制臨界區的時候可以通過忙等待來保證只有一個進程停留在臨界區,僞代碼如下所示:
    1. int bolt;  
    2. void onlyOneThread(){  
    3.     while(!testset(bolt)){  
    4.         /*等待*/  
    5.     }  
    6.     /*臨界區*/  
    7.     bolt=0;  
    8. }  

    專用機器指令的優點是可以不限制處理器數量,也不限制臨界區的數量,但它的問題是使用了忙等待,消耗處理器時間。並且也存在飢餓和死鎖的問題

2)信號量

其原理是多個進程可以通過簡單的信號進行合作,一個進程可以被迫在某一個位置停止,直到它收到一個特定的信號,再重新被喚起工作。這種方式最大優點就是解決了忙等待的問題。其核心和機器指令類似,通過提供原子性信號量控制方法,一般情況下提供等待和喚起兩種操作原語,以較爲簡單的二元信號量原語爲例,兩種方法的僞代碼如下:

  1. void wait(semaphore s){  
  2.     if(s.value==1){  
  3.         s.value=0;  
  4.     }else{  
  5.         /*停止此線程,並把線程放入s的線程等待隊列(s.queue)裏*/  
  6.     }  
  7. }  
  8. void signal(semaphore s){  
  9.     if(s.queue.size()==0){  
  10.         s.value=1;  
  11.     }else{  
  12.         /*從s的線程等待隊列(s.queue)裏拿出一個線程,使其激活執行*/  
  13.     }  
  14. }  

兩個方法的實現關鍵在於其原子性,當然也可以藉助專用機器指令的方法來保障其原子性,畢竟這兩種方法的執行不長,使用忙等待也問題不大。

再看互斥的問題,若使用信號量,則其具體實現如以下僞代碼所示:

  1. void onlyOneThread(){  
  2.     wait(s);  
  3.     /*臨界區*/  
  4.     signal(s);  
  5. }  

3)管程

信號量雖然解決了性能問題,但使得信號量的控制邏輯遍佈在程序裏,控制邏輯複雜以後很難整體上控制所有信號量。而管程的思路和麪向對象類似,通過一個管程的概念把互斥和資源保護的細節封裝在管程的內部,外部程序只需對管程的正確使用就能保證避免併發問題,管程的特點如下:

  • 共享數據變量只能被管程的過程訪問
  • 一個進程通過調用管程的一個過程進入管程
  • 只能有一個進程在管程中執行,其他進程被掛起,等待進入管程

4)消息傳遞

消息傳遞是通過消息通信的方式進程之間相互配合,滿足互斥需求。這種方式最大好處就是可以運用與分佈式環境。說到消息,抽象地看有兩種操作方式:send和receive。從同步方式上看分爲阻塞和非阻塞兩種,其組合起來有以下 情況:

  • 阻塞send,阻塞receive。發送進程和接收進程都被阻塞,直到信息交付,同步性最好
  • 非阻塞send,阻塞receive。最爲自然的一對組合
  • 非阻塞send,非阻塞receive。

那麼通過實現以上send和receive原語操作,就可達到互斥的目的,以下面僞代碼爲例,其中receive爲阻塞的,send爲非阻塞的:

  1. void onlyOneThread(){  
  2.     receive(box,msg);  
  3.     /*臨界區*/  
  4.     send(box,msg);  
  5. }  

小結

以上是從操作系統的底層來看待併發問題,平常的開發過程一般不需要了解,但透過其原理,我們可以發掘一些解決併發問題的思路。只有真正瞭解併發產生的原因和操作系統採取的辦法,我們才能理解在更高一個層次(比如高級語言編程)爲什麼有那些控制和措施,爲什麼對一些代碼要做併發控制。

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