訊號 (Signals)

Overview Table

主題 核心概念 關鍵 API / 名詞
訊號本質 (p.792) signal 是通知 process 系統事件發生的小訊息;Linux 支援 30 種 SIGINT, SIGKILL, SIGSEGV, SIGCHLD...
術語 (8.5.1) 傳送 (send)接收 (receive) 是兩個獨立步驟;未接收的訊號為 pending pending / blocked bit vector
傳送訊號 (8.5.2) process group 為單位;可由 shell、鍵盤、killalarm 觸發 getpgrp, setpgid, kill, alarm
接收訊號 (8.5.3) kernel 於 kernel mode → user mode 時檢查 pending & ~blocked,強制 process 反應 signal, SIG_IGN, SIG_DFL, handler
阻擋訊號 (8.5.4) 隱式(handler 執行中自動阻擋同型訊號)與 顯式(sigprocmask) SIG_BLOCK / SIG_UNBLOCK / SIG_SETMASK
安全 handler (8.5.5) 遵守 G0–G5 準則;pending 訊號不會排隊 (not queued) async-signal-safe, volatile, sig_atomic_t
同步流程 (8.5.6) 主程式與 handler 的交錯執行造成 race;用阻擋訊號消除 sigprocmask 包住 fork+addjob
等待訊號 (8.5.7) spin loop 浪費 CPU、pause 有 race、sleep 太慢 → 正解 sigsuspend sigsuspend(&mask)

訊號的本質與分類 (p.792-794)

signal 是一種較高階的軟體形式 ECF (Exceptional Control Flow),允許 process 與 kernel 中斷其他 process。低階硬體 exception 由 kernel 的 exception handler 處理,user process 通常看不到;訊號提供了把這類事件曝露給 user process 的機制。

常考訊號整理(完整 30 種見書 Figure 8.26, p.793):

編號 名稱 預設動作 對應事件
2 SIGINT Terminate 鍵盤中斷 (Ctrl+C)
8 SIGFPE Terminate + dump core 浮點例外(含除以零)
9 SIGKILL Terminate(不可 catch/ignore) 強制殺掉程式
11 SIGSEGV Terminate + dump core 非法記憶體參照 (seg fault)
14 SIGALRM Terminate alarm 函式的計時器訊號
17 SIGCHLD Ignore 子行程停止或終止
18 SIGCONT Ignore 若已停止則繼續執行
19 SIGSTOP Stop until SIGCONT(不可 catch/ignore) 非終端機來源的停止訊號
20 SIGTSTP Stop until SIGCONT 終端機停止訊號 (Ctrl+Z)
Important

SIGKILL (9) 與 SIGSTOP (19) 既不能被 catch 也不能被 ignore,其預設動作無法用 signal/sigaction 修改。

8.5.1 訊號術語 (p.794-795)

訊號傳遞分兩個明確步驟:

   傳送端                    kernel                     目的 process
  ┌───────┐   kill()/事件  ┌──────────────────┐  接收時機:kernel→user mode
  │process│ ─────────────> │ pending |= 1<<k  │ ────────────────────────>
  │或kernel│               │ (blocked 者暫存)  │  檢查 pending & ~blocked
  └───────┘                └──────────────────┘  非空→強制 receive 最小的 k

8.5.2 傳送訊號 (p.795-798)

Process Groups

每個 process 恰好屬於一個 process group(正整數 process group ID)。所有傳送機制都建立在 process group 概念之上。

#include <unistd.h>
pid_t getpgrp(void);               // 回傳呼叫者的 process group ID
int setpgid(pid_t pid, pid_t pgid); // 成功回傳 0,錯誤 -1

/bin/kill 程式

linux> /bin/kill -9 15213     # 送 SIGKILL 給 process 15213
linux> /bin/kill -9 -15213    # 負 PID:送給 process group 15213 內每個 process

用完整路徑 /bin/kill 是因為部分 shell 有內建 kill 指令。

從鍵盤傳送(job 與前景/背景)

shell 以 job 抽象代表對一條命令列求值所產生的 processes;任一時刻至多一個 foreground job、零或多個 background job。shell 為每個 job 建立獨立的 process group(群組 ID 通常取自 job 中某個父行程)。

                        pid=10  Shell
                        pgid=10
                       /       |        \
        Foreground job     Back job#1     Back job#2
        pid=20 pgid=20     pid=32         pid=40
        /            \     pgid=32        pgid=40
   Child pid=21   Child pid=22
   pgid=20        pgid=20
   └── foreground process group 20 ──┘

kill 函式

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);   // 成功回傳 0,錯誤 -1
pid 引數 行為
pid > 0 送訊號 sig 給 process pid
pid == 0 送給呼叫者所在 process group 的每個 process(含自己)
pid < 0 送給 process group |pid|(絕對值)的每個 process

alarm 函式

#include <unistd.h>
unsigned int alarm(unsigned int secs);
// 回傳:前一個 alarm 的剩餘秒數;若無前一個 alarm 則回傳 0

安排 kernel 在 secs 秒後送 SIGALRM 給呼叫者;secs=0 則不排新 alarm。任何情況下呼叫 alarm 都會取消既有的 pending alarm,並回傳其剩餘秒數。

8.5.3 接收訊號 (p.798-800)

kernel 把 process p 從 kernel mode 切回 user mode 時(如 system call 返回、context switch 完成),檢查 p 的未阻擋 pending 訊號集合 pending & ~blocked:

每種訊號都有預設動作 (default action),為以下四者之一:terminate、terminate + dump core、stop(直到 SIGCONT)、ignore。

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
// 回傳:前一個 handler 的指標;錯誤回傳 SIG_ERR(不設 errno)

signalhandler 值以三種方式改變行為:

handler 執行 return 後,控制權「通常」回到被中斷處的指令。

          Main program            Handler S           Handler T
  (1) catch s   Icurr ──(2)──>  S 開始執行
                                (3) catch t (t≠s) ──(4)──> T 開始執行
                                S 恢復執行  <──(5) T return
  (7) main 恢復 Inext <──(6)── S return

handler 可被其他型別訊號的 handler 中斷(Figure 8.31, p.800);同型別則因隱式阻擋而不會巢狀。

Warning

「handler 返回後回到被中斷的指令」有例外:某些系統上被中斷的 slow system call 不會恢復執行,而是立即回傳錯誤並設 errno = EINTR(見 8.5.5 Portable Signal Handling)。

8.5.4 阻擋與解除阻擋訊號 (p.800-801)

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set);      // 初始化為空集合
int sigfillset(sigset_t *set);       // 加入所有訊號
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
// 以上成功回傳 0,錯誤 -1
int sigismember(const sigset_t *set, int signum); // 是成員回傳 1,否則 0
how 效果(公式)
SIG_BLOCK blocked = blocked | set
SIG_UNBLOCK blocked = blocked & ~set
SIG_SETMASK blocked = set

oldset 非 NULL 時,舊的 blocked bit vector 存入 oldset。典型用法(暫時阻擋 SIGINT,Figure 8.32):

sigset_t mask, prev_mask;
Sigemptyset(&mask);
Sigaddset(&mask, SIGINT);
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);   /* 阻擋 SIGINT 並存舊集合 */
/* ... 不會被 SIGINT 中斷的程式區段 ... */
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);  /* 還原舊集合 */

8.5.5 撰寫 Signal Handler (p.802-811)

handler 難寫的三個原因:(1) 與主程式並行執行且共享全域變數;(2) 訊號接收規則反直覺;(3) 不同系統語義不同。

安全準則 G0–G5

準則 內容
G0 handler 越簡單越好——例如只設一個全域旗標就返回,實際處理留給主程式
G1 handler 內只呼叫 async-signal-safe 函式(reentrant 或不可被 handler 中斷者)。printfsprintfmallocexit 皆不安全;安全輸出只能用 write(或課本 Sio 套件:sio_puts/sio_putl/sio_error,底層用 write + _exit)
G2 進入 handler 時把 errno 存到區域變數,返回前還原(handler 以 _exit 終止 process 則不必)
G3 存取共享全域資料結構時,handler 與主程式都要暫時阻擋所有訊號,避免指令序列被中斷、資料處於不一致狀態
G4 共享全域變數宣告 volatile,強制編譯器每次都從記憶體讀值,不用暫存器快取副本
G5 旗標宣告為 volatile sig_atomic_t:讀與寫保證是原子的(單一指令),可免阻擋訊號
volatile sig_atomic_t flag;   /* G4 + G5 的標準旗標宣告 */
Warning

sig_atomic_t 的原子性只保證單次讀或單次寫;flag++flag = flag + 10 可能需要多條指令,不具原子性

Tip

這些準則是保守的(不總是嚴格必要,例如 handler 確定不改 errno 就不必存還原),但一般情況下難以證明安全,故建議一律遵守。

正確處理:訊號不排隊 (p.806-809)

pending bit vector 每種訊號只有一個 bit → 同型別至多一個 pending;多出者直接丟棄。 pending 訊號只表示「至少來過一個」。

經典 bug(Figure 8.36 signal1):父行程用 SIGCHLD handler 回收子行程,handler 內只呼叫一次 waitpid。三個子行程幾乎同時終止:

  子1 終止 → SIGCHLD#1 被接收,handler 開始執行(同型訊號被隱式阻擋)
  子2 終止 → SIGCHLD#2 deliver → pending bit 設起(阻擋中,未接收)
  子3 終止 → SIGCHLD#3 deliver → pending 已有 → 直接丟棄!
  handler#1 返回 → kernel 見 pending → handler 再跑一次(回收子2)
  子3 永遠不被回收 → zombie (<defunct>)

教訓:訊號不能用來計數其他 process 的事件。 修正(Figure 8.37 handler2):handler 每次被呼叫時用迴圈盡量回收所有 zombie:

while (waitpid(-1, NULL, 0) > 0) {
    Sio_puts("Handler reaped child\n");
}
if (errno != ECHILD)
    Sio_error("waitpid error");

可攜性處理:sigaction 與 Signal wrapper (p.810-811)

舊 Unix 系統的兩個差異:

Posix 的 sigaction 讓使用者明確指定語義,但介面繁瑣;課本採 W. Richard Stevens 風格的 Signal wrapper(Figure 8.38):

handler_t *Signal(int signum, handler_t *handler)
{
    struct sigaction action, old_action;
    action.sa_handler = handler;
    sigemptyset(&action.sa_mask);  /* 只阻擋正在處理的同型訊號 */
    action.sa_flags = SA_RESTART;  /* 盡可能自動重啟被中斷的 syscall */
    if (sigaction(signum, &action, &old_action) < 0)
        unix_error("Signal error");
    return (old_action.sa_handler);
}

Signal 保證的語義:(1) 只阻擋目前正在處理的同型訊號;(2) 訊號一樣不排隊;(3) 被中斷的 system call 盡可能自動重啟;(4) handler 安裝後持續有效,直到以 SIG_IGN 或 SIG_DFL 再呼叫 Signal

8.5.6 同步流程以避免併發 bug (p.812-814)

併發流程的可能交錯數量對指令數呈指數成長,部分交錯產生錯誤結果——這就是 race(競爭)

shell 範例(Figure 8.39,有 bug):主程式 fork 後呼叫 addjob 加入 job list;SIGCHLD handler 回收 zombie 後呼叫 deletejob。錯誤交錯:

 父: fork() ──┐ kernel 先排程子行程
 子: 立刻終止 → kernel deliver SIGCHLD 給父
 父: 恢復可執行,但執行前 kernel 先讓它 receive SIGCHLD
 父(handler): waitpid 回收 → deletejob(pid) → job list 沒這筆,什麼都沒做
 父(main):   fork 返回 → addjob(pid) → 加入一個已不存在的 job,永遠不會移除!

race 發生在 main 的 addjob 與 handler 的 deletejob 之間;addjob 贏則正確、輸則錯誤。這類錯誤幾乎無法靠測試涵蓋所有交錯,極難除錯。

修法(Figure 8.40 procmask2):在 fork 之前阻擋 SIGCHLD,addjob 完成之後才解除,保證 addjob 先於 deletejob:

Sigprocmask(SIG_BLOCK, &mask_one, &prev_one);   /* fork 前擋 SIGCHLD */
if ((pid = Fork()) == 0) {
    Sigprocmask(SIG_SETMASK, &prev_one, NULL);  /* 子行程先解除再 execve */
    Execve("/bin/date", argv, NULL);
}
Sigprocmask(SIG_BLOCK, &mask_all, NULL);        /* 存取共享 job list 前擋全部 */
addjob(pid);
Sigprocmask(SIG_SETMASK, &prev_one, NULL);      /* 解除,SIGCHLD 才可被接收 */
Important

子行程繼承父行程的 blocked set,所以子行程必須在呼叫 execve 前自行解除 SIGCHLD 的阻擋,否則新程式將帶著被阻擋的 SIGCHLD 執行。

8.5.7 明確等待訊號 (p.814-817)

情境:shell 建立前景 job 後,必須等 job 終止並被 SIGCHLD handler 回收,才能接受下一條命令。基本作法(Figure 8.41):handler 把回收到的 PID 寫入 volatile sig_atomic_t pid,主程式先阻擋 SIGCHLD → forkpid = 0 → 解除阻擋 → 等 pid 變非零。

三種等待方式的比較:

寫法 問題
while (!pid) ;(spin loop) 正確但浪費 CPU
while (!pid) pause(); race! SIGCHLD 若在 while 判斷後、pause 前被接收,pause 將永遠沉睡(仍需迴圈,因 pause 可能被 SIGINT 喚醒)
while (!pid) sleep(1); 正確但太慢;訊號在 while 後、sleep 前到達須空等一整秒。用 nanosleep 也不行——沒有決定間隔的好法則(太小浪費、太大遲鈍)

正解:sigsuspend

#include <signal.h>
int sigsuspend(const sigset_t *mask);   // 總是回傳 -1

mask 暫時取代目前的 blocked set,然後掛起 process 直到收到一個「動作為執行 handler 或終止」的訊號:若動作是終止,process 直接終止不返回;若是執行 handler,sigsuspend 在 handler 返回後才返回,並還原呼叫時的 blocked set。等價於以下三行的原子(不可中斷)版本:

sigprocmask(SIG_BLOCK, &mask, &prev);   /* 1 */
pause();                                /* 2 */
sigprocmask(SIG_SETMASK, &prev, NULL);  /* 3 */

原子性保證第 1、2 行之間不會插入訊號接收,消除了 pause 版的 race。用法(Figure 8.42):每次呼叫 sigsuspend 前 SIGCHLD 是被阻擋的;sigsuspend(&prev) 暫時解除並沉睡;收到 SIGINT 則迴圈繼續、收到 SIGCHLD 則 pid 非零而離開迴圈。

  Sigprocmask(SIG_BLOCK, {SIGCHLD}, &prev)   ← SIGCHLD 被擋,handler 不會插入
  fork();  pid = 0;
  while (!pid)
      sigsuspend(&prev);   ← 原子地「解除阻擋 + 睡眠」;handler 跑完才醒
  (此時 SIGCHLD 再度被擋,可選擇性解除)

比 spin loop 省 CPU、無 pause 的 race、比 sleep 有效率。

Exam/Test Patterns

情境 / 關鍵字 答案
送 N 個同型訊號,handler 只收到幾個? 可能少於 N:pending 每型只有 1 bit,阻擋期間重複送達者被丟棄;訊號不能計數事件
SIGCHLD handler 只呼叫一次 waitpid 有 bug,會留 zombie;應 while (waitpid(-1,NULL,0) > 0) 盡量回收
哪些訊號不能 catch/ignore? SIGKILLSIGSTOP
Ctrl+C / Ctrl+Z 送什麼、送給誰? SIGINT / SIGTSTP,送給前景 process group 的每個 process
kill(pid, sig) 的 pid=0 / pid<0 0:送給自己所在群組全部;<0:送給群組 |pid| 全部
handler 內可否用 printf? 不可(非 async-signal-safe);只能用 write / Sio 套件
handler 與 main 共享旗標的宣告 volatile sig_atomic_t;但 flag++ 不是原子操作
kernel 何時接收訊號、挑哪個? kernel mode → user mode 時檢查 pending & ~blocked,非空則挑(通常最小編號的)k
addjob/deletejob 順序錯誤 race;forksigprocmask 阻擋 SIGCHLD,addjob 後解除;子行程要先解除再 execve
while (!pid) pause(); 哪裡錯? 訊號在判斷與 pause 間到達 → 永久沉睡;改用 sigsuspend(原子版 block+pause+restore)
alarm(secs) 回傳值 前一個 pending alarm 的剩餘秒數(並取消它);無則 0
handler 執行中又來同型訊號 隱式阻擋 → 變 pending,handler 返回後才接收;不同型訊號可中斷目前 handler
Practice 8.8 型:fork + kill(SIGUSR1) + counter 注意 handler 在子行程執行(有自己的變數副本)、_exit 不沖刷 stdio;交錯輸出需逐步追蹤