併發王者課-鉑金4:令行禁止-為何說訊號量是執行緒間的同步利器

秦二爺發表於2021-06-22

歡迎來到《併發王者課》,本文是該系列文章中的第17篇

在併發程式設計中,訊號量是執行緒同步的重要工具。在本文中,我將帶你認識訊號量的概念、用法、種類以及Java中的訊號量。

訊號量(Semaphore) 是執行緒間的同步結構,主要用於多執行緒協作時的訊號傳遞,以及對共享資源的保護、防止競態的發生等。訊號量這一概念聽起來比較抽象,然而讀完本文你會發現它竟然也是如此通俗易懂且挺有用。

一、認識簡單的訊號量

雖然訊號量的概念很抽象,但理解起來可以很簡單。比如下面這幅圖,在峽谷對局中,大喬使用大招向哪吒發起了救援,而哪吒在接收到求救訊號後前往救援

在救援的過程中,訊號無疑是關鍵的。如果把大喬和哪吒看作兩個執行緒,那麼他們在求救、救援過程中的訊號就可以看作是訊號量用於執行緒間的同步和通訊

接下來,我們寫一個簡單的訊號量,模擬還原剛才的求救和施救的過程。

定義一個求救的訊號量,裡面包含訊號訊號傳送訊號接收

// 求救訊號
public class ForHelpSemaphore {
    private boolean signal = false;

    public synchronized void sendSignal() {
        this.signal = true;
        this.notify();
        System.out.println("呼救訊號已經傳送!");
    }

    public synchronized void receiveSignal() throws InterruptedException {
        System.out.println("已經就緒,等待求救訊號...");
        while (!this.signal) {
            wait();
        }
        this.signal = false;
        System.out.println("求救訊號已經收到,正在前往救援!");
    }
}

再建立兩個執行緒,分別代表大喬和哪吒。

 public static void main(String[] args) {
   ForHelpSemaphore helpSemaphore = new ForHelpSemaphore();

   Thread 大喬 = new Thread(helpSemaphore::sendSignal);
   Thread 哪吒 = new Thread(() -> {
     try {
       helpSemaphore.receiveSignal();
     } catch (InterruptedException e) {
       e.printStackTrace();
     }
   });

   大喬.start();
   哪吒.start();
 }

從執行結果中可以看到,他們通過訊號量的機制完成了救援行動。

你看,最簡單的訊號量就是這樣的簡單。

二、理解寬泛意義上的的訊號量

如果把上面大喬和哪吒救援的例子做個梳理的話,可以發展訊號量中的一些關鍵資訊:

  • 共享的資源。比如signal欄位是兩個執行緒共享的,它是兩個執行緒協同的基礎;
  • 多個執行緒訪問相同的共享資源,並根據資源狀態採取行動。比如大喬和哪吒都會讀寫signal欄位,然後採取行動。

基於上面的兩點理解,我們可以把訊號量抽象為下面這張圖所示:

從圖中可以看到,多個執行緒共享一份資源列表,但是資源是有限的。所以,執行緒之間必然要按照一定的順序有序地訪問資源,並在訪問結束後釋放資源。沒有獲得資源的執行緒,只能等待其他執行緒釋放資源後再次嘗試獲取

多執行緒對共享資源的訪問過程,也可以用下面這張流程圖表示:

如果你能把這兩幅圖理解了,那麼你也就把訊號量的機制理解了。而一旦理解了機制,所謂的原始碼不過只是某種具體的實現。

三、認識不同型別的訊號量

根據訊號量的機制和應用場景,一般有下面幾種不同型別的訊號量。

1. 計數型訊號量

public class CountingSemaphore {
  private int signals = 0;
  public synchronized void take() {
    this.signals++;
    this.notify();
  }
  public synchronized void release() throws InterruptedException {
    while (this.signals == 0)
      wait();
    This.signals--;
  }
}

2. 邊界型訊號量

在計數型訊號量中,訊號的數量是沒有限制的。換句話說,所有的執行緒都可以傳送訊號。與此不同的是,在邊界型訊號量中,通過bound欄位增加了訊號量的限制。

public class BoundedSemaphore {
  private int signal = 0;
  private int bound = 0;

  public BoundedSemaphore(int upperBound) {
    this.bound = upperBound;
  }
  public void synchronized take() throws InterruptedException {
    while (this.signal == bound)
      wait();
    this.signal++;
    this.notify++;
  }
  public void synchronized release() throws InterruptedException {
    while (this.signal == 0)
      wait();
    this.signal--;
  }
}

3. 定時型訊號量

定時型(timed)訊號量指的是允許執行緒在指定的時間週期內才能執行任務。時間週期結束後,定時器將會重置,所有的許可也都會被回收。

4. 二進位制型訊號量

二進位制訊號量和計數型訊號量類似,但許可的值只有0和1兩種。實現二進位制型訊號量相對也是比較容易的,如果是1就是成功,否則是0就是失敗。

四、Java中的訊號量

在理解了訊號量機制並且也理解它很有用之後,先不用著急實現它。在Java中,已經提供了相應的訊號量工具類,即java.util.concurrent.Semaphore。並且,Java中的訊號量實現已經比較全面,你不需要再重寫它。

1. Semaphore的核心構造

Semaphore類有兩個核心構造:

  1. Semaphore(int num)
  2. Semaphore(int num, boolean fair)

其中,num表示的是允許訪問共享資源的執行緒數量,而布林型別的fair則表示執行緒等待時是否需要考慮公平。

2. Semaphore的核心方法

  1. acquire(): 獲取許可,如果當前沒有可用的許可,將進入阻塞等待狀態;
  2. tryAcquire():嘗試獲取許可,無論有沒有可用的許可,都會立即返回;
  3. release(): 釋放許可;
  4. availablePermits():返回可用的許可數量。

五、如何通過訊號量實現鎖的能力

在上面的示例中,由於訊號量可以用於保護多執行緒對共享資源的訪問,所以直覺你可能會覺得它像一把鎖,而事實上訊號量確實可以用於實現鎖的能力。

比如,藉助於邊界訊號量,我們把執行緒訪問的上線設定為1,那麼此時將只有1個執行緒可以訪問共享資源,而這不就是鎖的能力嘛!

下面是通過訊號量實現鎖的一個示例:

BoundedSemaphore semaphore = new BoundedSemaphore(1);
...
semaphore.take();
try {
  //臨界區
} finally {
  semaphore.release();
}

我們把訊號量中的訊號數量上限設定為1,程式碼中的take()就相當於lock(),而release()則相當於unlock()。如此,訊號量搖身一變就成了名副其實的鎖

小結

以上就是關於訊號量的全部內容。在本文中,我們介紹了訊號量的概念、執行機制、訊號量的幾種型別、Java中的訊號量實現,以及如果通過訊號量實現一把鎖。

理解訊號量的關鍵在於理解它的概念,也就是它所要解決的問題和它的方案。在理解概念和機制之後,再去看Java中的原始碼時,就會發現原來如此,又是佇列...

正文到此結束,恭喜你又上了一顆星✨

夫子的試煉

  • 基於對訊號量的理解,嘗試自己實現一個簡單的訊號量。

延伸閱讀與參考資料

關於作者

關注公眾號【庸人技術笑談】,獲取及時文章更新。記錄平凡人的技術故事,分享有品質(儘量)的技術文章,偶爾也聊聊生活和理想。不販賣焦慮,不做標題黨。

如果本文對你有幫助,歡迎點贊關注監督,我們一起從青銅到王者

相關文章