併發編程不徹底指北(二)

本文發於公衆號「百川海的小記」,一個小菜鳥的自留地,歡迎關注討論。另外本文的用圖,部分修改自極客時間專欄,侵刪java


寫在前面c++

本篇是《併發編程不徹底指北》第二篇,第一篇中討論了併發、併發問題與併發問題的緣由,歡迎閱讀留言討論。連接:併發編程不徹底指北(一)算法


################正題#################編程



進程/線程的協做關係併發

接下來這一部分聊聊進程/線程間的協做關係問題。post

在聊進程線程協做關係前,首先要確認一點,就是兩個進程線程間存在臨界資源。這裏先說明一下這個臨界資源怎麼定義。學習過操做系統原理的同窗都應該記得:「進程是系統資源分配的最小單位」,爲何這裏會連帶上線程呢?性能

由於這裏定義的臨界資源,起碼是Java編程上的「資源」的概念,應該是從邏輯上來理解的。畢竟Java程序啓動就是一個獨立的進程,後面的全部數據交互都只是線程間的交互。好比,咱們說「建立線程會給線程分配一個獨立的虛擬機棧空間」,這裏的「分配」就不是對於操做系統來講,而是基於JVM來講的。從操做系統的角度來講,這個過程其實並無任何系統資源分配動做發生,這些操做都只是進程內部的運做,從進程的視角都是邏輯分配而已,可是這並不妨礙咱們理解資源分配的這一個概念。這是對進程與線程的資源分配概念的一點說明。學習

說回臨界資源,咱們說臨界資源是對多個進程/線程開放訪問的資源。在沒有附加限制的條件下,開放意味着全部的進程/線程能夠在任意時刻對資源進行訪問,訪問的形式能夠是輸入或者輸出。若是兩個進程/線程之間不存在臨界資源,那麼它們之間的運做從邏輯上是徹底獨立的,不管如何顛倒執行順序,都不對結果有任何影響,這能夠理解爲資源隔離,這一點應該比較好明白的。優化

而若是兩個進程/線程間出現了臨界資源,它們之間就存在相互關係。關係主要分爲兩種:互斥與同步this

互斥在以前討論併發問題的內容中討論得比較多了,一句到底,互斥的意義在於保護臨界資源,保護臨界資源在同一時間節點只被有限的線程進行操做。而同步,就是進程/線程間的相互等待的表現。通常來講,互斥和同步都是有條件的:互斥的條件控制了互斥發生的必要性;同步的條件則做爲喚醒條件出現,由於等待都是須要喚醒的,若是沒有喚醒條件,那麼等待的喚醒就不能發生,或者說發生變得不可控。

互斥與同步的出現每每是同時的,二者是相互依賴的,而又都依賴於臨界資源的存在的。由於這裏有一個邏輯:

  • 出現了臨界資源,因此要保護臨界資源的訪問,因而造成了互斥條件;

  • 出現了互斥,就意味着某些進程/線程須要發生等待

  • 進程/線程的等待須要被喚醒,長睡不起是不符合合法程序原則的;

  • 須要喚醒,就須要有喚醒的條件,這個喚醒的條件也就是等待的條件,或者說是等待的限度;

  • 等待條件的出現,意味着同步關係的造成。

可見,互斥與同步是相互的,辯證的,不然是不能造成有效控制的。


信號量模型

那麼如何良好地控制程序的互斥與同步呢?前輩大牛們已經總結出來了一些可靠的套路,稱爲併發模型。併發模型是併發問題得以解決的核心,由於控制好互斥與同步能夠有效地避免併發問題,而併發模型又能夠可靠地控制互斥與同步,因此將具體問題直接往合適的模型上面套,解決的編程方案就呼之欲出了。

比較通用的模型,這裏介紹兩個,第一個是信號量模型。

這裏是信號量模型的一個示意圖,咱們結合這個圖來看。


這個黑框,咱們定義爲一個或一組臨界資源具備互斥性的範圍,黑框的邊界便是臨界資源訪問的邊界。咱們將整個黑框看做一個對象,內部定義一個計數器,通常稱爲信號量,一個等待隊列,對外定義了三個操做,分別是Init,P操做與V操做。不少資料都不將Init初始化這個動做視做爲一個對外的操做,信號量模型的重點主要在於PV操做。

下面簡要說明這個模型是怎麼運做的,結合下面的僞代碼:

Sempaphore {
  Number c = Number(n)
  BlockingQueue q P() {
    c--
    if(c<0)
      block()
  }
  
  V() {
    c++
    if(c<=0)
      wakeupOne()
  }
}
複製代碼

  1. Init初始化,先將信號量的值初始化爲臨界資源的數量

  2. P操做是一個原語,所以也被普遍地稱爲P原語。原語的邏輯是這樣的:首先將信號量-1,而後判斷信號量是否小於0。若是爲真,則P原語結束,意味着該進程/線程獲取到臨界資源的訪問權限;若是爲假,則進入進程/線程進入等待隊列,並進入阻塞狀態。

  3. V操做也是一個原語,所以被稱爲V原語,也和P原語合稱PV原語。它的邏輯是:首先將信號量+1,而後判斷信號量是否小於等於0。若是爲真,則選擇喚醒等待隊列的一個等待進程/線程;若是爲假,不作動做。以後V原語隨即結束,同時意味着執行原語的進程/線程放棄了臨界資源的訪問權限,訪問流程結束。

  4. 須要注意,阻塞不是終止,從阻塞隊列中被喚醒的進程/線程,還處於P操做的執行過程當中,被喚醒後依然須要依據後面的邏輯指令繼續執行。固然,在P操做裏面也沒有其餘後續指令,操做結束,能夠直接進入臨界區。

咱們從這個模型裏面分析一下互斥和同步的協做關係是怎麼樣體現的。

在信號量模型中,交互的標識就是信號量。每一個進程/線程在進入臨界區前,先執行P操做,而後進入臨界區,最後執行V操做。

P()
// 臨界區
function()
V()
複製代碼

信號量是能夠大於1的,因此信號量模型互斥不是絕對的排他的,而是依據臨界資源的量容許必定程度的共享的,這就是信號量的初始值應該與臨界資源一致的緣由,也是信號量模型的侷限性之一。

信號量大於0,意味着有非佔用的臨界資源,不會發生任何阻塞;當信號量小於等於0,意味着臨界資源均被佔用,沒有可用的資源,這時候就體現出互斥的特性,P操做會阻塞後續執行的任務,從而起到互斥的效果。而同步的條件,就是V操做中判斷的「信號量小於等於0」,意義是存在至少一個正處於阻塞的進程/線程等待喚醒,只要知足同步條件,就實施一次同步喚醒。PV操做老是成對出現,P操做在進入臨界區前體現互斥特性,V操做則在退出臨界區時體現同步喚醒的特性。


管程模型

信號量模型的確有效地知足了進程/線程協做的須要,可是它的缺點也比較明顯,歸結起來主要有兩點:

  • 一是模型的阻塞同步依賴信號量,而信號量又強依賴臨界資源的數量,這使得互斥與同步條件的設置強關聯於臨界資源,不夠靈活;

  • 二是模型中定義的阻塞隊列只能是惟一的,這是模型的一個設定,畢竟模型也沒有提供其餘可用的變量了。

雖然從理論上來講,信號量模型能夠經過擴展臨時變量的定義和模型複合來解決實際的業務問題,可是在工程實現上依然比較複雜,因而大牛們提出了一種新的模型——管程模型。

管程的英語單詞對應的是monitor,直譯是「監視器」,「管程」的翻譯形式是來源於操做系統原理中的說法,可是比起監視器,管程更能體現模型的本質,所以我也比較認同將這個模型翻譯爲管程模型,而不是監視器模型。在Java中,synchronized關鍵詞就是典型的管程模型實現,JUC併發包的Lock使用也是管程模型的形式實現,可見Java語言的開發者從工程實踐上,承認而且選擇了管程模型。

管程模型在具體的工程實現上,又分了幾種常見的形式,好比Hansen模型,Hoare模型,MESA模型。這幾個模型大致差別不大,這裏重點討論在Java選擇實現的MESA模型,另外兩個模型的一些差別點,在聊過MESA模型以後適當補充便可。

MESA管程模型是這幾個管程模型裏面最後生的,它誕生於上世紀70年代後期,如今已經在工程上被普遍應用,久經考驗。

MESA模型的示意圖如圖


和信號量模型相似,這個黑框也是一個臨界範圍,黑框邊界也是外部訪問的邊界。整個模型的定義分爲幾個部分:

  • 臨界資源(也就是圖中的共享資源)

  • 若干個外部方法

  • 若干個條件變量

  • 與條件變量一一對應的條件等待阻塞隊列

  • 還有一個入口等待隊列

下面結合僞代碼說明這個模型是怎麼運做的:

  1. 線程在進入臨界區以前,先進入入口等待隊列。一個線程須要先從入口等待隊列出隊,才能申請進入臨界區

  2. 臨界區只容許一個線程進入,所以經過修改互斥標識的方式阻塞其餘線程進入

  3. 而後線程依次檢查模型中定義的各個條件變量,一旦發現不知足某個條件變量,則進入該條件變量對應條件等待隊列,放棄臨界區的獨佔並進入blocking阻塞狀態,也就是對特定條件變量的條件等待狀態。一旦線程進入等待狀態,在入口等待隊列中的其餘線程就能夠再次嘗試申請臨界區的訪問

  4. 若是線程進入臨界區後,對全部條件變量都徹底知足,則能夠對臨界資源實施訪問操做

  5. 資源操做完畢以後,再次依次斷定各個條件變量是否知足,若是條件變量成立,則喚醒對應條件等待隊列中的線程。從條件等待隊列中被喚醒的線程,只能從新進入入口等待隊列開始新一輪的排隊

  6. 喚醒操做結束以後,線程準備離開臨界區,修改互斥標識,放棄臨界區獨佔。

相比於信號量模型,管程模型主要的優勢是它容許了多個條件變量與條件隊列定義。這個變化讓等待條件再也不受到臨界資源的限制,能夠更加靈活自由,還能夠知足多個不一樣的臨界資源相互做用的複合狀況。可是管程模型再也不自然地容許多個線程同時進入臨界區,這既有好處,也有壞處。

上面提到Java的synchronized關鍵字就是典型的MESA管程模型的應用,如今能夠回過頭來討論這一個話題了。Java中的synchronized關鍵字,不管是修飾在什麼位置,本質上都是依據一個特定的monitor做爲互斥標識進行互斥操做的(編注:在1.6版本之前,synchronized關鍵字都是以此方式實現,在1.6之後,隨着synchronized的優化,monitor主要用於重量級鎖的實現),這個monitor取自於對象的內存定義:在JVM的對象頭定義中,以1個字寬長度(編注:32位/64位JVM分別佔用32/64 bit)存儲Mark Word,在Mark Word中包含monitor對應的互斥量指針。下面是Java1.6後的Mark Word的內存基本接口示意表格,注意重量級鎖的定義:


根據不一樣的修飾方式,實際上取用的monitor也不同:

  • 若是修飾靜態方法或靜態塊上,依據的是class加載對象的monitor;

  • 若是修飾在普通方法上,使用的是this,也就是對象自己的monitor;

  • 若是指定特定對象進行修飾,使用的就是特定對象的monitor。

Synchronized是最簡單的管程模型形式,它沒有設定任何的條件變量,因此惟一的等待條件臨界區中的線程離開臨界區。這樣在套用回到管程模型的流程,synchronized的實現機制就很好理解了。

下面再舉一個稍微複雜的例子,也是一個很是經典的問題:生產者消費者問題。問題你們應該都知道,就不贅述了。下面是一個借阻塞隊列定義的生產者消費者問題代碼,入隊至關於生產,出隊至關於消費:

public class BlockedQueue<T>{
    final Lock lock = new ReentrantLock();
    // 條件變量:隊列不滿
    final Condition notFull = lock.newCondition();
    // 條件變量:隊列不空
    final Condition notEmpty = lock.newCondition();
    final List<T> list = new ArrayList<>();
    final int upperLimit;

    public BlockedQueue(int upperLimit) {
        this.upperLimit = upperLimit;
    }

    // 入隊,生產
    public void enq(T x) throws InterruptedException {
        lock.lock();
        try {
            while (list.size() == upperLimit){
                // 等待隊列不滿
                notFull.await();
            }
            // 省略入隊相關操做...
            // 入隊後, 通知可出隊
            notEmpty.signalAll();
        }finally {
            lock.unlock();
        }
    }
    // 出隊,消費
    public void deq() throws InterruptedException {
        lock.lock();
        try {
            while (list.size() == 0){
                // 等待隊列不空
                notEmpty.await();
            }
            // 省略出隊相關操做...
            // 出隊後,通知可入隊
            notFull.signalAll();
        }finally {
            lock.unlock();
        }
    }
}
複製代碼

在這裏的代碼中,用到了JUC併發包的一個典型用法,Lock & Condition。Lock的概念,就至關於管程模型中的互斥標識,而Condition的概念,就至關於管程模型中的條件變量,所以對於一個Lock,能夠定義零個至多個Condition,正如模型中能夠設置任意多個條件變量。這裏對程序的條件變量簡單解釋一下:

  • 當隊列已滿時,生產者不能再生產,經過條件變量notFull進行阻塞,notFull的意思是隊列不滿則容許操做;

  • 當隊列爲空時,消費者不能再消費,經過條件變量notEmpty進行阻塞,notEmpty的意思是隊列非空則容許操做。

這段代碼也是遵循MESA管程模型的,仍是幾個步驟:

  • 排他互斥;

  • 循環檢查與阻塞等待;

  • 臨界資源操做;

  • 阻塞喚醒;

  • 釋放互斥。

這裏的寫法是用了一把鎖,引伸了兩個條件變量,這裏能夠思考幾個小問題:

  • 這段代碼是否能夠只用一個條件變量實現呢?

  • 用一個條件變量對性能和語義上有什麼影響呢?

  • 那如今的形式是否是最優呢?

  • 若是不是最優的寫法,那如何進一步優化呢?

併發問題的困難和有趣之處,大概也見於這些反反覆覆的問題上面了。


MESA管程模型的兩個注意點

讓咱們拋開生產者消費者問題這個特定場景,回到MESA模型自己。這裏還有兩個值得注意的要點,其中第一個要點是,咱們對於條件變量判斷與阻塞的操做,必須使用循環,好比上面代碼就用到while死循環。這裏的while是不能被if替代,這是MESA模型特有的。由於在MESA模型中,當線程A喚醒條件等待隊列中的線程B以後,線程A會繼續運行,而線程B不會真正地立刻開始執行,而是從條件等待隊列轉移到入口等待隊列,繼續排隊。所以,當線程B恢復現場從新開始執行時,由於和被喚醒的時間中已經存在時間差,條件變量已經不必定符合了,所以必須從新進行條件變量判斷,若是不符合條件變量,則要再次進入阻塞。

相較MESA的前輩Hansen模型和Hoare模型,入口等待隊列是MESA特有的,它是爲了解決喚醒線程A與被喚醒線程B之間執行順序的爭搶而設計的。而對於Hansen模型和Hoare模型,他們也給出了不一樣的策略:

  • Hansen模型在線程A喚醒線程B以後,A就立刻結束,B緊接着執行,因此Hansen模型必需要求喚醒操做必須放在臨界區的最後,增長了實現的限制性;

  • Hoare模型則選擇在A喚醒B後,直接阻塞線程A,將訪問權讓渡給線程B,線程B立刻開始執行,直到線程B執行結束之後再從新喚醒線程A,這種思路的代價是每次操做都要增長一次喚醒操做,擬製了效率;

  • 而MESA的作法,就是設計了入口等待隊列,做爲線程B執行的緩衝,對執行效率和實現的靈活性作了平衡的選擇。

關於MESA模型的第二個要點,是喚醒的方式。在MESA模型中,咱們推薦使用全阻塞隊列喚醒,而非單阻塞線程喚醒,對Java來講,也就是notifyAll()優於notify()。這是爲什麼呢?緣由在於活躍性的考慮:由於notify是根據具體實現的調度算法決定出隊線程的,頗有多是一個隨機的選擇,也就是說,notify喚醒的是線程是不肯定。

試着考慮下面的場景:一個條件變量同時控制着資源A和資源B,線程1和線程2分別由於資源A、B阻塞在條件等待隊列中。此時資源A被釋放,若是使用notify,則有且只有一個線程被喚醒。根據notify喚醒對象不肯定性,可能發生如下事件:

  1. 線程2被喚醒

  2. 線程2在執行一輪空轉之後,從新進入阻塞狀態(由於線程2是須要佔用的是資源B,而非資源A)

  3. 再次觸發notify

  4. 線程2被喚醒,重複1……

以上循環形成的空轉流程可能屢次重複,這個空轉的運算,既浪費了CPU資源,也下降了真正有執行資源的線程1的及時性,甚至有可能形成特定的線程出現飢餓問題。

關於活躍性問題,咱們在下文再去進一步討論。


本節總結

這一節主要從進程/線程的協做關係提及,討論協做關係的內在邏輯關係。而後介紹兩種併發協做模型:信號量模型與管程模型,其中管程模型又着重已舉例的方式說明了MESA管程模型的運做。最後,補充介紹了一下幾個管程模型的區別和MESA模型的一些要點


(未完待續)……

相關文章
相關標籤/搜索