訊號 (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、鍵盤、kill、alarm 觸發 |
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 的機制。
- 硬體事件轉訊號:除以零 → SIGFPE (8)、非法指令 → SIGILL (4)、非法記憶體參照 → SIGSEGV (11)
- 軟體事件:前景輸入 Ctrl+C → SIGINT (2);子行程終止或停止 → kernel 送 SIGCHLD (17) 給父行程
- 強制終止其他 process:SIGKILL (9)
常考訊號整理(完整 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) |
SIGKILL (9) 與 SIGSTOP (19) 既不能被 catch 也不能被 ignore,其預設動作無法用 signal/sigaction 修改。
8.5.1 訊號術語 (p.794-795)
訊號傳遞分兩個明確步驟:
- Sending(傳送/遞送 deliver):kernel 更新目的 process context 中的狀態。觸發原因:(1) kernel 偵測到系統事件(除以零、子行程終止);(2) 某 process 呼叫
kill明確要求(process 可以送訊號給自己) - Receiving(接收):kernel 強制目的 process 對訊號作出反應——ignore、terminate、或以 user-level 函式 signal handler 來 catch
- pending signal:已傳送但尚未接收的訊號。同型別最多只有一個 pending;同型別後續訊號直接被丟棄,不排隊
- blocked signal:被阻擋的訊號仍可被 deliver(變成 pending),但在解除阻擋前不會被 receive;blocked bit vector 又稱 signal mask
- kernel 為每個 process 維護 pending 與 blocked 兩個 bit vector:type k 訊號 deliver 時設 pending 的 bit k,receive 時清除 bit k;因此 pending 訊號至多被接收一次
傳送端 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
- 子行程預設與父行程同組
setpgid(pid, pgid):把 processpid改到群組pgid;pid=0 表示用目前 process 的 PID;pgid=0 表示以pid指定之 process 的 PID 作為群組 ID- 例:process 15213 呼叫
setpgid(0, 0)→ 建立群組 ID 為 15213 的新群組並加入自己
/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 ──┘
- Ctrl+C → kernel 送 SIGINT 給前景 process group 的每個 process(預設:終止前景 job)
- Ctrl+Z → kernel 送 SIGTSTP 給前景 process group 的每個 process(預設:停止/暫停前景 job)
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:
- 集合為空(常態)→ 控制權交給邏輯控制流的下一條指令
- 集合非空 → kernel 挑選其中某個訊號 k(通常是最小的 k),強制 p 接收;完成反應動作後控制權回到
每種訊號都有預設動作 (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)
signal 依 handler 值以三種方式改變行為:
- SIG_IGN:忽略型別
signum的訊號 - SIG_DFL:恢復預設動作
- 其他:
handler為使用者定義函式(signal handler)的位址 → 稱為安裝 (install) handler;handler 被呼叫稱 catching;執行 handler 稱 handling。handler 收到唯一整數引數 = 訊號編號 k,同一 handler 因此可服務多種訊號
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);同型別則因隱式阻擋而不會巢狀。
「handler 返回後回到被中斷的指令」有例外:某些系統上被中斷的 slow system call 不會恢復執行,而是立即回傳錯誤並設 errno = EINTR(見 8.5.5 Portable Signal Handling)。
8.5.4 阻擋與解除阻擋訊號 (p.800-801)
- 隱式阻擋 (implicit):kernel 預設會阻擋「目前 handler 正在處理之型別」的 pending 訊號——handler S 執行中若再收到同型訊號 s,s 變成 pending,S 返回後才會被接收
- 顯式阻擋 (explicit):應用程式用
sigprocmask及輔助函式操作
#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 中斷者)。printf、sprintf、malloc、exit 皆不安全;安全輸出只能用 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 的標準旗標宣告 */
sig_atomic_t 的原子性只保證單次讀或單次寫;flag++ 或 flag = flag + 10 可能需要多條指令,不具原子性。
這些準則是保守的(不總是嚴格必要,例如 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 系統的兩個差異:
- signal 語義不一:有些系統 handler catch 一次後就恢復預設動作,handler 必須每次執行時重新安裝自己
- slow system call 可被中斷:
read、wait、accept等可能長時間阻塞的呼叫,在某些舊系統上被訊號中斷後不會自動重啟,而是立即回傳錯誤並設errno = EINTR,程式須手動重啟
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 才可被接收 */
子行程繼承父行程的 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 → fork → pid = 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? | SIGKILL 與 SIGSTOP |
| 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;fork 前 sigprocmask 阻擋 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;交錯輸出需逐步追蹤 |