執行緒同步 11:睡眠與喚醒

執行緒寫了那麼多期,對於一個其實相當重要的東西經常總是一筆帶過,那就是休眠。 對於許多自學出家的程式員來說,休眠可能不過就是呼叫一個函式去休息一段時間而已, 然而實際上休眠的背後藏著許多眉角的呢! 對休眠這件事裡裡外外多加了解一番融會貫通,將有助於我們能站在更高的位置掌握多緒程式的運作效能。

當我們想要讓執行緒休息一段時間的時候,只要簡單的呼叫睡眠 API 就能完成, 比如 Windows API 裡的 Sleep()、或者 POSIX 相容系統下的 sleep()usleep(), 或者 C11 的 thrd_sleep() 等等。 它們可以讓你設定給執行緒休眠數秒、數毫秒、數微秒,或甚至可能有些其它 API 可以讓你指定休眠數個奈秒。 然若你曾經嘗試使用過極短時間的休眠,可能就會發現現實中休眠到醒來至少會需要一定的時間, 而任何小於這個時間的休眠設定值在實際上都是不可能達成的。 至於這個最小的休眠時間是多少?通常大約落在幾個毫秒到十幾毫秒的範圍,具體得看個別作業系統的配置設定。

睡眠機制

我們所呼叫使用的睡眠函式其實並不一定會讓電腦真正休息下來, 因為當代絕大部份的作業系統都是多工系統, 你的執行緒沒事休息了,但後臺可能還有成千上萬的其它行程等著被處理。 因此電腦並不一定會在你休眠之後就真的閒下來,只不過把你放著然後轉頭去忙其他別人的事情而已。 所以我們所呼叫的睡眠,其實就是告訴作業系統說「我現在沒事了,你可以暫時把我晾一邊」, 然後作業系統就會自己安排剩下的事情, 排程器接下來好一段時間就不會把你給納入排程規劃而已, 然後作業系統還是繼續如往常一樣該幹什麼就幹什麼。

其實休眠與執行不過就是作業系統將各執行緒在不同的狀態之間做切換而已。 如下圖(Figure 1)所示,一個行程(其實就是執行緒,詳見段落後面的說明)有三種不同的狀態 (其實真實的「行程狀態」(Process Status)除這三種之外還有一些其它狀態, 但因為那些其它的狀態與本篇敘述重點無關,因此本篇忽略它們的存在)。 可以想像為作業系統給行程(執行緒)們準備了兩個大籃子, 一個綠色的籃子叫「已備便」(Ready)、另一個藍色的籃子叫「等待中」(Waiting), 於是行程排程器(Scheduler)便只會在已備便的綠色籃子裡輪番挑出幸運行程並執行它一小段時間, 而得到了執行時間片(Timeslice)並正在被執行當中的行程則屬於黃色的「執行中」(Running)狀態。 因此休眠不過就是作業系統將某個行程從綠色籃子裡拿出來並放入藍色籃子而已, 而喚醒其實就是將一個行程從藍色籃子裡取出並放入綠色籃子裡而已。 至於一個被喚醒的行程要什麼時候才會真正被執行起來變成黃色的狀態?這就得看排程器的安排了!

running ready waiting
Figure 1. 行程們在等待區(藍色)、備便區(綠色)、與執行中(黃色)三個狀態互相調度
Tip
雖然以前我介紹過系統排程的對象其實是執行緒而非行程, 也說明過行程是資源分配的單位而執行緒才是排程單位, 然而在許多相關的討論、文件、和文獻當中都會使用行程一詞而非執行緒, 如行程排程器、行程狀態等等。 這是因為執行緒的概念其實是比較後來才出現的,大約就在 2000 年前後才開始逐漸流行普及。 在那之前的所有排程相關的規劃自然都是以行程為單位,相關平行運算實現的手段也是多行程協作, 反正那個時候作業系統對行程間的隔離也還沒有像現在這樣壁壘分明。 於是即便到了現在其實許多描述的主體應該以執行緒稱呼更加準確, 但那個時代所建立起的行業術語詞彙已經成為一種慣例而並沒有被推翻。

無事可做時

無論系統裡存在有多少行程,行程管理器只會排序和調度那待在「就緒」籃子裡的行程而已。 那如果遇到籃子裡是空的,沒有行程在等待執行的話,會發生什麼事呢? 其實這種狀況還蠻常見的,甚至於在我們平常使用電腦的絕大多數時間裡都是這種狀況。

當排程器沒有行程可排、無事可做的時候,就會讓 CPU 本身進入休眠。 這件事情是透過執行 CPU 的休眠指令來進行, 例如 x86 相容 CPU 的 HLT 指令,或者 ARM CPU 上的 WFI 或 WFE 指令。 CPU 收到這項命令後的作用就是停止自己絕大部份的工作,只保留必要的資源以避免狀態丟失, 以達到省電降溫的目的,等於是 CPU 自己休眠跑去睡了。 這裡會不會有人覺得不對勁? 執行緒雖然休眠了但作業系統還在工作,所以還可以調度排程叫醒它; 可是如果連 CPU 都休眠不工作了,作業系統當然也動不了,這可不就睡死了? 其實並不會發生這種事,因為 CPU 對硬體中斷訊號(hardward interruption)還是有反應的, 任何一個外部裝置發送的中斷信號都能讓 CPU 再次醒過來並繼續工作; 就算所有外部裝置設備一時半會都無事發生而不發送信號,這不還有時鐘嗎?! 也就是說即便是在最風平浪靜的情況下,CPU 都會在主機板上的時鐘電路下一次發送計時器信號的時候醒過來, 然後排程器再看看這時若有行程已經備便的話就安排排程,若仍然沒有備便的行程那就再次下達指令讓 CPU 睡覺去。 於是你在電腦的資源管理器上面所看到的 CPU 使用率,其實就是 CPU 在幹實事的時間和睡覺的時間之間的比例關係。

最後補充,上面的描述內容為了減少不必要的複雜度,是假設一個單一 CPU 單一核心的運作條件。 而在當代主流的多核心 CPU 之下情況會稍微複雜一點,備便行程區不一定要全空, 只要備便的行程數量少於 CPU 核心數量就會發生有部份 CPU 核心被下令休眠的情況, 使得部份 CPU 核心正在努力工作而部份 CPU 核心正在睡覺的情況可以同時發生。

忙碌等待

由於牽涉到行程排程以及作業系統本身的運作狀態,導致休眠到被喚醒之間的時間帶有相當大的浮動不確定性。 這樣的現象除了導致前篇在講述 自旋鎖 時所介紹過的對事件反應速度的不夠即時問題外, 在某些需要精確時間控制的應用用途上也帶來相當的問題, 比方說許多的硬體控制和通訊用途上需要能夠產生出精確的訊號波形。

對於這種需要精準控制時間的用途,通常會使用類似像 NOP 這樣的 CPU 指令來實現。 這個指令的作用就是讓 CPU 做一件沒有實際作用的工作, 例如在那些沒有提供 NOP 指令的 CPU平臺下常常就是使用值交換指令來實現相同的效果。 (值交換指令就是將兩個暫存器的值互相交換,但是當我指定的來源暫存器和目的暫存器是同一個暫存器的時候, 等同於讓 CPU 做了一件實際上沒有效果的事情,而 NOP 指令就是類似同樣讓 CPU 做空事的空轉指令。) 雖然 CPU 做的事實際上沒產生任何效果,但是時間花出去了, 而且因為 CPU 是靠著振盪電路的訊號來同步工作的緣故,所以這個花費的時間還是可預期且固定的, 我們稱為 CPU「指令週期」(Instruction Cycle)。 這就給我們對於時間的精準控制提供了道路。 例如假設一個 CPU 的指令週期是 1 微秒, 那麼我就可以像下面示範的自制一個能夠精準等待微秒等級時間的 WaitUs() 函式, 然後做出一個能夠產生精準方波訊號的一段程式碼如下(僅虛擬碼示意):

void WaitUs(unsigned us)
{
    while(us--)
        Nop();
}

void GenerateSquareWave()
{
    while(1)
    {
        SetGpioHigh();
        WaitUs(600);
        SetGpioLow();
        WaitUs(400);
    }
}

上面的這一段程式碼實現了一個用來等待時間的 WaitUs() 函式, 其作用效果與 usleep() 一樣都是等待一小段時間, 不同的是 usleep() 透過讓執行緒進入休眠再讓作業系統計時喚醒的方式進行等待, 而 WaitUs() 則是透過執行無效指令的方式消耗時間。 於是像 WaitUs() 這樣的等待方式就被稱為「忙碌等待」(Busy Wait), 而像 usleep() 的等待方式則被稱為「休眠/睡眠等待」(Sleep Wait)。 睡眠等待能減少對系統執行資源的佔用,而忙碌等待需要持續佔用執行資源; 睡眠等待對時間的把控粗糙且浮動性高,而忙碌等待對時間的把控較為精準。

不過到這裡有些讀者或許會覺得奇怪,為什麼印象裡大部份的程式語言都不存在像 NOP 這樣的功能呢? 為什麼在程式課程和教材裡面幾乎就沒有出現過 NOP 方面的內容呢? 也或者有些讀者可能已經躍躍欲試想要嘗試使用 NOP 來整點花活兒了? 但是這裡要給可能滿懷期待的讀者們潑一桶冷水, 因為忙碌等待在我們平常的作業環境裡並不俱備如前段所描述的價值,它並不能夠達到精確控制時間的效果! 因為我們通常的程式作業環境背後還有一個作業系統,還有其它的行程同時也需要安排執行, 所以排程器隨時可能把你原本想要精準控制的時間給掐斷然後把你給岔出去!

這就是為什麼基於精準控時需求的忙碌等待在通常的作業環境下並不好用的原因。 忙碌等待的方案大多生存在如 8051、Arduino 等單晶片環境下被大量應用, 原因無他,在那些地方沒有作業系統,而你的程式碼能夠掌管一切; 至於在有作業系統的執行環境下通常沒有辦法進行這種精細等級的時間控制, 只能把需要傳遞收送的資料或者相關命令傳輸到(比如主機板上的)周邊外部設備,再讓它們代為進行精確的信號控制。 因此在我們一般接觸到的流通的執行環境下,忙碌等待的用途往往不是用來做時間控制使用, 而是為了能夠避開行程休眠和喚醒的機制而達到快速響應事件的目的, 例如前篇自旋鎖和忙碌輪詢的內容。

睡眠無處不在

行程休眠(執行緒休眠)這件事情遠不只在呼叫 sleep() 這類函式的時候會發生。 休眠其實不過就是作業系統將我們的執行緒給丟到「等待區」而使得排程器不會將它納入排程, 因此若稍加思索可能不難發現休眠在我們的程式裡其實無處不在,經常在發生。

比如前篇介紹互斥子時描述過, 當執行緒沒有搶到通行牌的時候就會被卡在函式呼叫內進行等待,而這個等待就和本篇所介紹的休眠是一模一樣的東西。 同樣的,所有類型的執行緒鎖(除了自旋鎖以外)的本質核心也都是讓執行緒進行休眠等待。 然而實際上休眠等待的發生遠遠不只於此。 當我們呼叫一次性讀取大範圍的檔案內容、或寫入檔案內容的時候,一定能夠明顯感受到程式被卡在讀寫函式裡一段時間, 這不用說,當然也是經歷了休眠到喚醒的過程。 當我們呼叫讀取檔案內容功能的時候,我們的程式會把要讀取的相關參數如哪個檔案、哪段範圍等 傳遞給作業系統內實際負責讀寫資料的功能模組, 然後它就呼叫控制硬碟嘎啦嘎啦的開始進行讀取得工作,這時我們的執行緒已經待在行程等待區睡覺去了, 然後等到什麼時候資料通通已經要到手上可以給我們的程式取用的時候,作業系統才通知我們的執行緒醒過來。 除此之外,呼叫網路收送資料的過程當然也會讓我們的執行緒進入休眠; 甚至於,當你呼叫配置記憶體緩衝區的時候,要求各種系統資源的時候,或查閱各種系統資訊的時候, 也都會進入等待的過程,只不過後面這些等待的時間通常非常短暫以致於我們並不容易感覺出來。

於是你會發現我們的程式裡其實到處充滿了休眠的機會,休眠無處不在, 這些所有的休眠與 sleep() 這類函式本質上並沒有差別, 只不過 sleep() 在經過一定的時間之後被喚醒,讀取檔案的呼叫在檔案資料備便時被喚醒, 傳送網路資料的呼叫在資料被轉交給網路卡之後被喚醒, 其它各種要求系統資源的呼叫則在索要的資源配置完畢可供使用的時候被喚醒; 所有的休眠等待內涵都是一樣的,差別只在於各自被喚醒的條件不同而已!

上一篇:「執行緒同步 10:輪詢」
下一篇:「執行緒同步 12:中斷信號」