鎖原理 - 信號量 vs 管程:JDK 為什么選擇管程


鎖原理 - 信號量 vs 管程:JDK 為什么選擇管程

並發編程之美系列目錄:https://www.cnblogs.com/binarylei/p/9569428.html

管程和信號量都能解決並發問題,它們是等價的。所謂等價指的是用管程能夠實現信號量,也能用信號量實現管程。但是管程在信號量的基礎上提供條件同步,使用更容易,所以 Java 采用的是管程技術。synchronized 關鍵字及 wait()、notify()、notifyAll() 這三個方法都是管程的組成部分。

1. 並發編程解決方案 - 信號量 vs 管程

1.1 相關概念

  1. 臨界資源:雖然多個進程可以共享系統中的各種資源,但其中許多資源一次只能為一個進程所使用,我們把一次僅允許一個進程使用的資源稱為臨界資源。許多物理設備都屬於臨界資源,如打印機等。此外,還有許多變量、數據等都可以被若干進程共享,也屬於臨界資源。
  2. 臨界區:對臨界資源的訪問,必須互斥地進行,在每個進程中,訪問臨界資源的那段代碼稱為臨界區。
  3. 互斥:只有一個線程能訪問臨界區。

1.2 信號量 vs 管程

並發編程這個技術領域已經發展了半個世紀了,相關的理論和技術紛繁復雜。那有沒有一種核心技術可以很方便地解決我們的並發問題呢?事實上,鎖機制的實現方案有兩種:

  • 信號量(Semaphere):操作系統提供的一種協調共享資源訪問的方法。和用軟件實現的同步比較,軟件同步是平等線程間的的一種同步協商機制,不能保證原子性。而信號量則由操作系統進行管理,地位高於進程,操作系統保證信號量的原子性。
  • 管程(Monitor):解決信號量在臨界區的 PV 操作上的配對的麻煩,把配對的 PV 操作集中在一起,生成的一種並發編程方法。其中使用了條件變量這種同步機制。

說明: 信號量將共享變量 S 封裝起來,對共享變量 S 的所有操作都只能通過 PV 進行,這是不是和面向對象的思想是不是很像呢?事實上,封裝共享變量是並發編程的常用手段。

在信號量中,當 P 操作無法獲取到鎖時,將當前線程添加到同步隊列(syncQueue)中。當其余線程 V 釋放鎖時,從同步隊列中喚醒等待線程。但當有多個線程通過信號量 PV 配對時會異常復雜,所以管程中引入了等待隊列(waitQueue)的概念,進一步封裝這些復雜的操作。

2. 信號量(Semaphere)

2.1 原理

信號中包括一個整形變量,和兩個原子操作 P 和 V。其原子性由操作系統保證,這個整形變量只能通過 P 操作和 V 操作改變。

  • 信號量由一個整形變量 S 和兩個原子操作 PV 組成。
  • P(Prolaag,荷蘭語嘗試減少):信號量值減 1,如果信號量值小於 0,則說明資源不夠用的,把進程加入等待隊列。
  • V (Verhoog,荷蘭語增加):信號量值加 1,如果信號量值小於等於 0,則說明等待隊列里有進程,那么喚醒一個等待進程。

說明: 共享變量 S 只能由 PV 操作,PV 的原子性由操作系統保證。P 相當獲取鎖,可能會阻塞線程,而 V 相當於釋放鎖,不會阻塞線程。根據同步隊列中喚醒線程的先后順序,可以分為公平和非公平兩種。

信號量分類:

  • 二進制信號量:資源數目為 0 或 1。
  • 資源信號量:資源數目為任何非負值。

2.2 代碼實現

private class Semaphore {
    private int sem;
    private WaitQueue q;

    void P() {
        sem--;
        if (sem < 0) {
            // add this thread t to q;
            block(t);
        }
    }

    void V() {
        sem++;
        if (sem <= 0) {
            // remove a thread t from q;
            wakeup(t);
        }
    }
}

說明: Semaphere 的思路很簡單,就是將共享變量 S 及其所有操作 PV 統一封裝起來。事實上,封裝共享變量是並發編程的常用手段。

2.3 使用場景

2.3.1 互斥訪問

Semaphore mutex = new Semaphore(1);
mutex.P();
// do something
mutex.V();

實現臨界區的互斥訪問注意事項: 一是信號量的初始值必須為 1;二是 PV 必須配對使用。

2.3.2 條件訪問

Semaphore condition = new Semaphore(0);
// ThreadA,進行等待隊列中
condition.P();

// ThreadB,喚醒等待線程 ThreadA
condition.V();

實現臨界區的條件訪問注意事項: 初始信號量必須為 0,這樣所有的線程調用 P 操作時都無法獲取到鎖,只能進行等待隊列(相當於管程中的等待隊列),當其余線程 B 調用 V 操作時會喚醒等待線程。

2.3.3 阻塞隊列

阻塞隊列是典型的生產者-消費者模式,任何時刻只能有一個生產者線程或消費都線程訪問緩沖區。並且當緩沖區滿時,生產者線程必須等待,反之消費者線程必須等待。

  1. 任何時刻只能有一個線程操作緩存區:互斥訪問,使用二進制信號量 mutex,其信號初始值為 1。
  2. 緩存區空時,消費者必須等待生產者:條件同步,使用資源信號量 notEmpty,其信號初始值為 0。
  3. 緩存區滿時,生產者必須等待消費者:條件同步,使用資源信號量 notFull,其信號初始值為 n。
private class BoundedBuffer {
    private int n = 100;
    private Semaphore mutex = new Semaphore(1);
    private Semaphore notFull = new Semaphore(n);
    private Semaphore notEmpty = new Semaphore(0);

    public void product() throws InterruptedException {
        notFull.P();      // 緩沖區滿時,生產者線程必須等待
        mutex.P();
        // ...
        mutex.V();
        notEmpty.V();      // 喚醒等待的消費者線程
    }

    public void consume() throws InterruptedException {
        notEmpty.P();      // 緩沖區空時,消費都線程等待
        mutex.P();
        // ...
        mutex.V();
        notFull.V();      // 喚醒等待的生產者線程
    }
}

總結: 直接使用信號量,當多個 Semaphore 條件同步時,PV 配對比較困難而且容易寫錯。為了解決 PV 配對困難的問題,管程登場了。管程實際上是對條件同步的進一步封裝。

2. 管程(Monitor)

Monitor 直譯過來就是 "監視器",操作系統領域一般都翻譯成 "管程"。所謂管程,指的是管理共享變量以及對共享變量的操作過程,讓他們支持並發。翻譯為 Java 領域的語言,就是管理類的成員變量和成員方法,讓這個類是線程安全的。那管程是怎么管的呢?

2.1 MESA 模型

在管程的發展史上,先后出現過三種不同的管程模型,分別是:Hasen 模型、Hoare 模型和 MESA 模型。其中,現在廣泛應用的是 MESA 模型,並且 Java 管程的實現參考的也是 MESA 模型。所以今天我們重點介紹一下 MESA 模型。

在並發編程領域,有兩大核心問題:一個是互斥,即同一時刻只允許一個線程訪問共享資源;另一個是同步,即線程之間如何通信、協作。這兩大問題,管程都是能夠解決的。

2.2 互斥

管程和信號量關於互斥的實現完全一樣,都是將共享變量及其操作統一封裝起來。

2.3 同步

在上述用信號量實現生產者-消費者模式的代碼中,為了實現阻塞隊列的功能,即等待-通知(wait-notify),除了使用互斥鎖 mutex 外,還需要兩個判斷隊滿和隊空的資源信號量 fullBuffers 和 emptyBuffers,使用起來不僅復雜,還容易出錯。

管程在信號量的基礎上,更進一步,增加了條件同步,將上述復雜的操作封起來。

JUC AQS 也是基於管程實現的,我們基於 ReentrantLock 實現一個阻塞隊列,重點比較和信號量的區別。阻塞隊列有兩個操作分別是入隊和出隊,這兩個方法都是先獲取互斥鎖,類比管程模型中的入口。

  1. 對於入隊操作,如果隊列已滿,就需要等待直到隊列不滿,即 notFull.await();。
  2. 對於出隊操作,如果隊列為空,就需要等待直到隊列不空,即 notEmpty.await();。
  3. 如果入隊成功,那么隊列就不空了,就需要通知條件變量:隊列不空 notEmpty 對應的等待隊列。
  4. 如果出隊成功,那就隊列就不滿了,就需要通知條件變量:隊列不滿 notFull 對應的等待隊列。
public class BlockedQueue<T> {
    final Lock lock = new ReentrantLock();
    // 條件變量:隊列不滿
    final Condition notFull = lock.newCondition();
    // 條件變量:隊列不空
    final Condition notEmpty = lock.newCondition();

    // 入隊
    void enq(T x) {
        lock.lock();
        try {
            while (隊列已滿) {
                // 等待隊列不滿
                notFull.await();
            }
            // add x to queue
            // 入隊后,通知可出隊
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    // 出隊
    void deq() {
        lock.lock();
        try {
            while (隊列已空) {
                // 等待隊列不空
                notEmpty.await();
            }
            // remove the first element from queue
            // 出隊后,通知可入隊
            notFull.signal();
        } finally {
            lock.unlock();
        }
    }
}

總結: 對於用信號量實現阻塞隊列,是不是感覺要簡單些。這里的 notFull 相當於之前的 fullBuffers,notEmpty 相當於之前的 emptyBuffers。

2.4 wait() 的正確姿勢

對於 MESA 管程來說,有一個編程范式,就是需要在一個 while 循環里面調用 wait()。這個是 MESA 管程特有的。所謂范式,就是前人總結的經驗。

while (條件不滿足) {
    wait();
}

Hasen 模型、Hoare 模型和 MESA 模型的一個核心區別就是當條件滿足后,如何通知相關線程。管程要求同一時刻只允許一個線程執行,那當線程 T2 的操作使線程 T1 等待的條件滿足時,T1 和 T2 究竟誰可以執行呢?

  1. Hasen 模型里面,要求 notify() 放在代碼的最后,這樣 T2 通知完 T1 后,T2 就結束了,然后 T1 再執行,這樣就能保證同一時刻只有一個線程執行。
  2. Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 馬上執行;等 T1 執行完,再喚醒 T2,也能保證同一時刻只有一個線程執行。但是相比 Hasen 模型,T2 多了一次阻塞喚醒操作。
  3. MESA 管程里面,T2 通知完 T1 后,T2 還是會接着執行,T1 並不立即執行,僅僅是從條件變量的等待隊列進到入口等待隊列里面。這樣做的好處是 notify() 不用放到代碼的最后,T2 也沒有多余的阻塞喚醒操作。但是也有個副作用,就是當 T1 再次執行的時候,可能曾經滿足的條件現在已經不滿足了,所以需要以循環方式檢驗條件變量。

思考1:wait() 方法,在 Hasen 模型和 Hoare 模型里面,都是沒有參數的,而在 MESA 模型里面,增加了超時參數,你覺得這個參數有必要嗎?

有必要。Hasen 是執行完再去喚醒另外一個線程,能夠保證線程的執行。Hoare 是中斷當前線程,喚醒另外一個線程,執行玩再去喚醒,也能夠保證完成。而 MESA 是進入等待隊列,不一定有機會能夠執行,產生飢餓現象。

2.5 notify() 何時可以使用

除非經過深思熟慮,否則盡量使用 notifyAll(),不要使用 notify()。

那什么時候可以使用 notify() 呢?需要滿足以下三個條件:

  1. 所有等待線程擁有相同的等待條件;
  2. 所有等待線程被喚醒后,執行相同的操作;
  3. 只需要喚醒一個線程。

比如上面阻塞隊列的例子中,對於“隊列不滿”這個條件變量,其阻塞隊列里的線程都是在等待“隊列不滿”這個條件,反映在代碼里就是下面這 3 行代碼。對所有等待線程來說,都是執行這 3 行代碼,重點是 while 里面的等待條件是完全相同的。

2.6 AQS 和 synchronized 原理

JUC AQS 就是基於管程實現的,內部包含兩個隊列,一個是同步隊列,一個是等待隊列:

  1. 同步隊列:鎖被占用時,會將該線程添加到同步隊列中。當鎖釋放后,會從隊列中喚醒一個線程,又分為公平和非公平兩種。
  2. 等待隊列:當調用 await 是,會將該線程添加到等待隊列中。當其它線程調用 notify 時,會將該線程從等待隊列移動到同步隊列中,重新競爭鎖。

synchronized 也是基於管程實現的,核心的數據結構見 ObjectMonitor。AQS 和 synchronized 都是管程 MESA 模型在 Java 中的應用。一切都套路,有章可循。

參考:

  1. √ 信號量與管程
  2. √ 操作系統同步原語
  3. 深入分析Synchronized原理

每天用心記錄一點點。內容也許不重要,但習慣很重要!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM