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

號誌與計數資源

互斥鎖說「一次一個」;條件變數說「等到某件事成立」。號誌(semaphore)把這兩個想法摺進一個計數器,用它追蹤某樣東西還剩幾份可用。本篇從頭把號誌建起來,展示人們使用它的兩種方式,並一步步贏得那個著名的、解決生產者—消費者問題的一行式寫法。

從一把鎖到一個計數器

你帶著兩件工具來到這一篇。第 1 篇給了你互斥鎖:一把鎖,一次只讓恰好一條執行緒進入臨界區,其他人全被擋著等。第 2 篇給了你條件變數:一種方式,讓你睡到某個關於共享狀態的述語變成真為止,並在別的執行緒改動它時被喚醒。這兩者談的都是一個是/否的事實——鎖是被持有還是空著,述語是真還是假。號誌(semaphore)把那個是/否推廣成一個計數:問的不是「房間空著嗎?」,而是「還剩幾個位子?」。

想像一座小圖書館有三間一模一樣的自習小間。門口掛著一塊牌子顯示還剩幾間空著,旁邊一個碗裡放著三把鑰匙。號誌就正是那個碗。想用小間的學生拿走一把鑰匙——計數減一;離開時把鑰匙放回——計數加一。她抵達時若碗是空的,她不會硬闖,也不會搶別人的小間:她在門口等,直到有鑰匙重新出現。整套機制不過是一個整數,而你只被允許透過兩個謹慎的操作去碰它,那兩個操作就是這整個想法的全部。

這兩個操作有來自 Dijkstra 的歷史名稱——他在 1960 年代中期引入了號誌:P(取,又叫 waitdownacquire)與 V(歸還,又叫 postsignaluprelease)。在現代 POSIX 系統上,對應的呼叫是 sem_wait() 與 sem_post(),計數器以 sem_init() 建立。名稱記個大概就好;真正重要的是每一個做了什麼,接下來我們把它講精確。

把那兩個操作講精確

一個號誌持有一個非負整數計數,也就是目前可用的單位數量。sem_wait() 的意思是「我要一個單位」:如果計數大於零,就把它減一並立刻返回;如果計數是零,就阻塞——去睡覺——直到有人把它再變成正數,然後減一再返回。sem_post() 的意思是「我正在歸還一個單位」:把計數加一,而如果有任何執行緒正睡在 sem_wait() 裡,就喚醒其中一個,讓它能完成它的減一。這就是整個契約。

  A semaphore is conceptually an int + a wait queue, with two
  ATOMIC operations.  Read this as a SKETCH of meaning, not real code:

    sem_wait(s):                 sem_post(s):
        atomically:                  atomically:
            while s.count == 0:          s.count = s.count + 1
                sleep on s                if a thread is sleeping on s:
            s.count = s.count - 1            wake exactly one of them

  Library calls (POSIX):
    sem_t s;
    sem_init(&s, 0, 3);   /* count starts at 3 ; second arg 0 = threads */
    sem_wait(&s);         /* take one : count 3 -> 2 (or block at 0)   */
    sem_post(&s);         /* give one : count 2 -> 3 (wake a waiter)   */
兩個操作的意義,外加真正的 POSIX 呼叫。那個「atomically」外殼才是出力的關鍵——計數與睡眠/喚醒是一個不可分割的步驟。

一件工具,兩種差事:計數 vs 訊號

號誌被用於兩個真正不同的目的,把它們看成兩回事,正是化解困惑的關鍵。第一個就是小間的故事:一個計數號誌(counting semaphore),初始化成 N,用來限制同時最多有幾條執行緒能持有某個池化的資源。比方說你有 4 條資料庫連線、50 條工作執行緒。把號誌初始化成 4。每條工作執行緒在用連線前呼叫 sem_wait()、用完後呼叫 sem_post()。任何時刻最多 4 條在裡面;其餘 46 條睡在門口,等到有連線被釋放。沒有任何一條連線被同時使用兩次,也沒有任何執行緒空轉燒 CPU——它們睡到被喚醒為止。

第二種用法更微妙,而一旦你看懂,會出乎意料地有力:一個二元號誌(binary semaphore)——初始化成 0 或 1 的那種——純粹當作執行緒之間的一個訊號。把它初始化成 0,意思是「事件還沒發生」。執行緒 A 呼叫 sem_wait() 並立刻阻塞,因為計數是 0。稍後,執行緒 B 做完某段工作、呼叫 sem_post(),把計數推到 1;那喚醒了 A,A 把它減回 0 然後往下走。你剛剛讓 A 等候 B,既沒有忙等,也不需要一個共享旗標去輪詢。這是號誌扮演一個一次性的「出發」訊號——和限制資源池是完全不同形狀的問題。

號誌對上條件變數

差不多此刻會冒出一個合理的問題:「上一篇不是已經教我用條件變數等東西了嗎?為什麼還要第二件工具?」它們有重疊,但並不相同,而差別在於記憶。條件變數沒有記憶——如果執行緒 B 在當下沒有任何執行緒等候時去 signal 它,那個訊號就單純地遺失了,憑空消散。這就是為什麼第 2 篇堅持你永遠要把條件變數,搭配一個對共享狀態為真的述語,以及那個 while-述語 迴圈:條件變數只是推你去重新檢查;真相住在你自己的變數裡。

相對地,號誌會記住。如果 B 在 A 還沒呼叫 sem_wait() 之前就先呼叫了 sem_post(),計數會變成 1 並留在那裡;等 A 終於呼叫 sem_wait() 時,它會發現那個 1 正等著它,於是毫不阻塞地直接通過。那次 post 沒有遺失——它被存進了計數裡。正是這一個差別,使得單單一個號誌、不需要額外的互斥鎖或旗標,就能安全地把一個訊號遞給跨執行緒,而條件變數則永遠需要一個夥伴鎖和述語才能正確。號誌本身就是它自己的狀態。

那該選哪一個?當你等的東西自然是一個計數時——空位、可取的項目、池中的許可——就用號誌,因為那個計數正是號誌所存的。當你等的東西是一個更豐富、單一整數捕捉不了的條件時——「佇列非空,且沒有正在被重新調整大小」,或「緩衝區至少裝著 64 個位元組」——就用條件變數。誠實的總結是:當你的述語剛好是「某個計數器大於零」時,號誌正是對的工具;而對於每一個不是這形狀的述語,條件變數是那把通用的工具。

回報:用寥寥幾行寫出生產者—消費者

現在來領回報。經典的生產者—消費者問題——正是下一篇的主題——有一條或多條執行緒把項目生產進一個固定大小的共享緩衝區,另一些則把它們消費出來。需要兩個等待,而它們朝相反方向拉:生產者在緩衝區滿時必須等(沒有空格可放),消費者在緩衝區時必須等(沒有項目可拿)。這兩個等待各自都是一個計數:「還剩幾個空格?」和「有幾個被填滿的格子?」。兩個計數就是兩個號誌,而它們幾乎像變魔術般地對應上這個問題。

  Bounded buffer of N slots.  Two counting semaphores:
     empty  = sem_init(N)   /* slots free to write : starts at N */
     filled = sem_init(0)   /* items ready to read : starts at 0 */
  Plus one mutex 'm' so only one thread edits the buffer at a time.

   PRODUCER loop:                  CONSUMER loop:
     item = make_item();             sem_wait(&filled);  /* need an item */
     sem_wait(&empty);  /* need a    sem_wait... */
                          free slot*/   pthread_mutex_lock(&m);
     pthread_mutex_lock(&m);           x = buffer_take();
     buffer_put(item);                 pthread_mutex_unlock(&m);
     pthread_mutex_unlock(&m);         sem_post(&empty);  /* freed a slot */
     sem_post(&filled); /* an item    use(x);
                          is ready */
有界緩衝區的解法。empty 數空格、filled 數備妥的項目;互斥鎖保護的是緩衝區本身的編輯。每一邊都在一個號誌上等待、在另一個上 post。

把它走一遍,齒輪就咬合了。生產者呼叫 sem_wait(&empty):若每個格子都滿了,empty 已歸 0,生產者就睡在那裡,直到某個消費者騰出一格。消費者呼叫 sem_wait(&filled):若沒東西備妥,filled 是 0,它就睡到某個生產者 post 為止。當生產者放完一項,它的 sem_post(&filled) 抬高 filled 計數、喚醒一個睡著的消費者;當消費者拿完一項,它的 sem_post(&empty) 抬高 empty、喚醒一個睡著的生產者。兩個計數在兩邊之間一吸一吐——沒有輪詢、沒有忙等、沒有遺失的喚醒,因為每一次 post 都被存進了一個計數裡。

有一個細節值得細看,而它預告了最後一篇。注意生產者那一邊的順序:sem_wait(&empty) 排在 pthread_mutex_lock(&m) 之前,絕不在它之後。把它們對調——先抓互斥鎖,然後在仍持有它的情況下,因緩衝區滿而阻塞在 empty 號誌上——這樣一個生產者就會抱著那把消費者拿項目、騰格子所需要的鎖睡著。誰都無法再前進。那就是一個死結(deadlock),而它低聲說出的規則——先取得你那些「允許前進」的號誌,再去拿守護資料的那把短命互斥鎖——正是死結那一篇會把它系統化的那種排序紀律。

誠實的邊角,以及接下來

號誌很優雅,但要誠實面對它鋒利的邊角,因為它容易出微妙的錯。它是無結構的:語言裡沒有任何東西,會像一個作用域把鎖與解鎖配成一對那樣,把 sem_wait() 跟它對應的 sem_post() 配起來。少 post 一次,一條執行緒就永遠睡下去;多 post 一次,你就發出了一張根本不存在的許可——資源池會溢出它真正的容量,兩條執行緒撞在同一條連線上。沒有擁有者可供檢查,沒有編譯器警告你。計數有多正確,永遠只取決於你自己的記帳有多正確。

退一步,把形狀握住。一個號誌是一個原子的、非負的計數器,只被 sem_wait()(取一個,沒有就睡)和 sem_post()(給一個,喚醒一個等待者)所碰觸。把它當計數號誌用,去限制共享一個池的執行緒數量;或當二元號誌用,去跨執行緒送出一個事件訊號——而且和條件變數不同,它會記住 post,所以它不需要自己另配一個述語。你現在握住了本階段引入的最後一個原語。下一篇會花一整章鑽進我們剛勾勒的生產者—消費者模式裡,再下一篇則替我們剛瞥見的死結命名,並給你避開它的紀律。