【Linux】进程信号:从产生、阻塞与递达到 sigaction、可重入与内核返回路径

🔥 本文定位 :面向已经掌握 Linux 进程、虚拟地址空间与基础系统调用的同学,从"按下 Ctrl+C 后究竟发生了什么"出发,完整梳理信号的产生、未决、阻塞、递达与处理,并深入到 sigaction、信号处理函数安全性、SIGCHLD 回收以及用户态/内核态切换路径。

💡 学习目标 :不仅会用 kill()alarm()sigprocmask()sigaction(),还要真正分清 阻塞与忽略、标准信号与实时信号、进程级属性与线程级属性、异步通知与同步等待;能写出不丢事件、不制造竞态、不会在处理函数里死锁的代码。


文章目录


一、信号到底是什么

1.1 信号是一种异步事件通知

Linux 信号(signal)是内核向进程或线程递送的一类异步事件通知。信号只携带"发生了某类事件"这一层含义;普通标准信号不是用来传输大量业务数据的。

常见例子:

  • 用户在终端按下 Ctrl+C,终端驱动向前台进程组发送 SIGINT
  • 一个进程调用 kill() 向另一个进程发送 SIGTERM
  • 定时器到期,内核产生 SIGALRM
  • 某条指令访问非法地址,当前线程收到 SIGSEGV
  • 子进程退出、停止或继续运行,父进程收到 SIGCHLD
  • 管道已经没有读端,写进程可能收到 SIGPIPE

信号的"异步"体现在:主程序并不知道事件会在自己的哪条指令附近到来。它可能正在计算、睡眠、执行系统调用,甚至刚刚准备进入一个临界区。

1.2 信号不是"函数调用"

注册处理函数之后,最终的表现确实像某个函数突然被执行,但它与普通函数调用有本质区别:

  1. 主程序没有显式写出调用语句;
  2. 内核在合适的用户态返回点安排处理函数执行;
  3. 处理函数与被中断代码形成两条交错的控制流;
  4. 处理函数能做的事情受到异步信号安全约束;
  5. 返回处理函数需要特殊的信号帧与 sigreturn 恢复上下文。

因此,信号处理函数不是"随便写一个回调"这么简单。

1.3 编号只是实现细节,代码应使用符号名

在多数 x86/ARM Linux 上,SIGINT 常为 2、SIGKILL 常为 9、SIGSEGV 常为 11。但某些标准信号在不同体系结构上的编号并不完全一致,实时信号范围还会受 C 库线程实现影响。

正确写法是:

c 复制代码
kill(pid, SIGTERM);
sigaction(SIGINT, &sa, NULL);

不应把程序写成:

c 复制代码
kill(pid, 15);       // 可读性和可移植性都更差
signal(2, handler);  // 不清楚 2 表示什么

可以在当前机器上查看:

bash 复制代码
kill -l
kill -l SIGTERM
kill -l 15

注意SIGKILLSIGSTOP 不能被捕获、阻塞或忽略。这是内核为"必定终止"和"必定停止"保留的控制能力。


二、信号的完整生命周期

一个信号从事件发生到真正产生效果,至少要经过四个概念:

  1. 产生(generation):某个事件让内核创建一个信号通知;
  2. 未决(pending):信号已经产生,但尚未递达;
  3. 阻塞(blocked):当前线程的信号掩码暂时禁止某信号递达;
  4. 递达(delivery):内核执行该信号的处理动作。

2.1 "产生"不等于"立即执行处理函数"

假设线程阻塞了 SIGINT,此时按下 Ctrl+C

text 复制代码
终端产生 SIGINT
        ↓
SIGINT 进入未决集合
        ↓
因当前掩码阻塞,暂不递达
        ↓
线程解除阻塞
        ↓
内核选择合适时机递达 SIGINT

所以,未决描述的是信号是否已经发生但还没处理 ,阻塞描述的是当前是否允许递达。二者不是同一个状态位的两种叫法。

2.2 阻塞不等于忽略

这是最容易混淆的一组概念:

行为 含义 之后解除会怎样
阻塞 暂时不递达,信号可以保持未决 解除阻塞后可能马上递达
忽略 该信号的处理动作是 SIG_IGN 通常不执行处理动作
默认 执行信号规定的默认动作 可能终止、产生 core、停止、继续或忽略
捕获 运行用户注册的处理函数 处理完后通常回到被中断位置

可以把它们类比为:

  • 未决集合:收件箱里是否已有这类通知;
  • 阻塞集合:暂时开启"免打扰"的通知类别;
  • 处理动作:看到通知后究竟删除、按默认规则处理,还是交给自定义处理函数。

2.3 多线程程序里哪些属性属于谁

现代 Linux 中要特别注意层级:

  • 信号处理动作(disposition)是进程级属性,同一进程的线程共享;
  • 信号掩码(mask)是线程级属性,每个线程可阻塞不同信号;
  • 未决信号既可能是进程定向 ,也可能是线程定向
  • 进程定向信号可以递达给任意一个没有阻塞它的线程;
  • 指令异常以及 pthread_kill() 等产生的信号通常指向特定线程。

这也是为什么多线程程序应使用 pthread_sigmask() 管理掩码,而不是依赖 sigprocmask() 的未指定行为。


三、信号从哪里产生

信号的来源看似很多,但可以归纳为五类:终端、进程请求、定时与软件条件、指令异常、内核对象状态变化。

3.1 命令行 kill 与系统调用 kill()

命令行:

bash 复制代码
kill -TERM 12345
kill -SIGTERM 12345
kill -15 12345

三种写法通常表达同一件事,但推荐符号名。对应的系统调用接口为:

c 复制代码
#include <signal.h>

int kill(pid_t pid, int sig);

pid 的取值很有讲究:

pid 目标
pid > 0 PID 等于 pid 的进程
pid == 0 调用者所在进程组中的进程
pid == -1 调用者有权限发送的广泛目标集合,具体排除项见系统手册
pid < -1 进程组 ID 等于 -pid 的进程

发送能否成功还要经过权限检查。kill(pid, 0) 不会真正递送信号,常用于检查目标是否存在以及调用者是否具备发送权限,但应正确处理 ESRCHEPERM

c 复制代码
if (kill(target, 0) == -1) {
    if (errno == ESRCH) {
        /* 目标不存在,或已不再可见 */
    } else if (errno == EPERM) {
        /* 目标可能存在,但当前进程无权限发送 */
    }
}

3.2 raise():向当前执行流发送信号

c 复制代码
#include <signal.h>

int raise(int sig);

raise(SIGUSR1) 适合在当前程序内触发预先设计好的信号路径。在 POSIX 线程语义下,它把信号发送给调用线程,而不必先查询 PID。

3.3 abort():发出不能被程序"糊弄过去"的 SIGABRT

c 复制代码
#include <stdlib.h>

void abort(void);

abort() 产生 SIGABRT,默认动作是终止并尝试生成 core dump。即便程序曾忽略或捕获 SIGABRTabort() 也必须最终使进程异常终止;它适合报告不可继续运行的不变量破坏,而不是普通业务错误。

3.4 alarm():进程级一次性秒级闹钟

c 复制代码
#include <unistd.h>

unsigned int alarm(unsigned int seconds);

alarm(n) 安排约 n 秒后产生 SIGALRM。同一进程只有一个这类 alarm,后一次调用会替换前一次安排,并返回前一闹钟剩余的秒数;alarm(0) 用于取消。

下面的代码只在处理函数中设置标志,主流程完成输出:

c 复制代码
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

static volatile sig_atomic_t timeout = 0;

static void on_alarm(int signo)
{
    (void)signo;
    timeout = 1;
}

int main(void)
{
    struct sigaction sa = {0};
    sa.sa_handler = on_alarm;
    sigemptyset(&sa.sa_mask);
    if (sigaction(SIGALRM, &sa, NULL) == -1) {
        perror("sigaction");
        return 1;
    }

    alarm(3);
    while (!timeout) {
        /* 模拟主任务;真实程序不应无意义忙等 */
    }
    puts("timeout");
    return 0;
}

alarm() 是秒级、一次性、进程级接口。需要周期定时、纳秒精度或与事件循环统一管理时,应评估 POSIX timer、timerfd 等方案。

3.5 指令异常与软件条件

  • 非法内存访问可能导致当前线程收到 SIGSEGV
  • 某些体系结构上的整数除零可能表现为 SIGFPE
  • 向无读者管道写入可能触发 SIGPIPE
  • 子进程状态变化触发父进程的 SIGCHLD

这里不要把 C 语言未定义行为当成稳定的"发信号 API"。例如整数除零在 C 语义上就是未定义行为,不能依赖其一定产生某个编号的信号。


四、终端按键为什么会影响前台进程组

在交互式终端中,Shell 会管理会话、控制终端和前台进程组。终端驱动将特殊控制字符转换为作业控制信号,目标通常是前台进程组,不是屏幕上某个看起来最活跃的单独进程。

常见组合:

按键 信号 默认动作 常见用途
Ctrl+C SIGINT 终止 请求前台任务中断
Ctrl+\ SIGQUIT 终止并 core 退出并保留诊断现场
Ctrl+Z SIGTSTP 停止 让 Shell 接回控制,之后可 fg/bg

假设执行:

bash 复制代码
sleep 100 | cat

这是一个包含多个进程的管道作业。按下 Ctrl+C 时,终端会把 SIGINT 发给该前台进程组,所以管道中的进程都能收到,而不是只杀掉 cat

4.1 Ctrl+C 不是 SIGKILL

Ctrl+C 产生的是可捕获的 SIGINT。程序可以用它进行有边界的收尾,例如设置停止标志、让主循环退出、关闭连接。

SIGKILL 不给用户态代码执行清理的机会,通常只用于目标已经无法正常响应的最后手段:

bash 复制代码
kill -TERM pid   # 先请求正常退出
kill -KILL pid   # 确认无法退出后才强制终止

4.2 "终止并 core"不保证目录里一定出现 core 文件

SIGQUITSIGSEGVSIGABRT 等信号的默认动作可能标记为 Core,但 core 文件是否真正落盘还受多项条件影响:

  • ulimit -c / RLIMIT_CORE
  • 目录权限和剩余空间;
  • /proc/sys/kernel/core_pattern
  • 可执行文件权限与进程安全属性;
  • systemd-coredump 等接管机制。

排查时可先看:

bash 复制代码
ulimit -c
cat /proc/sys/kernel/core_pattern
coredumpctl list        # 使用 systemd-coredump 的系统

五、标准信号与实时信号

Linux 同时支持标准信号与 POSIX 实时信号。二者最重要的区别不是"编号大小",而是排队、顺序与附加数据语义。

5.1 标准信号通常只保留"至少发生过一次"

如果某标准信号被阻塞,在解除阻塞前连续产生多次同种信号,通常只保留一个未决实例:

text 复制代码
阻塞 SIGUSR1
发送 SIGUSR1 × 5
pending 中只表达"SIGUSR1 至少发生过一次"
解除阻塞后通常只递达一次

所以不能拿标准信号充当可靠计数器。如果业务语义要求"一次都不能少",请选择实时信号、消息队列、管道、eventfd 或其他带排队/计数语义的机制。

5.2 实时信号可以排队并附带值

实时信号的有效范围应通过 SIGRTMINSIGRTMAX 获取,不要硬编码 34 到 64。使用 sigqueue() 可以附带一个整数或指针值,配合 SA_SIGINFO 在接收端读取。

实时信号具备:

  • 同种信号多次产生可以排队;
  • 同种实时信号按发送顺序递达;
  • 不同实时信号通常从较小编号开始递达;
  • 队列受资源限制,发送仍可能失败。

5.3 常见标准信号速查

信号 典型含义 默认动作
SIGHUP 控制终端挂断或控制进程退出 Term
SIGINT 终端中断 Term
SIGQUIT 终端退出 Core
SIGABRT abort() Core
SIGKILL 强制终止 Term,不可改变
SIGSEGV 非法内存引用 Core
SIGPIPE 向无读者管道写入 Term
SIGALRM alarm 到期 Term
SIGTERM 可协商的终止请求 Term
SIGCHLD 子进程状态变化 Ign
SIGSTOP 强制停止 Stop,不可改变
SIGTSTP 终端停止 Stop
SIGCONT 继续已停止进程 Cont

默认动作描述的是未修改 disposition 时的行为,并不等于每个平台编号完全相同。


六、默认、忽略与自定义捕获

进程对信号有三类可选处理动作:

c 复制代码
SIG_DFL     /* 使用默认动作 */
SIG_IGN     /* 忽略 */
handler     /* 调用用户处理函数 */

6.1 为什么更推荐 sigaction() 而不是 signal()

signal() 接口短,但历史 UNIX 变体对处理函数重置、系统调用重启等语义曾存在差异。sigaction() 明确提供处理函数、附加屏蔽集和标志位,更适合生产代码。

c 复制代码
struct sigaction sa = {0};
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;

if (sigaction(SIGINT, &sa, NULL) == -1) {
    perror("sigaction");
}

6.2 fork 与 exec 对处理动作的影响

  • fork() 创建的子进程继承父进程的处理动作副本;
  • execve() 后,被自定义处理函数捕获的信号恢复为默认动作;
  • 被设为忽略的信号通常跨 execve() 保持忽略;
  • 信号掩码会被子进程继承,并跨 execve() 保留。

这会造成一个常见 bug:父进程为了某段临界区阻塞了信号,随后 fork + exec,新程序意外带着阻塞掩码启动。因此创建子程序前要明确恢复其期望掩码。

6.3 不要试图从同步故障里随意"恢复"

捕获 SIGSEGV 后直接 return,程序通常会回到同一条故障指令,再次触发 SIGSEGV,形成循环。除非你在实现崩溃报告、语言运行时或经过严格设计的容错边界,否则不要把 SIGSEGV 处理函数当普通异常恢复机制。

崩溃处理函数通常只做极少量异步信号安全操作,然后恢复默认动作并重新触发,或立即 _exit()。完整堆栈采集往往应交给外部监控、core dump 或专门崩溃处理基础设施。


七、阻塞、未决与信号集操作

7.1 三张"逻辑表"怎样协作

教学时可以把信号状态画成三张表:

  • disposition:默认、忽略或处理函数;
  • blocked mask:当前线程阻塞哪些信号;
  • pending:已经产生但尚未递达哪些信号。

这是理解语义的好模型,但不要把某本教材中旧版本 task_struct 的字段布局当成稳定内核 ABI。现代 Linux 还要区分线程私有与进程共享的 pending,具体结构随内核版本演进;用户态代码只能依赖公开接口。

7.2 sigset_t 必须通过 API 操作

sigset_t 是不透明信号集,不应自己假设它就是一个 unsigned long

c 复制代码
#include <signal.h>

int sigemptyset(sigset_t *set);           // 清空
int sigfillset(sigset_t *set);            // 填满
int sigaddset(sigset_t *set, int signo);  // 加入
int sigdelset(sigset_t *set, int signo);  // 删除
int sigismember(const sigset_t *set, int signo); // 查询

7.3 sigprocmask() 修改当前线程的掩码

c 复制代码
int sigprocmask(int how,
                const sigset_t *restrict set,
                sigset_t *restrict oldset);

how

新掩码
SIG_BLOCK 当前掩码与 set 的并集
SIG_UNBLOCK 从当前掩码移除 set 中信号
SIG_SETMASK 直接替换成 set

总是保存旧掩码并在临界区结束后恢复,比想当然地"解除几个信号"更稳妥:

c 复制代码
sigset_t block, oldmask;

sigemptyset(&block);
sigaddset(&block, SIGINT);

if (sigprocmask(SIG_BLOCK, &block, &oldmask) == -1) {
    perror("sigprocmask block");
    return 1;
}

/* 临界区:SIGINT 可以产生并保持未决,但不会在此递达 */

if (sigprocmask(SIG_SETMASK, &oldmask, NULL) == -1) {
    perror("sigprocmask restore");
    return 1;
}

SIGKILLSIGSTOP 即使被加入集合,也不会真的被阻塞。

7.4 sigpending() 看到的是什么

c 复制代码
sigset_t pending;
if (sigpending(&pending) == -1) {
    perror("sigpending");
}

if (sigismember(&pending, SIGINT) == 1) {
    puts("SIGINT is pending");
}

当前线程通过 sigpending() 看到的是与它有关的未决集合:包括进程定向未决信号与该线程的线程定向未决信号的并集。

7.5 一个可复现实验

  1. 安装 SIGINT 处理函数;
  2. 阻塞 SIGINT
  3. 循环打印它是否 pending;
  4. 在终端按多次 Ctrl+C
  5. 恢复旧掩码。

预期现象:按键后 SIGINT 变为 pending,但处理函数暂不执行;解除阻塞后处理函数执行。多次 Ctrl+C 不会像普通消息队列一样累积成相同次数,因为 SIGINT 是标准信号。


八、为什么等待信号要用 sigsuspend

很多程序希望做到:"暂时阻塞一个信号,完成准备工作,然后解除阻塞并睡眠,等该信号唤醒。"直觉代码却存在丢唤醒竞态。

8.1 错误写法:unblock + pause

c 复制代码
sigprocmask(SIG_SETMASK, &oldmask, NULL); // 解除阻塞
pause();                                  // 再睡眠

如果信号恰好在两条语句之间到来:处理函数先执行并返回,随后程序才进入 pause()。这次事件已经消费掉,程序可能无限等待下一次信号。

8.2 正确写法:sigsuspend() 原子替换掩码并等待

sigsuspend(mask) 在一次内核操作中临时安装 mask 并挂起线程,避免"解除阻塞"和"进入等待"之间的窗口。处理函数返回后,原信号掩码会自动恢复。

c 复制代码
static volatile sig_atomic_t ready = 0;

static void on_usr1(int signo)
{
    (void)signo;
    ready = 1;
}

int main(void)
{
    struct sigaction sa = {0};
    sigset_t block, oldmask, waitmask;

    sa.sa_handler = on_usr1;
    sigemptyset(&sa.sa_mask);
    if (sigaction(SIGUSR1, &sa, NULL) == -1)
        return 1;

    sigemptyset(&block);
    sigaddset(&block, SIGUSR1);
    if (sigprocmask(SIG_BLOCK, &block, &oldmask) == -1)
        return 1;

    /* 此处完成共享状态、文件描述符等准备工作 */

    waitmask = oldmask;
    sigdelset(&waitmask, SIGUSR1);

    while (!ready)
        sigsuspend(&waitmask);  // 通常返回 -1,errno == EINTR

    sigprocmask(SIG_SETMASK, &oldmask, NULL);
    return 0;
}

必须使用 while (!ready),因为唤醒线程的可能是其他未屏蔽信号,也要防止状态在复杂逻辑中发生变化。

8.3 pause() 的返回值

pause() 会等待到某个信号被捕获且处理函数返回;随后返回 -1 并令 errno = EINTR。如果信号默认动作直接终止进程,自然不会返回。生产代码不应把 pause()-1 一律当成故障日志刷屏。


九、用 sigaction 正确安装处理动作

9.1 结构体的三个核心部分

典型接口:

c 复制代码
struct sigaction {
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
};

实际头文件可能通过 union 或额外字段实现,应用代码不要依赖内部布局。

  • sa_handler:普通单参数处理函数,或 SIG_DFL / SIG_IGN
  • sa_sigaction:配合 SA_SIGINFO 的三参数处理函数;
  • sa_mask:处理函数执行期间额外阻塞哪些信号;
  • sa_flags:控制重启、重入、子进程通知、备用栈等语义。

9.2 当前信号默认会在自己的处理函数期间被阻塞

递达 SIGUSR1 时,内核通常自动把 SIGUSR1 加入当前线程的临时掩码,防止同种信号重入该处理函数。sa_mask 指定的其他信号也会在处理期间阻塞;处理函数结束后恢复原掩码。

只有显式设置 SA_NODEFER 才允许当前信号在自己的处理函数中再次递达。除非设计经过严格证明,否则不要启用,它很容易导致无限嵌套和栈耗尽。

9.3 常用 sa_flags

标志 作用 注意点
SA_SIGINFO 使用三参数处理函数 可获得 siginfo_t 与上下文
SA_RESTART 自动重启一部分被信号中断的阻塞接口 不是所有系统调用都会重启
SA_NODEFER 处理期间不自动阻塞当前信号 容易重入和栈爆炸
SA_RESETHAND 首次递达时恢复默认动作 一次性捕获语义
SA_NOCLDSTOP 子进程停止/继续时不产生 SIGCHLD 只影响 SIGCHLD
SA_NOCLDWAIT 不把终止子进程变成僵尸 可移植语义与程序结构需评估
SA_ONSTACK 在备用信号栈上执行 需先用 sigaltstack() 配置

9.4 SA_SIGINFO 示例

c 复制代码
#include <signal.h>
#include <string.h>
#include <unistd.h>

static void on_signal(int signo, siginfo_t *info, void *ucontext)
{
    (void)ucontext;
    if (signo == SIGUSR1 && info != NULL) {
        /* 处理函数内避免 printf;这里只设置简单状态或 write 固定消息 */
        const char msg[] = "SIGUSR1 received\n";
        (void)write(STDERR_FILENO, msg, sizeof(msg) - 1);
    }
}

int main(void)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_sigaction = on_signal;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_SIGINFO | SA_RESTART;

    if (sigaction(SIGUSR1, &sa, NULL) == -1)
        return 1;

    for (;;)
        pause();
}

siginfo_t 可提供发送者 PID/UID、sigqueue() 附带值、故障地址、子进程状态等信息,但具体哪些字段有效取决于信号来源与 si_code

9.5 SA_RESTART 不是"从此没有 EINTR"

一些慢速阻塞接口在处理函数返回后会自动重启,另一些始终可能返回 EINTR。因此正确策略是:

  1. 查目标接口的手册;
  2. 明确是否设置 SA_RESTART
  3. 对允许重试的操作显式处理 EINTR
  4. 不要对有副作用且完成度不明的操作盲目重试。

十、信号处理函数究竟怎样被执行

处理函数明明写在用户空间,内核又怎样让它"插入"当前程序的执行路径?关键发生在从内核态准备返回用户态时。

10.1 内核检查可递达信号

线程可能因为系统调用、中断、异常或调度进入内核。内核准备恢复用户态执行时,会检查是否存在:

text 复制代码
pending  且  未被当前线程 mask 阻塞  且  需要执行用户处理函数

如果没有,恢复原用户上下文;如果有,就准备信号处理现场。

10.2 构造用户态信号帧

内核会在用户栈或备用信号栈上保存必要上下文,例如:

  • 被中断时的程序计数器;
  • 寄存器状态;
  • 原信号掩码;
  • 备用栈状态;
  • SA_SIGINFO 所需信息。

随后把即将返回用户态的指令地址改成处理函数入口,并安排返回地址指向用户空间的 signal trampoline。

10.3 处理函数与主流程没有普通调用关系

内核返回用户态后,CPU 从 handler 入口开始执行。handler 的栈帧看起来像一次调用,但不是 main() 主动调用它。

当 handler 执行 return 时,控制流进入 trampoline,后者发起 sigreturn 系统调用。内核根据先前保存的信号帧恢复寄存器、掩码与程序计数器,最后回到被打断的位置继续执行。

10.4 一条重要边界:这是稳定语义,不是固定源码函数名

老教材可能画出 do_signal()sys_sigreturn() 或某一版本 task_struct 字段。它们有助于理解,但具体内核函数名和数据结构会随版本、架构改变。应用开发应依赖 signal(7)sigaction(2)sigreturn(2) 描述的用户可见语义,而不是绑定某版内核源码布局。


十一、异步信号安全、可重入与 volatile

11.1 为什么 printf、cout、malloc 和锁都危险

假设主流程正执行 printf(),内部已经锁住了 stdout;信号到来后,处理函数再次调用 printf(),它可能尝试获得同一把锁并永久死锁。malloc() 也可能在修改堆管理链表时被打断,处理函数再次分配内存会破坏内部状态。

类似风险包括:

  • C/C++ 标准 I/O:printffprintfstd::cout
  • 动态内存:mallocfreenewdelete
  • 普通互斥锁及大多数同步原语;
  • 大量依赖全局状态的库函数;
  • 非平凡容器修改、日志框架与异常抛出。

11.2 可重入不等于异步信号安全

两个概念相关但不完全相同:

  • 可重入函数:被中断后再次进入也不会破坏状态,通常只使用参数、局部变量和受控数据;
  • 异步信号安全函数:POSIX 明确保证可以从信号处理函数调用。

工程上最稳妥的原则是:处理函数只做极少工作------设置 volatile sig_atomic_t 标志,或向已准备好的文件描述符写入固定大小通知;复杂业务留给正常控制流。

11.3 write() 可以,printf() 不可以

write() 属于 POSIX 异步信号安全函数,而 printf() 不是:

c 复制代码
static void on_term(int signo)
{
    (void)signo;
    static const char msg[] = "termination requested\n";
    (void)write(STDERR_FILENO, msg, sizeof(msg) - 1);
}

即使是 write(),也要避免在处理函数里构造复杂格式、访问可能失效的对象或依赖阻塞一定很短。

11.4 volatile 真正能解决什么

正确的最小标志:

c 复制代码
static volatile sig_atomic_t stop_requested = 0;

static void on_term(int signo)
{
    (void)signo;
    stop_requested = 1;
}

这里两部分缺一不可:

  • sig_atomic_t:保证处理函数与主流程之间的简单访问不会被拆成不可安全观察的中间状态;
  • volatile:告诉编译器每次按抽象机规则读取/写入,不把循环中的读取永久缓存到寄存器。

volatile 不是锁,不提供线程间 happens-before,不保证复合操作原子,也不是 C++ 多线程同步方案 。例如 counter++ 是读、改、写复合操作,即使 countervolatile sig_atomic_t,也不应把它当可靠事件计数器。

11.5 推荐的 self-pipe 模式

如果主循环已经在 poll()/select() 中等待多个文件描述符,可以让处理函数向非阻塞管道写一个字节,主循环从读端得到可读事件后处理复杂逻辑:

text 复制代码
signal handler ── write(1 byte) ──> pipe ── readable ──> event loop

处理函数只执行异步信号安全的 write();业务逻辑重新回到正常执行环境。管道写端必须提前创建并设置非阻塞,还要设计缓冲区满时的策略,例如把通知视为"状态已脏",允许相同事件合并。


十二、用 SIGCHLD 正确回收子进程

子进程终止后,内核保留少量退出状态,直到父进程执行 wait()/waitpid()。如果父进程既不等待又持续运行,子进程会成为僵尸。

12.1 一个 SIGCHLD 可能对应多个子进程变化

SIGCHLD 是标准信号,不保证为每个退出子进程排队一个实例。因此处理函数不能只调用一次 waitpid(),而要循环回收所有已经可等待的子进程。

12.2 处理函数中的经典安全写法

c 复制代码
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>

static void reap_children(int signo)
{
    int saved_errno = errno;
    (void)signo;

    while (waitpid(-1, NULL, WNOHANG) > 0) {
        /* 循环回收所有已退出子进程 */
    }

    errno = saved_errno;
}

static int install_sigchld(void)
{
    struct sigaction sa = {0};
    sa.sa_handler = reap_children;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    return sigaction(SIGCHLD, &sa, NULL);
}

为什么保存 errno?信号处理函数可能打断刚刚失败的主流程。如果 handler 改写 errno,主流程恢复后就看不到原错误原因。

12.3 为什么父进程仍要理解 wait 状态

需要业务感知退出原因时,不要丢弃状态:

c 复制代码
int status;
pid_t pid = waitpid(child, &status, 0);

if (pid > 0) {
    if (WIFEXITED(status)) {
        printf("exit code=%d\n", WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        printf("killed by signal=%d\n", WTERMSIG(status));
#ifdef WCOREDUMP
        printf("core=%s\n", WCOREDUMP(status) ? "yes" : "no");
#endif
    } else if (WIFSTOPPED(status)) {
        printf("stopped by signal=%d\n", WSTOPSIG(status));
    } else if (WIFCONTINUED(status)) {
        puts("continued");
    }
}

应使用 WIF* / W* 宏,不要手工假设状态字节布局。WCOREDUMP 的可用性还需按目标平台条件编译。

12.4 更复杂服务中的方案

高并发服务通常不希望在异步处理函数中更新业务状态。可选做法:

  • handler 只设置标志或唤醒 self-pipe,主循环调用 waitpid()
  • 在专用线程中阻塞并同步等待 SIGCHLD
  • Linux 事件循环使用 signalfd
  • 已知子进程 PID 时结合 pidfd 避免 PID 复用竞态。

十三、硬件中断、异常、系统调用与信号

信号经常被类比成"软件中断",这个类比能帮助入门,但不能画等号。硬件中断、CPU 异常、系统调用与信号位于不同抽象层。

13.1 四个概念分别是什么

机制 典型来源 同步性 主要处理位置
硬件中断 定时器、网卡、磁盘控制器 相对当前指令异步 内核中断处理路径
CPU 异常 缺页、非法指令、除法错误 与当前指令同步 内核异常处理路径
系统调用 用户代码显式请求 与调用点同步 内核系统调用路径
进程信号 终端、进程请求、定时器、异常转译等 通常异步通知,也可由同步故障产生 内核记录,用户态处理或默认动作

13.2 页故障不等于 SIGSEGV

访问尚未映射到物理页但合法的虚拟地址时会触发 page fault。内核可能完成匿名页分配、文件页调入或写时复制,然后重新执行故障指令,用户程序甚至察觉不到。

只有当地址/权限无法合法修复时,内核才可能向当前线程产生 SIGSEGVSIGBUS

text 复制代码
CPU page fault
      ↓
内核查 VMA 与访问权限
      ├─ 合法且可修复 → 建立映射 → 重试指令
      └─ 非法/不可修复 → 产生同步故障信号

13.3 定时器中断不等于直接调用你的 SIGALRM 处理函数

硬件定时器中断让内核更新时钟、调度与软件定时器状态。某个 alarm() 到期后,内核为目标进程记录 SIGALRM;之后在该线程适合回到用户态时递达。中间隔着内核的计时、信号记账、调度与递达逻辑。

13.4 用户态与内核态是 CPU 权限状态,不是两个进程

同一个线程执行应用指令时处于用户态,通过系统调用、异常或中断进入内核态;内核完成工作后再回到用户态。切换的是权限级和执行上下文,并不是"用户态进程把任务发送给另一个内核进程"。

旧式 32 位 Linux 的 3G/1G 地址划分只是一种历史配置,不能推广为所有现代系统的固定地址边界。64 位架构、不同内核配置和隔离技术都有不同布局。


十四、工程实践、排查方法与现代替代方案

14.1 先决定:异步 handler 还是同步接收

传统方式是安装异步 handler;但现代服务端程序常把信号转成可管理的同步事件。

sigwaitinfo / sigwait

先在相关线程中阻塞目标信号,再由专用线程同步等待:

text 复制代码
所有工作线程阻塞 SIGTERM/SIGHUP
               ↓
信号线程 sigwaitinfo()
               ↓
正常线程上下文执行停止或重载逻辑

优点是没有异步处理函数安全限制,适合多线程程序。

signalfd

Linux 的 signalfd() 把已阻塞信号转换成可读文件描述符,可接入 epoll

text 复制代码
SIGTERM / SIGHUP / SIGCHLD
            ↓
         signalfd
            ↓
     epoll_wait() 可读事件
            ↓
       主事件循环处理

它是 Linux 特有接口;如果需要跨 UNIX 可移植性,可采用 sigwaitinfo() 或 self-pipe。

14.2 常见错误清单

  1. 在处理函数中调用 printf()malloc() 或加普通锁;
  2. 用普通 int 在 handler 与主流程间通信;
  3. volatile 当作跨线程同步或可靠计数器;
  4. 认为阻塞就是忽略;
  5. 认为连续发送 10 次标准信号就一定处理 10 次;
  6. 使用 unblock + pause,留下丢唤醒窗口;
  7. SIGCHLD handler 只执行一次 waitpid()
  8. 认为 SA_RESTART 会重启所有系统调用;
  9. 捕获 SIGSEGV 后直接返回,期待程序正常继续;
  10. 硬编码实时信号编号;
  11. 多线程程序仍随意使用 sigprocmask()
  12. 把旧内核源码结构当成稳定公开接口。

14.3 排查命令

查看进程信号状态
bash 复制代码
cat /proc/<pid>/status | grep -E '^(SigPnd|ShdPnd|SigBlk|SigIgn|SigCgt):'
  • SigPnd:线程级 pending;
  • ShdPnd:进程共享 pending;
  • SigBlk:阻塞集合;
  • SigIgn:忽略集合;
  • SigCgt:已捕获集合。

这些字段通常以十六进制位图显示,可配合 kill -l 解读,但需留意体系结构与信号编号。

跟踪信号与系统调用
bash 复制代码
strace -f -e trace=signal,process ./app
strace -f -e signal=all ./app

strace 可以看到 rt_sigactionrt_sigprocmask、信号递达以及被 EINTR 中断/重启的系统调用。

调试 core dump
bash 复制代码
ulimit -c unlimited
gdb ./app core

进入 GDB 后常用:

gdb 复制代码
bt
info registers
frame 0
x/i $pc

如果系统使用 systemd-coredump:

bash 复制代码
coredumpctl info <pid-or-exe>
coredumpctl debug <pid-or-exe>

14.4 一个更稳健的终止模型

服务程序收到 SIGTERM 后,不要在 handler 内直接释放所有资源。建议:

text 复制代码
SIGTERM 到达
    ↓
handler / signalfd 只记录 stop_requested
    ↓
主循环停止接收新任务
    ↓
等待在途任务完成(带超时)
    ↓
关闭监听 fd、刷新必要状态
    ↓
正常返回或 _exit

这种设计把异步边界压缩到最小,退出过程更容易测试和观测。

14.5 本文涉及的权威手册


十五、高频面试题

15.1 信号从产生到处理经历哪些阶段?

产生后先成为 pending;若目标线程阻塞该信号,它继续保持未决;解除阻塞后,内核在适合返回用户态等时机递达,执行默认、忽略或用户处理函数。自定义处理函数结束后通常通过 signal trampoline 与 sigreturn 恢复被中断上下文。

15.2 阻塞与忽略有什么区别?

阻塞是暂缓递达,信号可能保留在 pending 中;忽略是处理动作本身为 SIG_IGN。解除阻塞可能触发递达,而"取消忽略"只是在修改以后递达时采用的动作。

15.3 为什么 SIGKILL 和 SIGSTOP 不能捕获?

内核必须保留两个不受用户态代码干预的控制手段:无条件终止与无条件停止。否则失控进程可以通过忽略或阻塞让系统管理员永远无法控制它。

15.4 连续发送多次 SIGUSR1,会处理多次吗?

不一定。SIGUSR1 是标准信号,同种标准信号在已经 pending 时不会继续排队;它更像"至少发生一次"的状态通知。需要可靠计数时用实时信号或带队列/计数语义的 IPC。

15.5 信号处理动作与信号掩码是进程级还是线程级?

处理动作是进程级,所有线程共享;掩码是线程级,每个线程独立。进程定向信号可由内核选择某个未阻塞该信号的线程递达,线程定向信号则只面向指定线程。

15.6 为什么 handler 里不能 printf?

主流程可能在 printf() 内部持锁或修改缓冲区时被打断,handler 再次调用会死锁或破坏状态。只能调用标准明确保证的异步信号安全接口,常见做法是设置 volatile sig_atomic_t 标志或 write() 一个通知。

15.7 volatile sig_atomic_t 能保证什么?

适合处理函数与主流程共享简单标志,避免访问被拆分并让编译器每次重新观察对象。它不提供线程间同步,不保证 ++ 等复合操作原子,也不能把标准信号变成可靠计数事件。

15.8 为什么不能先解除阻塞再 pause?

信号可能在两条操作之间到达并被处理,随后线程才 pause(),造成丢唤醒。sigsuspend() 将临时替换掩码与挂起合并为原子操作。

15.9 为什么 SIGCHLD handler 要循环 waitpid?

一次 SIGCHLD 递达前可能已有多个子进程变成可等待状态,而标准信号不按实例排队。必须循环 waitpid(-1, ..., WNOHANG),直到没有可立即回收的子进程。

15.10 捕获 SIGSEGV 后能不能继续?

通常不能靠直接返回恢复,因为会重新执行故障指令。只有语言运行时、崩溃采集器等特殊组件,借助备用信号栈和受控上下文转移才能做有限处理;普通应用应保留诊断信息并终止。

15.11 SA_RESTART 的作用是什么?

它让一部分被信号处理函数打断的阻塞接口在 handler 返回后自动重启,但覆盖范围不是全部系统调用。程序仍须按目标接口文档处理 EINTR

15.12 信号与中断是什么关系?

硬件中断和 CPU 异常让 CPU 进入内核处理路径;信号是内核面向进程/线程的通知抽象。部分异常可被内核转化为同步故障信号,定时器中断也可推动 alarm 到期,但二者不是一一对应或同层机制。


总结

理解 Linux 信号,关键不是背 1~31 的编号,而是建立一条完整因果链:

text 复制代码
事件发生
  → 信号产生
  → 进入进程共享或线程私有 pending
  → 当前线程 mask 决定能否递达
  → disposition 决定默认 / 忽略 / 捕获
  → 内核构造信号帧并返回用户态 handler
  → trampoline + sigreturn 恢复原上下文

写可靠信号代码时,请抓住六条主线:

  1. 标准信号不按次数排队,不要拿它做可靠计数器;
  2. 阻塞不是忽略,pending、mask 与 disposition 各司其职;
  3. 优先使用 sigaction(),明确处理期间掩码与 flags;
  4. handler 越短越好,只做异步信号安全操作;
  5. 等待使用 sigsuspend() 或同步信号接口,消除丢唤醒窗口;
  6. 多线程与事件循环优先考虑 sigwaitinfo() / signalfd(),把复杂逻辑放回正常控制流。

当你能解释"为什么信号在解除阻塞后才递达""为什么 handler 里打印可能死锁""为什么 SIGCHLD 要循环回收""为什么 handler 返回需要 sigreturn"时,就真正跨过了 Linux 信号从会用到理解机制的门槛。


如果本文对你有帮助,欢迎点赞、收藏。下一篇将继续进入 Linux 线程概念与控制,讨论线程共享哪些进程资源、线程独享哪些执行上下文,以及线程创建、退出、等待与分离的完整生命周期

相关推荐
2603_966437261 小时前
格式化之后数据还能恢复吗?说清恢复条件
linux·运维·数据库
RisunJan1 小时前
Linux命令-traceroute(IP 路由路径追踪)
linux·运维·tcp/ip
芷栀夏1 小时前
极空间部署Sokoban:Docker搭建网页版推箱子并实现远程访问
运维·docker·容器
深圳恒讯1 小时前
乌克兰服务器的网络延迟、机房现状与稳定性情况
运维·服务器·网络
Dream-Y.ocean2 小时前
具身智能落地实战:数字人驱动的全自动化舆情监控系统
运维·自动化
拾光Ծ2 小时前
Linux五种I/O模型与fcntl非阻塞控制
java·linux·服务器·i/o模型
JL152 小时前
Java并发编程面试全攻略-从synchronized到AQS底层原理
java·开发语言·面试·并发编程
梦想的旅途22 小时前
企业微信外部群怎么实现自动化?API接口接入思路
运维·自动化·企业微信