16、信号处理:signal函数、sigaction、SIGINT/SIGTERM处理、优雅退出

信号处理这个话题,说白了就是让程序学会「听人劝」。

你写了一个后台服务,跑得好好的。突然运维说:「停一下,我要升级。」你总不能直接 kill -9 把进程干掉吧?那样数据库连接没关、文件没刷盘、日志写到一半……后果很严重。

所以我们需要信号处理。让程序在收到「请退出」的信号时,先把屁股擦干净,再体面地离开。

16.1 信号是什么?

信号是操作系统发给进程的一种异步通知。你可以把它理解成「软中断」。

常见的信号有:

信号默认行为典型触发场景
SIGINT终止进程Ctrl+C
SIGTERM终止进程kill 命令默认发送
SIGKILL强制终止(不可捕获)kill -9
SIGSEGV终止 + 产生 core dump段错误(野指针)
SIGPIPE终止进程向已关闭的管道写数据

嗯,这里要注意:SIGKILL 和 SIGSTOP 是不能被捕获、阻塞或忽略的。这是内核的底线——总得留一个「核按钮」给管理员。

16.2 signal 函数:最基础的信号处理

signal 函数是 POSIX 标准最早提供的信号处理接口。用法很简单:

#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);

这个声明看着有点吓人。说白了就是:给信号 signum 注册一个处理函数 handler。handler 接收一个 int 参数(信号编号),返回 void。

来个例子:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void sigint_handler(int sig) {
    printf("\n收到 SIGINT,但我偏不退出!\n");
}

int main() {
    signal(SIGINT, sigint_handler);
    while(1) {
        printf("工作中...\n");
        sleep(1);
    }
    return 0;
}

运行后按 Ctrl+C,程序不会退出,只会打印一条消息。想退出?只能开另一个终端 kill -9 了。

⚠ 注意:signal 函数在不同 Unix 系统上的行为有差异。比如在 System V 上,信号处理函数执行完后,信号会被重置为默认行为。而在 BSD 上则不会。这种不可移植性,是我后来转向 sigaction 的主要原因。

16.3 sigaction:更可靠的信号处理

我个人习惯用 sigaction 而不是 signal。为什么?因为 sigaction 更可控、更可移植、功能更强。

sigaction 的结构体长这样:

struct sigaction {
    void     (*sa_handler)(int);      // 信号处理函数
    void     (*sa_sigaction)(int, siginfo_t *, void *); // 带更多信息的处理函数
    sigset_t   sa_mask;               // 处理期间要阻塞的信号集
    int        sa_flags;              // 控制标志
    void     (*sa_restorer)(void);    // 已废弃,不用管
};

使用步骤:

  1. 定义 struct sigaction 变量
  2. 设置 sa_handler 或 sa_sigaction
  3. 设置 sa_mask(可选)
  4. 设置 sa_flags
  5. 调用 sigaction 注册

看个例子:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

volatile sig_atomic_t keep_running = 1;

void handle_signal(int sig) {
    if (sig == SIGINT) {
        printf("\n收到 SIGINT,准备退出...\n");
        keep_running = 0;
    } else if (sig == SIGTERM) {
        printf("\n收到 SIGTERM,准备退出...\n");
        keep_running = 0;
    }
}

int main() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = handle_signal;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;

    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);

    while(keep_running) {
        printf("服务运行中...\n");
        sleep(1);
    }

    printf("执行清理工作...\n");
    // 关闭文件、释放内存、断开数据库连接...
    printf("优雅退出完成。\n");
    return 0;
}
💡 关键点:keep_running 声明为 volatile sig_atomic_t。volatile 告诉编译器这个变量可能被异步修改(信号处理函数中),不要优化到寄存器里。sig_atomic_t 保证读写是原子的,不会出现「读了一半被中断」的情况。

16.4 SIGINT vs SIGTERM:该怎么处理?

这两个信号经常被混用,但语义不同:

  • SIGINT:通常是用户主动中断(Ctrl+C)。可以理解为「嘿,停一下」。
  • SIGTERM:系统或管理员发出的终止请求。可以理解为「请正常退出」。

我在项目中遇到过这样的情况:某个守护进程对 SIGINT 和 SIGTERM 做了完全相同的处理——设置退出标志、清理资源、退出。结果有一次运维发 SIGTERM 时,程序正在执行一个耗时操作,清理过程卡住了,导致进程无法退出。最后只能 kill -9。

后来我学乖了:

void handle_sigint(int sig) {
    // 用户主动中断,尽快退出
    keep_running = 0;
    // 不执行耗时清理,留给 SIGTERM 处理
}

void handle_sigterm(int sig) {
    // 系统要求退出,执行完整清理
    keep_running = 0;
    // 触发完整清理流程
    do_cleanup = 1;
}

16.5 优雅退出的实践

什么叫优雅退出?就是程序在收到退出信号后:

  1. 停止接受新请求
  2. 处理完正在进行的任务(或设置超时)
  3. 关闭文件描述符、释放内存、刷写缓冲区
  4. 断开数据库连接、通知其他服务
  5. 记录退出日志
  6. 返回正确的退出码

一个典型的优雅退出框架:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

volatile sig_atomic_t shutdown_flag = 0;
volatile sig_atomic_t cleanup_done = 0;

void shutdown_handler(int sig) {
    shutdown_flag = 1;
}

int main() {
    struct sigaction sa;
    sa.sa_handler = shutdown_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);

    // 忽略 SIGPIPE,防止写管道时意外退出
    signal(SIGPIPE, SIG_IGN);

    while (!shutdown_flag) {
        // 主循环:处理任务
        // 每次循环检查 shutdown_flag
        // 如果设置了,跳出循环
    }

    // 执行清理
    printf("正在清理资源...\n");
    // 关闭文件、释放内存等
    cleanup_done = 1;
    printf("清理完成,退出。\n");

    return 0;
}
🛠 避坑指南:我曾经在信号处理函数里调用 printf。结果程序在某个极端情况下死锁了。为什么?因为 printf 不是异步信号安全的。信号处理函数里只能调用 async-signal-safe 的函数,比如 write、_exit。如果非要打印日志,用 write 直接写文件描述符。

16.6 信号处理的常见陷阱

  • 不要在信号处理函数中调用非安全函数:malloc、free、printf、fprintf 都不安全。用 write 代替。
  • 信号处理函数要尽量简单:设置一个标志变量就够了。复杂的逻辑放到主循环中处理。
  • 注意信号屏蔽:处理一个信号时,同类型的信号默认会被阻塞。但其他信号可能乱入。用 sa_mask 控制。
  • 不要试图捕获 SIGKILL:捕获不了,别白费力气。
  • 多线程中的信号:信号处理在多线程中更复杂。建议指定一个线程专门处理信号。

16.7 知识体系图

下面这张图总结了信号处理的核心脉络:

信号处理知识体系 信号来源 键盘中断 (Ctrl+C) 系统调用 (kill) 硬件异常 (段错误) 软件条件 (SIGPIPE) 信号处理方式 默认行为 (终止/忽略/暂停) signal() 注册处理函数 sigaction() 高级注册 SIG_IGN 忽略 SIG_DFL 恢复默认 关键信号 SIGINT (Ctrl+C) SIGTERM (kill 默认) SIGKILL (不可捕获) SIGPIPE (管道断裂) 优雅退出流程 收到信号 → 设置标志 → 停止接受新请求 → 处理完当前任务 → 清理资源 → 退出

16.8 小结

信号处理是命令行工具开发中绕不开的一环。你想想看,一个不能优雅退出的服务,谁敢在生产环境用?

记住几个要点:

  • 能用 sigaction 就别用 signal
  • 信号处理函数里只做最轻量的事
  • 用 volatile sig_atomic_t 保护共享标志
  • 区分 SIGINT 和 SIGTERM 的语义
  • 优雅退出不是一句空话,是实实在在的代码

嗯,信号处理就聊到这里。代码写好了,跑起来试试看——按 Ctrl+C 的时候,你的程序会不会「有礼貌地」说再见?


公众号:蓝海资料掘金营,微信deep3321