JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

條件變數與監視器

號誌很強大,卻毫不寬容:忘掉一個 signal,整個程式就當住。本階梯最後一篇導覽,要帶你看語言改而提供的那個更安全、更有結構的想法——用來等待某個事件的條件變數,以及替你把門鎖好的監視器,讓紀律住進工具裡,而不是住在你的記性裡。

為什麼號誌還不太夠

上一篇導覽結束時,號誌看起來幾乎像在變魔術:一個整數、兩個原子操作,從這兩者你就能搭出一把互斥鎖、一個資源計數器、一個事件信號。但你也見過它鋒利的那一面——它只有在「每一條執行緒都同意:碰資料前一定呼叫 wait、碰完後一定呼叫 signal,每一次都不例外」時,才能保護共享資料。忘了一個 signal,一個呼叫器就永遠離開了盤子;不小心把一個 wait 跟一個 signal 對調,兩條執行緒就悄悄溜進一個一次只准一人的臨界區間,連一句錯誤訊息都沒有。正確性完全寄託在你的紀律裡,還散落在整個程式各處,一個疲憊的午後就能把它弄壞。

還有一個更深的彆扭,不只是手腳笨拙而已。號誌會計數,但它並不自然地表達「等到某個關於我自己資料的條件成真為止」。想想那個必須等到緩衝區不滿的生產者,或一個必須等到餘額至少達到提領金額的銀行帳戶。用原始號誌,你得把每一個這樣的條件編碼成一個獨立的計數器,還要記得從一個個剛好正確的地方去推動它——這裡 empty、那裡 full——於是規則的意義就被抹散在許多 wait 與 signal 呼叫之間,而不是寫在同一個地方。我們真正想要的,是用白話說一句「卡在這裡,直到這件事成真」,然後讓機制去處理記帳。

監視器:一間會自己鎖門的房間

監視器解決紀律問題的辦法,是把鎖直接蓋進程式碼的結構裡。想像一間裝著某些共享資料的小房間,房間的規矩很簡單:一次只能有一個人在裡面。你不必親手上鎖、開鎖;你一踏進去房間就自己鎖上,你一踏出來它就自己開鎖。用程式語言的話來說,監視器把共享變數和操作它們的那些程序綁在一起,而語言保證一次只有一條執行緒能在這些程序當中的任何一個裡頭執行。那把互斥鎖在門口自動取得、自動釋放——你不可能忘記它,因為你從來沒寫過它。

這跟你已經認得的互斥是同一回事,只是從一個約定搬進了房間的牆裡。號誌是一把你必須記得正確握住的利刃,監視器則是一個你只要待在裡面工作的安全形狀。Java 的 synchronized 方法、C++ 用鎖守護的物件,以及 Mesa、Modula 這類較老語言裡的監視器,全都源自這個由 Tony Hoare 與 Per Brinch Hansen 最早描述的想法。實務上的好處極大:最常見的同步臭蟲——沒取鎖就去碰共享資料——變得幾乎不可能發生,因為你根本沒辦法在門沒鎖上的情況下身處房間之內。

條件變數:房間裡的一間等候室

光有互斥還不夠,原因正是我們剛點出的問題:有時一條執行緒進了房間、看了看資料,卻發現自己還不能繼續——緩衝區是空的、餘額太低。它必須等待。可是如果它就這麼空轉或睡著、卻還鎖著門,那就沒有別人進得來改變它正在等的那件事了。這是一個自己造出來的僵局。房間需要一個辦法,讓一條執行緒讓到一旁、放開門好讓別人進來推進、稍後在情況可能已經改變時再被喚醒。

那個機制就是條件變數。它不是計數器,也不存任何值——把它想成一間掛在監視器上、有名字的等候室,外加兩個操作。條件變數同樣提供 wait 與 signal,但它們的意思和號誌的不一樣。對條件變數呼叫 wait 會把三件事當成一個不可分割的步驟做完:把呼叫的執行緒放去睡覺、原子地釋放監視器的鎖好讓別人進得來、把這條執行緒加進那個條件的等候室。呼叫 signal 則喚醒一條睡在那個條件上的執行緒——而如果沒有人在等,signal 就只是什麼都不做、隨即被遺忘。最後這個細節,是與號誌最鋒利的分歧:號誌的 signal 會被記住,成為計數上一個永久的加一。

看它運作:重寫一遍的有界緩衝區

讓我們再走一遍生產者—消費者問題,這次用一個監視器、而不是三個赤裸裸的號誌來表達。共享緩衝區和它的計數住在監視器裡;我們加上兩個條件變數,notFull(生產者在這裡等)與 notEmpty(消費者在這裡等)。發現緩衝區滿了的生產者,不必在某個計數器裡編碼任何東西——它就只是在 notFull 上 wait。騰空一個槽的消費者,在離開的路上就只是 signal notFull,因為它騰出了空間。房間的規矩、等待、喚醒,現在讀起來就像你會說出口的句子。

monitor BoundedBuffer:           # only one thread inside at a time
  buffer[N], count = 0
  condition notFull, notEmpty

  procedure put(item):
    while count == N:              # WHILE, not if -- re-check on wakeup
      wait(notFull)               #   sleeps AND releases the monitor lock
    add item to buffer; count += 1
    signal(notEmpty)              # a consumer may now proceed

  procedure take():
    while count == 0:
      wait(notEmpty)
    remove item; count -= 1
    signal(notFull)               # a producer may now proceed
    return item
鎖是隱形的:它在 put/take 的門口取得、在出口歸還,或在 wait 裡暫時釋放。把這個拿來跟用號誌親手雜耍 empty、full 與一把互斥鎖比一比。
  1. 一條消費者呼叫 take(),門在它身後鎖上,但 count 是 0——緩衝區是空的。
  2. 它撞上 wait(notEmpty):在一個原子步驟裡,它睡著、釋放監視器的鎖、加入 notEmpty 等候室。門現在空出來了。
  3. 一條生產者現在進得了 put(),加進一個項目使 count 變成 1,並在離開前呼叫 signal(notEmpty)。
  4. 睡著的消費者被從等候室移回門邊;一旦它重新取得鎖,它的 while 迴圈重新檢查 count,看到不再是 0,便繼續把項目取走。

誠實的角落:signal-and-wait、signal-and-continue,以及還剩下什麼

有一個微妙之處決定了上面那個 while 迴圈的一切。當監視器裡的一條執行緒呼叫 signal、喚醒了一個沉睡者時,接下來誰跑——仍握著鎖的發信號者,還是現在想把鎖要回去的剛醒執行緒?兩者不能同時在裡面。這有兩個經典答案。在 Hoare 語意(signal-and-wait)下,發信號者立刻讓位,被喚醒的執行緒馬上跑,所以它恢復執行時,條件保證仍為真。在 Mesa 語意(signal-and-continue)下——今天幾乎每個真實系統都用這種——發信號者繼續跑,被喚醒的執行緒只是變成稍後有資格重新進來而已。因為它真正進得來之前已有時間流逝,它的條件可能又變成假——這正是真實程式碼必須用迴圈、而非只檢查一次的全部理由。

第二個誠實的細節:signal 只喚醒一個等待者,但有時你是真的想全部喚醒——好比在讀者—寫者問題裡,一個寫者剛寫完,任意數量的等待中讀者現在都可以繼續。為此有廣播(常叫 signalAll 或 notifyAll),它把那間等候室裡的每一條執行緒都移回門邊。當然它們不會同時全跑——它們從那扇唯一鎖住的門一個接一個魚貫而過,各自在自己的 while 迴圈裡重新檢查條件,而任何條件已經變假的就只是再次等待。當你不確定到底該喚醒幾個時,廣播是安全、稍微有點浪費的預設選擇。

把監視器到底替你買到了什麼、又沒買到什麼看清楚是值得的。它溶解了那些最常見、最傻的錯誤——忘了解鎖、沒取鎖就碰資料——因為在房間裡,那些現在從結構上就不可能發生。它沒有溶解的,是並行真正困難的那一部分。你仍然可能因為不同執行緒以相反順序取兩個監視器的鎖而造出死結。你仍然可能遭遇優先權反轉:一條握著監視器的低優先權執行緒,卡住了一條在門口等待的高優先權執行緒。而你仍然必須想清楚該在哪個條件上 wait、該在何時 signal。監視器把記帳搬進了工具裡,卻沒辦法把思考從你的腦袋裡搬出去。

這一階梯把你帶到哪裡

回頭看看這段攀爬。本階梯一開頭就是一個令人震驚的事實——count++ 其實是好幾個非原子的步驟,所以兩條執行緒交錯就可能丟掉一次更新——而這帶出了臨界區間和它的三個要求:互斥、推進、有限等待。你看了 Peterson 的軟體解法、以及它為何需要記憶體屏障,然後是讓它變便宜的硬體(test-and-set、compare-and-swap),再來是建在那個硬體之上的鎖:自旋鎖對阻塞鎖。從鎖你爬到了號誌,一個既守護、又計數、又發信號的單一計數器。而現在,有了監視器與條件變數,你抵達了「紀律終於住進結構裡、而非住在你記性裡」的那一層。

但請注意那個一再出現、本篇最後也以它收尾的警告:就算用上最好的工具,兩條執行緒各握著一把鎖、又各自在等對方的那把,仍會凍結成一個僵局,再多小心的等待都解不開。那個凍結、人人永遠互等的死結,就是死結——而與忘掉的 signal 不同,沒有任何監視器能從結構上防住它。把「到底是什麼讓它成為可能、那四個必須同時成立的條件、以及真實系統選擇拿它怎麼辦(老實說,常常什麼都不辦)」徹底弄懂,正是下一階梯的全部主題。