跳轉到

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 章)時你會感謝它。

習題#

  1. 跑 signal-hook 範例,用另一個終端 kill -TERM、kill -HUP、kill -9 觀察差異;解釋 SIGKILL 為何無法捕捉。
  2. 修改 self-pipe 範例:handler 裡同時處理 SIGINT 與 SIGTERM,用 strace 觀察 rt_sigaction 呼叫。
  3. 研究 signalfd(2)(Linux 2.6.22, 2007):它如何徹底解決 async-signal-safe 問題?與 self-pipe 比較優劣。