一種形狀完全不同的訊息
本級稍早的導引給了你搬運位元組的通道。匿名管線或具名管線是一條串流,你像對檔案那樣 read() 與 write() 它;訊息佇列則交給你帶有邊界的整則訊息。它們全都共有一種形狀:傳送者放下資料,接收者準備好時再取走,沒有人被迫停下手邊的事。信號是相反的形狀。它是一種完全不帶真實酬載的行程間通訊——只帶一個指名哪一個信號的小整數——而且它不在緩衝區裡禮貌地等待,而是伸手進去,在兩條尋常指令之間打斷目標行程。
把它想成寄一封信和用力拍一下別人肩膀之間的差別。管線是那封信:它躺在信箱裡,直到讀者選擇打開它。信號是那一拍:不論你正做到一半的是什麼,你都得停下、轉身、現在就回應。那種非同步性——在一個你沒選的時刻抵達——就是信號的全部性格,也是它之所以好用、以及正確處理它幾乎所有困難的來源。其實你早就見過信號、只是沒替它命名:在 shell 按 Ctrl-C,或一個程式因區段錯誤而死,都是信號正在被遞送。
常見信號的角色群像
信號有幾十個,每個都有像 SIGINT 這樣的名字和一個固定的小號碼,但你起步只需要少數幾個。在這些常見信號之中:SIGINT(號碼 2)是 Ctrl-C 送出的——「中斷,使用者要你停下」。SIGTERM(15)是禮貌的「請終止」,是 kill 指令的預設值,也是請一個伺服器關機的標準方式。SIGKILL(9)是不可忽略的「立刻去死」,行程甚至根本看不到它。SIGSEGV(11)會在你對一個壞指標解參考時被遞送。SIGCHLD 在你的某個子行程改變狀態時抵達,這直接接回你在行程那一級看過的等待與回收。而 SIGPIPE 則在你寫入一條讀端已經消失的管線時現身。
每個信號抵達時都帶著一個內建的預設動作,若你對它什麼都不做,這個動作就會發生。對大多數信號而言,預設是終止行程——這就是為什麼一個沒被處理的 Ctrl-C 會直接殺掉你的程式。有些預設動作還會額外傾印核心(SIGSEGV、SIGABRT),好留下一份核心傾印給除錯器;少數信號預設被忽略(SIGCHLD);而 SIGSTOP 則單純把行程凍結。一個沒被處理的程式之所以在 Ctrl-C 或壞指標上死掉,並不是什麼魔法——只是 SIGINT 與 SIGSEGV 的預設動作在你從未要求做別的事的情況下,悄悄地執行了。
送出一個信號,以及核心拿它怎麼辦
送出一個信號是一次系統呼叫,所以它會照作業系統介面那一級所描述的方式陷入核心。送出信號的基本呼叫是 kill(pid, sig)——這名字令人困惑,因為儘管叫「kill」,它送的是任何信號、不只是致命的;kill(pid, SIGTERM) 請一個行程退出,而一個行程甚至能用 raise(sig) 對自己發信號。shell 的 kill 指令只是包在這個系統呼叫外的一層薄皮,而 Ctrl-C 則是終端機驅動程式替你呼叫它。最關鍵的是,核心會先檢查權限:你不能對任意行程發信號,只能對你擁有的(或以超級使用者身分)。正是那個檢查,擋下了任何程式去殺掉任何其他程式。
一旦核心接受了這個信號,遞送是個值得精確描繪的兩步驟過程。首先信號變成擱置中:核心在目標行程的紀錄裡設一個位元,說「有一個 SIGINT 在等」。此刻還沒有任何看得見的事發生——那個位元只是待在那裡。接著,在下一個核心執行該行程的安全時刻(通常是它從一次系統呼叫或中斷返回使用者模式之際),核心注意到那個擱置位元,並遞送該信號:它把行程改道去執行所設定的動作,然後才讓尋常的執行接續下去。擱置與遞送之間的那道間隙,正是為什麼一個在行程忙於核心之中時送出的信號不會弄壞任何東西——它會等一條乾淨的接縫。
每個信號只用單一擱置位元,會帶來一個尖銳的後果:標準信號不會排隊。如果在一個 SIGINT 還擱置、尚未遞送時又來了三個 SIGINT,那個位元早已被設好,所以你可能只看到一次遞送、而非三次。這是一個真實而常令人意外的限制——你無法靠數樸素信號的個數來可靠地計算事件。(POSIX 即時信號確實會排隊,但有額外代價;而像 SIGINT 這樣的日常信號則不會。)把一個信號當成「這件事至少發生過一次」,永遠別當成一個精確的計數。
攔下一個:信號處理常式
你可以不讓預設動作執行,而改安裝你自己的信號處理常式——一個屬於你的函式,核心會在信號被遞送時跳進去執行它。一個信號處理常式用 sigaction()(或較舊、較可攜但脾氣古怪的 signal())來註冊;你給它信號號碼和一個函式指標,從此之後,當那個信號抵達時,核心會暫停你的正常程式碼、呼叫你的處理常式,事後再從它離開之處接續正常程式碼。這就是 Ctrl-C 如何變成「存好我的工作再乾淨地退出」、而非一次突兀的死亡:你攔下 SIGINT,自己去做清理。
現在來講那個刺耳的真相,要直白地說、因為藏起它會養出危險的工程師:信號處理常式在一個真正凶險的脈絡裡執行,而你天真地放進去的大多數程式碼都是錯的,即使它看起來能跑。處理常式可能在你程式裡字面意義上任何一條指令邊界上觸發——包括在 malloc() 正重排堆積內部串列的途中、或在 printf() 正更新 stdio 緩衝區的途中。如果你的處理常式接著自己呼叫 malloc() 或 printf(),它就重新進入了一個正改到一半共享狀態的函式,結果是資料損毀、死結,或一個一千次執行才出現一次的當機。這就是第一節那種非同步性回過頭來咬你。
那行旗標宣告裡有兩個關鍵字當之無愧,並連回更早幾級的觀念。來自const 與 volatile的 `volatile` 限定詞告訴編譯器這個變數可能在它背後改變——少了它,最佳化器可能把旗標快取在一個暫存器裡,於是你的主迴圈永遠看不到處理常式的更新,這個臭蟲出了名地在 -O0 消失、在 -O2 重現。而 sig_atomic_t 型別保證讀或寫這個旗標是單一、不可分割的操作,所以主迴圈絕不會逮到它更新到一半。誠實地說:這只給你一個從處理常式出來的安全溝通位元,這正是設旗標模式如此精簡的原因。
volatile sig_atomic_t got_sigint = 0;
void on_sigint(int signo) {
got_sigint = 1; /* the ONLY safe thing to do here */
}
int main(void) {
struct sigaction sa = {0};
sa.sa_handler = on_sigint;
if (sigaction(SIGINT, &sa, NULL) == -1) { /* check the syscall! */
perror("sigaction");
return 1;
}
while (!got_sigint) {
/* ... real work runs out here, where printf/malloc ARE safe ... */
}
/* clean shutdown happens here, in normal context */
return 0;
}你會絆到的那個信號:SIGPIPE
現在有一個信號值得詳細認識,因為它直接從本級第 1 篇導引的管線中長出來,而且幾乎讓每個人都吃驚。當你 write() 到一條讀端已經關閉的管線、FIFO 或通訊端——沒人留下來接收了——核心會送你的行程一個 SIGPIPE,也就是斷管信號。它的預設動作是終止行程。所以一個正開心地寫入管線的程式,可能在下游讀者退出的那一瞬間一聲不響地消失,而第一次遇到時,它看起來就像一場毫無錯誤訊息、無法解釋的當機。
這正是 shell 管線(像 `producer | head`)背後的機制:當 head 讀夠了行數並退出,producer 裡下一個 write() 就觸發 SIGPIPE,於是 producer 死掉——而這恰恰是你要的行為,所以管線會乾淨地拆除。但在你自己的程式裡,你通常想自己處理斷掉的連線、而不是死掉。標準的修法是把 SIGPIPE 的動作設成 SIG_IGN 來忽略它。那麼 write() 就不再殺掉你;它改為返回 -1 並把 errno 設成 EPIPE,把一場猝死變成一個你可以照前面那一級的錯誤處理紀律去檢查並從中復原的尋常錯誤。