2.2 訊號處理與 signal-hook#
訊號:1970 年代的軟體中斷#
訊號是 Unix 最古老的 IPC 之一(Version 4 Unix, 1973 年前後成形):核心或其他程序透過 kill(pid, sig) 對目標程序「發一個位元等級的事件」。程序可以:
- 忽略(SIG_IGN)
- 使用預設行為(終止、core dump、停止……)
- 安裝處理函式(handler)
經典訊號表(節選):
| 訊號 | 號碼 | 預設 | 意義 |
|---|---|---|---|
| SIGHUP | 1 | 終止 | 掛斷(modem 時代遺物,daemon 常用它做 reload) |
| SIGINT | 2 | 終止 | Ctrl-C |
| SIGKILL | 9 | 終止 | 不可捕捉、不可忽略 |
| SIGSEGV | 11 | core | 段錯誤 |
| SIGPIPE | 13 | 終止 | 寫入無讀者的管線 |
| SIGCHLD | 17 | 忽略 | 子程序結束 |
| SIGTERM | 15 | 終止 | 優雅結束請求(systemd stop 用它) |
daemon 的標準生命週期是:捕捉 SIGTERM、清理資源、離開。Kubernetes、systemd 全都靠這套機制做 graceful shutdown。
為什麼訊號處理是 C 的地獄#
1990 年代起 POSIX 定義了 async-signal-safe(非同步訊號安全)概念:handler 可能在任意指令之間插入執行,包括程式正在 malloc 一半、正在更新鏈結串列一半的時候。因此規則是:handler 內只能呼叫一小組函式(write、_exit 等),不能 printf、malloc、操作資料結構。
經典 C 錯誤:
volatile sig_atomic_t quit = 0; // 正確做法:只設旗標
void handler(int sig) { quit = 1; }
int main() {
signal(SIGINT, handler);
while (!quit) { /* ... */ }
}
但即便如此仍有陷阱:signal() 語義在不同 Unix 上不一致(System V 會重設 handler、BSD 不會),後來有了 sigaction;多執行緒下訊號可能送給任一執行緒,於是又發明 pthread_sigmask 與「專用訊號執行緒」;而 handler 與主流程對 quit 的存取若沒有 volatile 與正確的原子型別,編譯器可能最佳化掉讀取——C 的抽象根本不足以描述「被非同步打斷的記憶體」。
Rust 的訊號世界#
Rust std 幾乎不碰訊號(因為太危險),生態系的答案是 signal-hook crate(2018 年,Viktor Sokolov 開發)。它的核心洞見是:在 handler 裡只做一件事——向 self-pipe(或 eventfd)寫一個位元組,把非同步事件轉成同步的 fd 事件,之後所有邏輯都在安全的主迴圈裡跑。
[dependencies]
libc = "0.2"
signal-hook = "0.3"
use signal_hook::consts::signal::*;
use signal_hook::iterator::Signals;
fn main() -> anyhow::Result<()> {
let mut signals = Signals::new([SIGINT, SIGTERM, SIGHUP])?;
println!("pid={}, Ctrl-C 或 kill {} 來結束", std::process::id(), std::process::id());
for sig in signals.forever() {
match sig {
SIGTERM | SIGINT => {
println!("收到 {sig},優雅關閉");
break;
}
SIGHUP => println!("收到 SIGHUP:重新載入設定(模擬)"),
_ => {}
}
}
println!("清理完成");
Ok(())
}
Signals 內部:註冊 handler → handler 執行 write(self_pipe_fd, "x", 1)(async-signal-safe)→ 主執行緒的 iterator 對 pipe 做 blocking read。所有不安全被封裝在 crate 裡,使用者端是純 safe Rust。這是第 1.3 節「安全抽象」哲學的完美範例。
檢視老問題如何被消滅#
| C 的問題 | Rust 的對策 |
|---|---|
| handler 裡呼叫 printf/malloc | handler 內只 write 一 byte(crate 內部保證) |
volatile sig_atomic_t 被最佳化 | 事件改由 pipe 傳遞,讀取在正常控制流 |
| 訊號送給哪個執行緒不確定 | signal-hook 可指定註冊執行緒,事件集中到單一迭代器 |
| signal vs sigaction 平台差異 | crate 統一用 sigaction |
裸寫一次,理解底層#
不用 crate,自己用 libc 走一遍 self-pipe trick(也是 2003 年 Dan Bernstein 推廣、後來進入 Linux signalfd(2007) 之前的標準解法):
use std::sync::atomic::{AtomicBool, Ordering};
static GOT_SIG: AtomicBool = AtomicBool::new(false);
extern "C" fn handler(_: i32) {
GOT_SIG.store(true, Ordering::SeqCst);
}
fn main() {
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = handler as usize;
sa.sa_flags = libc::SA_RESTART;
libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut());
}
println!("pid = {}", std::process::id());
while !GOT_SIG.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(100));
}
println!("結束");
}
這裡有三個值得討論的 unsafe:mem::zeroed()(sigaction 結構有 padding 與 union)、函式指標轉換(extern "C" ABI)、以及 handler 對全域 atomic 的存取(AtomicBool 保證了可見性,這正是 Rust 型別系統把「原子操作的正確語意」變成顯式選擇的地方——Ordering::SeqCst 在 C 裡容易被忽略,在 Rust 裡必須寫出來)。
SIGPIPE:Rust 的立場#
寫網路程式時 SIGPIPE 是隱形殺手:對端關閉後繼續 write,程序被訊號直接打死。C 伺服器都要記得 signal(SIGPIPE, SIG_IGN)。Rust std 預設忽略 SIGPIPE,改讓 write 回傳 Err(EPIPE)——把「異常終止」轉成「可處理的錯誤值」。這是語言設計者對歷史包袱做出的明確裁決,寫網路伺服器(第 4 章)時你會感謝它。
習題#
- 跑 signal-hook 範例,用另一個終端
kill -TERM、kill -HUP、kill -9觀察差異;解釋 SIGKILL 為何無法捕捉。 - 修改 self-pipe 範例:handler 裡同時處理 SIGINT 與 SIGTERM,用 strace 觀察
rt_sigaction呼叫。 - 研究
signalfd(2)(Linux 2.6.22, 2007):它如何徹底解決 async-signal-safe 問題?與 self-pipe 比較優劣。