執行緒同步 12:中斷信號

我們知道在呼叫休眠函式時,程式實際休眠的時間可能會比你所要求的時間還要久; 但是你知道嗎,有的時候實際休眠的時間會比所要求的還要短,甚至有可能短很多! 這是怎麼回事呢?本篇就讓我們來看看有關信號,或者有時被稱為軟體中斷的內容。

在開始之前,這裡要先分別兩個非常容易混淆的名詞。 「信號」與「信號量」中文名稱非常相似,容易搞混,雖然它們的英文名稱其實相當具有差異性。 本文所要探討的主題是「信號」(Signal),而「信號量」(Semaphore)則是下一篇將要探討的主題。 為了更容易識別並區隔這兩者,本文有時候會以「中斷信號」這樣的名詞來稱呼前面所述的那個「信號」。 至於為什麼叫「中斷」?相信讀完本篇後就理解了!

除此之外還有一點,如果您在閱讀本篇的時候感覺頭腦暈眩,完全看不明白的話,其實也不要緊! 如果本篇消化不了的話其實跳過去並接續閱讀下篇也是可以的。 因為本篇所介紹的這個中斷信號,至少對於應用程式的層面來說,一般使用的情景並不太多, 需要大量精細操作的使用需求更是非常罕見; 而對於本篇所描述在多緒程式中所需要的那些情況,一般更傾向於使用從下篇開始要介紹的那些同步工具。 因此對於信號如果實在不容易理解掌握的話,其實對實際開發的影響也不大,無需要為此感到氣餒。 那麼既然如此,本篇的意義又在哪裡呢? 我想可能是藉由對於中斷機制的更多了解,來幫助掌握在程式開發除錯的過程中, 更能夠從某些程式行為來知道程式所遭遇的問題及有關的最佳化處理!

硬體中斷

雖然對於純軟體應用程式層級的開發人員來說, 硬體的中斷機制可能並不是一個必須要學習理解且基本不會接觸的部份, 不過信號(Signal)其實就是由軟體模擬的「中斷」(Interrupt)機制, 而軟體中斷模仿的也是硬體中斷的效果,因此我還是喜歡從硬體中斷開始說故事。

大多現代常見的 CPU 都支援中斷機制。 當 CPU 的特定幾個針腳收到觸發信號時 (可能在低電位轉成高電位時被觸發,或高電位轉成低電位時被觸發), CPU 就會立刻保存當下的執行狀態,然後跳轉到某一個固定的記憶體位址,去執行放在那裡的一小段指令, 結束後再還原當時所備份的執行狀態,接續在被中斷觸發前正在做的事情。 中斷機制的發明其實就是為了解決前一篇輪詢主題中提到的兩難問題: 若輪詢周期長了則遇到事件發生時的反應慢,若輪詢周期短了則會過於忙碌。 而在中斷機制的作用下就能不需要這樣忙碌焦慮, 因為當外部設備發生什麼事情的時候,就能透過特定線路的電壓變化來通知 CPU, 然後 CPU 會立刻跳轉執行某一段我們安排的程式碼,就能立刻對事件做出反應。

當中斷發生時,CPU 會跳轉到一個固定的位址,因此我們必須事先將需要執行的指令安排在那一小段記憶體位址上。 然而那一小段的記憶體位址通常也不足以放下能夠妥善處理事件響應行為的程式碼, 因此通常我們放在那裡的都是跳轉指令,讓 CPU 執行流(程式計數器)跳轉到另一個更加充裕且已妥善安排的空間。 最後我們的中斷處理程式也需要通知 CPU 這中斷處理程序已經完成(使用類似 IRET 這樣的 CPU 指令), 讓 CPU 知道該回復原來中斷前的執行狀態了。 但是還有一個問題,CPU 的中斷跳轉機制相當低階,也不支援高等語言才有的函式參數什麼的, 那麼又要怎麼區分到底是哪一個設備發生了什麼事件呢? 答案是 CPU 對於不同的中斷信號會跳轉到不同的固定位址上, 所以我們只要把對於不同類型中斷的處理程式各自安排在特定的記憶體位址,就能區別處理不同的中斷事件了。 那麼 CPU 對於每一種觸發信號的不同跳轉位置是不是得整理成一張表格啊? 得,這就是中斷向量表(Interrupt Vector Table (IVT))。

因為中斷機制本身的特殊性,對於中斷處理程序的編寫其實也有相當的限制要求。 想想如果在中斷處理進行的過程中又發生一個中斷,那會發生什麼事呢? 因此大部份的 CPU 設計可能會在中斷處理結束前暫時屏蔽中斷機制。 意思就是說,如果中斷程序內容太複雜,花的時間太久,那可能會讓我們的電腦丟失對許多可能事件的反應。 因此中斷程序內容通常會要求要相當精簡短小。 也由於中斷程序本身就是一個特殊情況的暫時處理程序,自然難以得到來自於更高層級如作業系統的資源支援, 因此一般也要求不能夠呼叫使用一大票的東西,如幾乎所有的系統呼叫等。 (因為前篇解試過,其實絕大部份的系統呼叫都會經歷睡眠與喚醒, 而喚醒的機制可能多半也與中斷有關,但是中斷已經被你給佔住了……) 所以最後,中斷處理程序往往只能幹一些很簡單的事情, 其通常只是最小幅度的記錄發生了什麼事情,以及填寫一些必要的數據,然後就退出了, 讓後續正常的處理流程再去接手真正完整該做的事情。

此外中斷機制不只被用於正常事件的通知,也被用於異常問題的通知, 比如什麼電壓異常、記憶體分頁異常、浮點數計算異常等等。 而如果嫌中斷太複雜難搞的話,其實在絕大多數情況下完全忽視中斷機制也並不是不行的。 如同前面所述,中斷機制的發明是為了能即時通知事件的發生, 而若不使用中斷機制,那麼退回輪詢大法去檢查個事件所對應的旗標狀態也是完全可行的, 就是需要接受一定程度的性能退化代價而已!

最後,如果硬體中斷機制有關內容實在看不懂也操作不來的話,其實也是無妨的, 因為相關的實際操作對於應用程式或系統程式的開發來說,大概是永遠不會接觸到的。 通常只有在開發修改作業系統核心、相關設備驅動程式、啟動引導, 或編寫執行在如 8051 等單晶片裝置上的程式時才會需要讓人手動安排處理中斷事宜。 因此本節的重點可能更多的在於對中斷機制一個大體結構的理解,主要是為了後面的軟體中斷做鋪墊。

軟體中斷

CPU 的硬體中斷機制是真不錯,但是它畢竟是針對硬體事件的通知機制。 那如果軟體也如法炮製相似的機制行不?得,這就是軟體中斷,也是本篇的主題。

軟體中斷一般是由作業系統提供相關的服務,我們的應用程式也可以向系統註冊中斷處理函式, 這樣當特定的事件發生的時候,我們自己的函式就可以被呼叫起來,然後執行一些對應的處理行為。 這些其實就是在標準 C 語言的 <signal.h> 內所提供的功能, 其中 signal() 用來註冊自己定義的中斷處理函式(或被稱為 signal handler), 而 raise() 則可以讓我們主動發動一個信號; 是的沒錯,軟體中斷除了由作業系統在特定的條件下產生之外,我們自己也可以主動發起信號給自己 (其實也可以發信號給別的行程,只不過標準 C 的功能沒有提供,而必須使用作業系統 API)。

這些軟體中斷對應各種不同狀況的信號有好多種,這裡只挑其中幾個作為範例演示。

比方說當我們的程式裡面一不小心讓除式的分母為零, 就會引發 divide by zero 的錯誤(具體錯誤訊息可能會因作業系統或硬體平臺而有所差異), 隨後我們的程式就會被中止並退出。 不過實際上其實是 CPU 在執行除法運算的時候檢查到分母為零而拉起對應的旗標, 最後由作業系統產生一個軟體中斷給我們的行程去處理這樣的情況, 只不過程式預設的行為是打印一段錯誤訊息然後終止行程而已。 那麼我們是不是可以自己註冊一個中斷信號的處理程序來取代這個預設的處理程序, 然後自己決定在發生除零情況的時候要做什麼事情呢? 答案是可以的! 而下面程式碼就是這樣的範例:

#include <setjmp.h>
#include <signal.h>
#include <stdio.h>

static jmp_buf env;
static int have_err = 0;

static
void OnSignal(int sig)
{
    printf("Interrupted by signal %d\n", sig);
    have_err = 1;

    // 如果只是直接攔截並忽視除零的錯誤,那麼可能會讓程式進入死循環,
    // 因此這裡加入一個跳轉呼叫,
    // 使得在攔截到除零錯誤之後可以跳過原來正在處理的計算程式碼。
    longjmp(env, 1);
}

int main(int argc, char *argv[])
{
    // 註冊一個用來處理算術異常訊號的處理程序
    signal(SIGFPE, OnSignal);

    int a, b, c;

    // 故意測試一個會產生除零異常的計算

    a = 15;
    b = 0;
    c = 999;
    have_err = 0;

    if( 0 == setjmp(env) )  // 這裡先設定一個跳轉標記再執行下面的計算
    {
        printf("Calculating: c = %d / %d\n", a, b);
        c = a / b;
    }

    printf("Done calculation: a=%d, b=%d, c=%d, err=\"%s\"\n",
        a, b, c, have_err ? "error occurred" : "good");

    // 測試一個正常的計算

    a = 15;
    b = 3;
    c = 999;
    have_err = 0;

    if( 0 == setjmp(env) )  // 這裡先設定一個跳轉標記再執行下面的計算
    {
        printf("Calculating: c = %d / %d\n", a, b);
        c = a / b;
    }

    printf("Done calculation: a=%d, b=%d, c=%d, err=\"%s\"\n",
        a, b, c, have_err ? "error occurred" : "good");

    return 0;
}
Tip
由於當中斷處理程序結束之後會還原原來中斷前的執行狀態並繼續執行, 因此若只是簡單的攔截和忽略 SIGFPE 信號的話, 可能會讓行程返回原來的計算處並再算一次,然後再次發生除零錯誤, 然後再次觸發中斷,變成死循環……。 (這裡說可能,是因為或許在不同的執行平臺下可能會有不同的行為表現。) 因此上面的範例程式除了攔截 SIGFPE 信號之外,還做了一個 long jump 跳轉, 讓錯誤發生後我們可以跳過原來的計算內容並繼續往下執行; 當然因為中斷函式不支援相關參數的關係,使得這會需要使用討厭的全域變數, 但這是使用中斷機制的必然結果。
Calculating: c = 15 / 0
Interrupted by signal 8
Done calculation: a=15, b=0, c=999, err="error occurred"
Calculating: c = 15 / 3
Done calculation: a=15, b=3, c=5, err="good"

這段範例程式的執行結果如上。 可看到範例程式確實能捕捉到計算的錯誤並做出自定義的處理行為,然後正常的繼續執行程式。 只不過有一點可能需要注意。 上面的第一次計算結果為 999,其實是因為變數 C 的初始值設定的就是 999, 也就是說當除零情況發生時這個計算等於沒發生過; 但是這其實是屬於未定義行為,也就是說在不同的執行平臺下可能會產生不同的行為反應!

上面做了一個攔截處理運算異常的範例, 然而以應用軟體層級來說,其實在所有中斷信號裡面最常見使用的應該是 SIGINT 和 SIGTERM。 有別於一般任務工作型的應用軟體通常就是努力做完設計的工作內容然後就結束, 那些服務類型的軟體,也就是被設計來在背景默默運作提供某種服務的軟體卻往往得不斷維持運作, 直到使用者明確下達結束服務的指令才結束並退出。 那麼對於這些服務類型的程式,我們要怎麼從外面來通知它們該是時候要結束了呢? 當然能夠達成這個目的的方法非常多樣,比如使用檔案等等, 然而其中最常見的方式就是透過發送特定的中斷信號給行程,也就是發送 SIGINT 或 SIGTERM 信號。 也許有經常在使用終端機的讀者可能知道, 很多這類型的程式可以透過按下鍵盤 Ctrl+C 的組合鍵來正常結束程式, 這其實就是在按下這組合按鍵的時候,系統會發送 SIGINT 信號給當前的前景(foreground)行程的緣故。 若不是使用者透過鍵盤通知行程結束,而是由系統的服務管理程式來結束特定的行程, 則一般大多服務管理程式這時會發送 SIGTERM 信號給那個行程,讓它自己知道該準備結束了。 當然以上的情況是在程式有攔截對應的信號並做出處理行為的情況下的結果, 若程式並沒有設定對應的中斷信號處理程序,則會由預設的處理程序接手,而它會直接終止整個行程。

對於前面所述的這種用途,下面也提供一個範例程式給大家玩玩。 因為只是範例程式,所以它沒有提供任何具體的服務, 只是透過休眠的方式來定時每秒鐘打印一次訊息,並且如果你沒有去叫它停的話,它就會一直這麼做下去。

#include <assert.h>
#include <stdbool.h>
#include <signal.h>
#include <stdio.h>
#include <time.h>

#ifdef _WIN32
#   include <windows.h>
#else
#   include <unistd.h>
#endif

static bool ap_go_term = false;

static
void SleepMs(unsigned ms)
{
#ifdef _WIN32
    Sleep(ms);
#else
    usleep( 1000 * ms );
#endif
}

static
unsigned GetMsTime(void)
{
#ifdef _WIN32
    return GetTickCount();
#else
    struct timespec ts;
    int err = clock_gettime(CLOCK_MONOTONIC_COARSE, &ts);
    assert( err == 0 );

    return ts.tv_sec * 1000 + ts.tv_nsec / (1000*1000);
#endif
}

static
void OnSignal(int sig)
{
    printf("Interrupted by signal %d\n", sig);
    ap_go_term = true;
}

int main(int argc, char *argv[])
{
    // 註冊當使用者透過鍵盤結束程式時所發出的信號的處理函式
    printf("Register handler of signal %d\n", SIGINT);
    signal(SIGINT, OnSignal);

    // 註冊當使用系統服務管理程式結束程式時所發出的信號的處理函式
    printf("Register handler of signal %d\n", SIGTERM);
    signal(SIGTERM, OnSignal);

    // 進入執行主要服務內容的無窮循環
    unsigned begin_time = GetMsTime();
    while(!ap_go_term)
    {
        SleepMs(1000);

        // 印出一段簡短訊息,
        // 內容包含本次休眠到甦醒經過的實際時間,以及提示停止程式的方法。
        unsigned end_time = GetMsTime();
        printf("AP had sleep %u msecs. Press CTRL+C to terminate AP\n",
            end_time - begin_time);
        begin_time = end_time;
    }

    // 印出這段訊息的目的在於證明程式確實有執行到這裡,
    // 表達程式確實是正常完成流程並結束,而不是被強制截斷終止!
    printf("AP is terminating\n");

    return 0;
}

上面這範例程式執行起來以後就會不斷循環列印一段訊息, 而任何時候當我們按下鍵盤 Ctrl+C 組合鍵即可結束這個程式。 當然隨著你按下組合鍵的時機不同,程式的輸出結果會有所差異, 而下面是我執行程式約 3 秒鐘就叫它停止的輸出結果:

Register handler of signal 2
Register handler of signal 15
AP had sleep 1001 msecs. Press CTRL+C to terminate AP
AP had sleep 1000 msecs. Press CTRL+C to terminate AP
AP had sleep 1001 msecs. Press CTRL+C to terminate AP
^CInterrupted by signal 2
AP had sleep 220 msecs. Press CTRL+C to terminate AP
AP is terminating
Tip
在終端機上按下 Ctrl+C 組合鍵的時候,會在終端機上產生一個 ^C 的輸出字樣, 這就是為什麼在上面的程式輸出裡面會出現一個看著奇怪的 ^C 的原因。

以上就是兩個有關信號的使用示範。 相比於硬體中斷,軟體中斷因為是由作業系統提供的服務機制, 因此其中斷處理程序的相關編寫要求並沒有像硬體中斷程序那樣嚴格, 基本上幾乎寫什麼都可以,呼叫系統服務什麼的大多也都不受限。 只不過畢竟還是個中斷處理程序,因此一般還是要求儘量短小精簡, 不要在中斷程序裡面處理太多的複雜內容,也不適宜讓中斷程序佔用太多時間, 否則若在中斷處理的過程中又發生中斷,則程式可能會產生不預期的行為反應; 此外也不能在中斷處理程序裡面拋出例外(C++ Exception),即便自己拋自己接也要避免; 最後則是受限於中斷的機制,中斷處理程序與正常程式碼之間的溝通只能透過全域變數來進行。

使用信號實現休眠喚醒

在前面的範例中,程式設計每秒種執行一次訊息列印, 而其所記錄的每次休眠甦醒時間也差不多是 1 秒鐘(1000 毫秒); 但是有沒有注意到最後一次的休眠時間只有 220 毫秒? 休眠竟然被我按下 Ctrl+C 的動作給打斷了,這是怎麼回事呢?

不論是軟體中斷還是硬體中斷,設計目的都是臨時岔出去執行一小段處理程序後復原回來, 就像船過水無痕似的,那個可能正巧被岔出去又還原回來的行程可能根本也沒有發現任何異狀, 就像被行程排程器給岔出去再回來一樣。 然而有些狀態是不能百分百還原回來的! 例如原本作業系統可能已經執行類似 HLT 這樣的指令讓 CPU 休眠,而在發生過中斷以後 CPU 就是醒著的。 除非作業系統再次執行類 HLT 指令,否則 CPU 就是醒著的,並且繼續接緒執行睡著前正在進行的工作。 當然這個無法還原的狀態並不一定是個缺點, 比如讓 CPU 在發生中斷以後醒過來正好就是我們設計 CPU 的時候希望它有的行為反應, 因此這段描述只是在表達中斷前的狀態並不一定能百分百還原,並且其實這也並不一定是個缺點! 而軟體中斷其實也是一樣的,當中斷發生時, 除了我們所註冊的中斷處理程序會被執行以外,原來的休眠狀態也被喚醒了。

原本的睡眠狀態被插入的中斷信號打斷,這就是前面範例為什麼會發生休眠到一半醒過來的原因! 其實若仔細查閱睡眠相關函式的說明文件,應該都能發現裡面記載著睡眠會被中斷信號給終止的可能。 這就回應了本篇開頭的問題,當我們呼叫睡眠的時候,實際的睡眠時間可能會與要求的時間不同, 其中睡眠時間短少於所要求的時間的情況就是因為被中斷信號給打斷的緣故。 但前篇說了,幾乎所有的系統呼叫其實都內含了睡眠與喚醒的過程, 至於之所以我們一般可能很難感受到在系統呼叫的過程中被打斷的現象,主要原因有二: 其一是因為大部份的系統呼叫耗時非常短暫,以至於很不容易遇到剛好在期間收到中斷訊號; 其二是其實大多的系統呼叫在可以重複執行而不產生影響的情況下, 都會在發現被中斷給打斷的時候自動重新執行未完成的部份。 這兩點就是讓我們平常不容易感受到被信號給干擾的原因, 而只有在那部份無法自動還原狀態的系統呼叫裡(比如休眠)才會讓我們有感的感受到被中斷信號給干擾; 而這時如果我們檢查錯誤狀態的話,應該能得到 EINTR 的錯誤碼, 這表示我們所執行的系統呼叫遭到中斷信號的打斷,並且該由我們決定接下來該怎麼辦; 至於這是個問題與困擾嗎?有時候可能是,而有時候不是。

使用中斷信號改善輪詢機制

在前篇介紹輪詢法的時候,我們知道使用輪詢法會面臨的一個大難題就是休眠時間長短的拿捏, 休眠長點或短點都會產生不一樣的問題,實為兩難。 而本篇介紹的中斷信號似乎給這個兩難的困境打開一道曙光。 我們可以在輪詢的基礎上放心的讓程式一次休眠一段很長的時間, 而當事情發生時,也就是訊息供應者推送了一筆新訊息的時候,主動發起一個中斷信號來終止休眠, 這樣就能兼顧又能讓排程器與 CPU 維持低負載,又能對事件迅速響應!

那麼接下來就讓我們把前篇輪詢的範例拿過來,改寫加入上面所述的中斷信號機制,看看效果如何?

首先需要決定要使用哪一個信號來通知等待的執行緒醒過來? 雖然理論上也可以從可用的信號列表裡面隨便挑選一個可用的代號來使用, 但是為了避免養成壞習慣,還是得考量一下避免歧異, 於是這裡選用兩個明確被標記為「使用者自定義」信號的第一個:SIGUSR1。 於是下面要先註冊一個 SIGUSR1 信號的中斷處理函式, 這個函式裡面可以不做任何事情, 因為它的存在只是為了用來取代信號的預設處理程序:直接終止程式!

另外為了避免信號處理在不同作業系統下的行為差異(後面會做解釋), 這裡沒有使用標準 C 的 signal() 函式,而是使用了 POSIX 的 sigaction() 函式, 這會導致這個範例可能沒有辦法在 Windows 平臺下編譯執行!

static
void OnSignal(int sig)
{
    // 這函式不需要做任何事,
    // 它的存在只是為了讓 SIGUSR1 發生時不會讓程式結束退出而已!
}

int main(int argc, char *argv[])
{
    // 註冊一個中斷函式來取代預設行為,
    // 讓程式不會在收到 SIGUSR1 的時候直接結束掉!
    // 由於為了避免不同平臺下有關軟體中斷的行為差異,
    // 因此這裡沒有使用標準 C 函式庫裡的 signal(),
    // 而是使用了 POSIX 下的 sigaction()。
    struct sigaction sig_act;
    sig_act.sa_handler = OnSignal;
    sig_act.sa_flags = SA_RESTART;
    sigaction(SIGUSR1, &sig_act, nullptr);

    /* ...... 後面省略 */
}

然後因為我們需要在向貯列推送一個訊息的時候去叫醒正在等待這個訊息的執行緒, 所以必須要知道是哪個執行緒正在等待貯列的訊息? 因此這裡要修改 Deliverer 類別,讓想要等待的人可以將它的執行緒資訊存入, 這樣後續推送訊息的人才能知道要去叫醒誰:

class Deliverer
{
public:
    // 用來保護對下面所有成員物件的操作
    pthread_mutex_t msg_mutex;

    // 將產生的、或處理過的訊息放在這裡,讓其他人稍候可以取走。
    list<Message> msg_list;

    // 由於使用中斷的手段進行通知時,需要知道哪個執行緒正在等待,
    // 因此這裡需要由等待的執行緒記錄它自己的執行緒識別資料。
    pthread_t *waiting_thrd;
};

於是在建立執行緒時,除了原本就要設定要向哪個來源索取訊息之外, 現在還得告訴讓那個來源知道是這個新建立的執行緒要等待它產生的訊息:

for(int i = 0; i < rcvr_num; ++i)
{
    Receiver *rcvr = &rcvr_list[i];

    /* ...... 中間省略 ...... */

    pthread_create(&rcvr->thrd, nullptr, thrd_func, rcvr);

    // 讓負責訊息來源的人知道在等待訊息的就是現在這條執行緒
    pthread_mutex_lock(&msg_src->msg_mutex);
    msg_src->waiting_thrd = &rcvr->thrd;
    pthread_mutex_unlock(&msg_src->msg_mutex);
}

做完這些設定之後, 現在當原來的 TransferThread() 函式在等待訊息的時候就可以不用那麼糾結休眠時間長短, 可以大膽的一次休眠一段長時間,在本範例給它設定了有些誇張的 10 秒鐘休眠 (另外還有一個 ReporterThread() 函式也需要同樣的修改,但這裡就不重複展示了):

static
void* TransferThread(Receiver *data)
{
    while(!data->go_term)
    {
        // 嘗試向設定的訊息來源索取新訊息
        Message msg;
        bool avail = PopMessage(data->src, msg);

        if(avail)
        {
            // 取得新的訊息以後,將訊息做點該做的處理,
            // 然後再將訊息推入自己的貯列,
            // 讓下一個接手的人可以取走。
            msg.count++;
            PushMessage(data, msg);
        }
        else
        {
            // 由於本範例會使用中斷訊號的方式來通知執行緒醒過來,
            // 因此可以不再像單純的輪詢那樣去糾結睡眠的時間,
            // 可以一次大膽休息一段很長的時間,
            // 而這裡就稍微誇張的讓它休息 10 秒鐘。
            SleepMs(10*1000);
        }
    }

    return nullptr;
}

最後一步就是修改將訊息推送入貯列的 PushMessage() 函式, 讓它在存入訊息之後順便喚醒可能正在等待這筆訊息的執行緒。 其中這裡不使用標準 C 語言的 raise() 函式來發送信號, 因為它並不能夠指定會喚醒哪一個執行緒, 因此這裡改呼叫 pthread 的 pthread_kill() 來將信號送給指定的執行緒:

static
void PushMessage(Deliverer *data, const Message &msg)
{
    // 將訊息存入訊息貯列

    pthread_mutex_lock(&data->msg_mutex);

    data->msg_list.push_back(msg);

    // 存入一筆訊息後,
    // 若現場存在正在等待訊息的執行緒,那麼就通知它醒過來!
    // 由於標準 C 函式的 raise() 不確定會中斷哪一條執行緒,
    // 因此這裡採用 POSIX 定義的函式來將訊號投送給指定的執行緒。
    if(data->waiting_thrd)
        pthread_kill(*data->waiting_thrd, SIGUSR1);

    pthread_mutex_unlock(&data->msg_mutex);
}

上面只節錄這次測試程式中的重要關鍵修改,而完整的程式碼可以 從這裡下載。 其在我的電腦上所執行的結果節錄如下:

...... 前面省略

Factory: Generate "message-6"
Reporter: Got message, txt="message-6", counter=1001, delay=16
Factory: Generate "message-7"
Reporter: Got message, txt="message-7", counter=1001, delay=16
Factory: Generate "message-8"
Reporter: Got message, txt="message-8", counter=1001, delay=12
Factory: Generate "message-9"
Reporter: Got message, txt="message-9", counter=1001, delay=12
Factory: Generate "message-10"
Reporter: Got message, txt="message-10", counter=1001, delay=12
Factory: Generate "message-11"
Reporter: Got message, txt="message-11", counter=1001, delay=16

...... 後面省略

從測試的結果來看這效果是非常的好啊! 不只和預期的一樣,有賴於大段大段的休眠讓資源管理器上顯示的 CPU 佔用率基本沒什麼變化, 並且處理程序對事件的反應速度還快的驚人。 原來的測試在我的電腦上得到的幾乎都是 0 毫秒的延遲, 因此我將執行緒的數量從原本的 100 個增加到了 1000 個,才讓延遲跑出了一些數字可供參考比較。 總結來說,在導入中斷信號作為事件通知的機制後,整個程式變得更有效率,並且反應還賊快!

問題與缺點

整個中斷信號的機制從介紹到實做測試做下來,感覺好像效果很好的樣子, 然而就如同本篇開頭所說那樣,現實上並不推薦這樣直接使用信號機制來作為執行緒的同步工具使用, 因為作為一個過於低階原始的中斷機制,其存在許多問題及使用彆扭的地方。

首先是中斷信號的處理函式並不支援更多的自訂參數,必須使用全域變數作為資料交換使用, 導致在現代模組化與物件導向程式設計的結構上使用存在困難與彆扭處; 然而在與本篇測試範例相似的執行緒同步用途上,其實我們所需要的也只是它可以中斷睡眠的作用而已, 因此可以繞過這個問題不管。

但是第二個問題,當一個信號發送給一個行程時,如果那個行程存在兩個以上的執行緒, 那麼應當是哪一個執行緒該被喚醒呢? 關於這個問題,傳統標準 C 函式的行為是:未定義; 也就是依作業系統對信號實做的不同,可以是任何一條執行緒會被喚醒! 其實之所以會產生這個問題,也是因為執行緒本身是一個相較比較新的概念 (大部份流通的執行緒 API 是在 2000 年左右開始成型), 因此早期發展出來的相關機制自然就只針對行程做規劃設計,而壓根就沒想到會有不同執行緒的問題; 而當後來執行緒相關技術開始漸漸流傳開來的時候,已經產生無法收斂的各家不同實現方案了! 對於這個問題,傳統的 C 標準目前是沒辦法處理了, 但是 Linux 系統用戶可以改呼叫 tgkill() API, 或者 POSIX 相容系統可以改呼叫 pthread_kill() 函式來將信號發送給指定的執行緒; 至於 Windows?Windows 應該也有自己類似的 API, 或者使用 pthread 的 Windows 移植版本程式庫應該也可以! 所以這個問題還算是可以在合理的範圍內得到解決。

第三個問題就比較棘手了! 如同本篇前面曾提及過的問題: 當正在中斷信號處理函式中執行的時候,又發生一個中斷信號的話,該怎麼辦呢? 關於這個問題,不同的作業系統處理的方式不盡相同! 比如 BSD 和 Linux (glibc 2 and later)會在中斷函式的處理過程中暫時屏蔽信號機制, 直到函式結束時才會復原, 也就是說這期間再發生的中斷信號會被忽略掉; 而比如 UNIX 和 System V 則會在系統呼叫中斷函式之前, 將先前透過 signal() 設定的信號處理函式還原為預設值, 也就是說只有在第一次發生中斷信號時的行為是符合我們的預期的, 而第二次發生的信號中斷則會導致程式直接終止掉! 至於 Windows 對此情況的行為反應則更加複雜,依據信號類型的不同, 有些會像 BSD 那樣保留並能複用信號處理函式,有些則會像 UNIX 那樣重置信號處理函式的設定。 對於這個問題,傳統的 signal() 已經各自為政而沒有辦法了, 對此 POSIX 定義了新的 sigaction() 可以讓呼叫者指定對於這些問題應該要採行什麼行為, 而本篇範例就是採用了 sigaction() 呼叫。 但是在本篇寫作的當下,Windows 上面似乎並沒有 sigaction() 或相似功能的函式, 也就是說對於這個問題可能並不存在跨平臺通用的解決方案!

這樣總結下來,如果要取一個最大公約數的話,那麼跨平臺都能夠正常使用的信號中斷機制, 似乎只能在程式只有一個執行緒、或哪一個執行緒被喚醒都沒差的情況, 並且只需要第一筆中斷信號而不需要重複接收中斷信號的條件下是保證能正常工作的; 於是看來看去好像似乎只有利用中斷信號來通知服務程式該結束這個用途是比較正常的!

此外其實還有一個問題。 使用中斷信號來喚醒執行緒的睡眠時,發送信號的地方必須要知道該喚醒哪一個執行緒? 也就是如同在本篇範例程式碼裡看到的, 要等待訊息的執行緒需要先把自身的執行緒資訊存入相關變數之後才能去休眠 (不過因為本篇範例中特定貯列的等待者是固定的而不會更換,所以偷懶只設定一次資訊之後就沒再變動了)。 在本篇的測試範例裡面每一個訊息貯列只會有一個執行緒在等待接收訊息, 然而實際的應用中可能會存在多個對象都在等待同一個貯列的情況 (可參考前篇 執行緒同步 6:互斥子 的範例); 也就是說若要使本篇介紹的這個機制能夠實際應用在現實的執行緒同步用途上的話, 我們還得實做多個等待者的等待列隊與排序等問題!

總結

本篇介紹了中斷信號的運作機制與使用方式, 並使用中斷信號來實現執行緒的同步,非常有效的改善了前篇輪詢法的問題, 在效能和反應速度上都得到兼顧,並且效果還更佳! 然而中斷信號的通用 API 相當混亂,且其機制本身還是太過低階原始, 導致在實際應用當中帶來相當的困擾難題。

雖然在操作上的雜亂彆扭挺多,但試驗的結果告訴我們這個想法方向本身是可行的, 只需要對相關的機制進行一些包裝封裝使其更為方便且易用即可, 並且最好是由作業系統核心態所提供的封裝工具更佳; 這就是從下一篇開始要介紹的內容了!

上一篇:「執行緒同步 11:睡眠與喚醒」
下一篇:「執行緒同步 13:信號量」