学习目标
- 理解信号是什么、为什么会产生。
- 掌握 signal 与 sigaction 两套 API 的用法及区别。
- 能用 SIGALRM、SIGINT、SIGCHLD 编写完整示例。
- 用 sigaction + SIGCHLD 实现 自动回收子进程(消灭僵尸进程)。
- 掌握常见陷阱(可重入、信号安全函数、并发竞争)并知道如何规避。
1️⃣ 什么是信号(Signal)?
| 名称 | 本质 | 触发时机 | 典型用途 |
|---|---|---|---|
| Signal | 操作系统向进程发送的"软中断"。本质是 异步通知,不携带额外数据,仅用一个整数标识事件。 | - 关键硬件/系统事件(如除‑0、非法指令) - 用户操作(Ctrl‑C、Ctrl‑Z) - 进程内部事件(子进程退出、定时器到期) | - 处理异常(SIGFPE、SIGSEGV) - 实现超时/定时(SIGALRM) - 捕获用户中断(SIGINT) - 回收子进程(SIGCHLD) |
1.1 信号的传播路径(简化图)
┌─────────────────────┐
│ 操作系统内核 │
│ ▲ ▲ │
│ │ 产生 Signal │ │
│ │(定时、子进程、键盘)│
│ ▼ ▼ │
│ 发送 Signal 到目标进程 │
└─────────────────────┘
│
▼
进程上下文(用户空间) ← 由内核执行信号分发
│
▼
调用 **信号处理函数**(Signal Handler)
关键点:
- 异步:信号可以在程序任何位置"突兀"出现。
- 一次性:同一种信号在未被处理前只能累计一次(POSIX 规定)。
2️⃣ 注册信号的两种方式
2.1 signal() ------ 老派、简洁但不够可靠
c
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
- 第1个参数 :信号编号(如
SIGCHLD、SIGALRM)。 - 第2个参数:处理函数的入口地址。
- 返回值:之前注册的处理函数指针(可以保存后恢复)。
适用场景:
- 学习、快速原型。
- 不推荐 在生产代码里使用(不同 UNIX 实现行为差异大)。
2.2 sigaction() ------ 正式、可控、跨平台
c
#include <signal.h>
int sigaction(int signum,
const struct sigaction *act,
struct sigaction *oldact);
- 结构体
sigaction描述了 信号处理器 、信号屏蔽 、标志位 等信息。
c
struct sigaction {
void (*sa_handler)(int); // 处理函数指针或 SIG_IGN / SIG_DFL
void (*sa_sigaction)(int, siginfo_t *, void *); // 备用,使用 SA_SIGINFO 时有效
sigset_t sa_mask; // 处理器执行期间要屏蔽的信号集合
int sa_flags; // 行为标志(SA_RESTART、SA_NOCLDSTOP、...)
void *sa_restorer; // 已废弃,通常设为 NULL
};
- 优点
- 行为一致:POSIX 明确规定,所有主流 Linux/Unix 均实现相同。
- 可选特性 :
SA_RESTART:系统调用被信号打断后自动重启。SA_NOCLDSTOP:子进程 停止 (SIGSTOP)不触发SIGCHLD。SA_NOCLDWAIT:子进程退出后直接被内核回收,不产生僵尸进程。
- 更安全 :可以在
sa_mask中屏蔽掉自己会再次收到的信号,防止递归调用。
结论 :现代代码 均使用
sigaction,本教程后面全部示例都基于它。
3️⃣ 常见信号案例
下面分别演示 SIGALRM(定时器) 、SIGINT(Ctrl‑C) 、SIGCHLD(子进程退出) 的完整流程。每个示例先用 signal(),随后给出等价的 sigaction() 版本。
提示 :所有信号处理函数 必须 满足原型
void handler(int signum),且 只能调用 异步信号安全函数 (如write()、_exit()、signal()、sigaction()),不能使用printf()、malloc()、cout等非安全函数。为演示简洁,示例中暂时使用puts()(实际运行大多数系统仍可工作),但生产环境请改为write(STDOUT_FILENO, ...)。
3.1 示例 1:周期性闹钟 → SIGALRM
3.1.1 用 signal()
c
/* demo_alarm_signal.c */
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void timeout_handler(int sig)
{
if (sig == SIGALRM) {
puts("⌛ Time out! (alarm fired)");
alarm(2); // 再次预约 2 秒后触发
}
}
int main(void)
{
/* 注册信号处理函数 */
signal(SIGALRM, timeout_handler);
alarm(2); // 首次 2 秒后触发
for (int i = 0; i < 3; ++i) {
puts("...sleeping 100s...");
sleep(100); // 被 SIGALRM 中断后会提前返回
}
puts("main finished");
return 0;
}
运行结果(简化)
...sleeping 100s... ⌛ Time out! (alarm fired) ...sleeping 100s... ⌛ Time out! (alarm fired) ...sleeping 100s... ⌛ Time out! (alarm fired) main finished程序只用了约 10 秒 (每 2 秒一次闹钟),而不是 300 秒,因为
SIGALRM会把正在sleep()的进程 唤醒 ,sleep随即返回剩余时间(在信号处理后为 0),循环继续。
3.1.2 用 sigaction()
c
/* demo_alarm_sigaction.c */
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void timeout_handler(int sig)
{
if (sig == SIGALRM) {
write(STDOUT_FILENO, "⌛ Time out! (alarm)\n", 22);
alarm(2); // 继续周期性闹钟
}
}
int main(void)
{
struct sigaction sa;
sa.sa_handler = timeout_handler;
sigemptyset(&sa.sa_mask); // 处理期间不额外屏蔽信号
sa.sa_flags = SA_RESTART; // 让被中断的系统调用自动重启(可选)
sigaction(SIGALRM, &sa, NULL);
alarm(2); // 首次预约
for (int i = 0; i < 3; ++i) {
write(STDOUT_FILENO, "...sleeping 100s...\n", 21);
sleep(100);
}
write(STDOUT_FILENO, "main finished\n", 15);
return 0;
}
关键点:
SA_RESTART让sleep()在被信号中断后 自动重新开始 ,所以即使我们不在处理函数里调用alarm(),sleep仍然会继续完成原来的 100 秒(但本示例仍显示提前返回,因为sleep本身被重新唤醒)。
3.2 示例 2:捕获用户中断 → SIGINT(Ctrl‑C)
c
/* demo_int.c */
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void ctrlc_handler(int sig)
{
if (sig == SIGINT) {
write(STDOUT_FILENO, "⚡ Ctrl+C pressed!\n", 20);
}
}
int main(void)
{
struct sigaction sa = {0};
sa.sa_handler = ctrlc_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 让 read()/sleep() 仍然可以继续
sigaction(SIGINT, &sa, NULL);
for (int i = 0; i < 10; ++i) {
write(STDOUT_FILENO, "Working... (press Ctrl+C)\n", 27);
sleep(1);
}
write(STDOUT_FILENO, "All done.\n", 10);
return 0;
}
运行示例 (按一次
Ctrl+C)
Working... (press Ctrl+C) ⚡ Ctrl+C pressed! Working... (press Ctrl+C) ...
3.3 示例 3:子进程退出 → SIGCHLD(消灭僵尸)
下面先用最常见的 signal() 实现,随后展示更安全的 sigaction 写法。
3.3.1 signal() 版(易产生 race)
c
/* demo_chld_signal.c */
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void child_exit_handler(int sig)
{
int status;
pid_t pid;
/* 采用非阻塞 waitpid,防止一次 SIGCHLD 只回收一个子进程的情况 */
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
if (WIFEXITED(status))
printf("✔ child %d exited, code=%d\n", pid, WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("✘ child %d killed by signal %d\n", pid, WTERMSIG(status));
}
}
int main(void)
{
signal(SIGCHLD, child_exit_handler);
for (int i = 0; i < 3; ++i) {
pid_t pid = fork();
if (pid == 0) { // 子进程
sleep(2 + i);
_exit(100 + i); // 用 _exit 防止 stdio 缓冲冲突
}
}
/* 父进程做点别的事,期间会被 SIGCHLD 打断 */
for (int i = 0; i < 10; ++i) {
printf("parent working %d/10\n", i+1);
sleep(1);
}
return 0;
}
观察 :子进程结束后,父进程立刻打印
✔ child ... exited,不会留下 僵尸进程 (ps -ef | grep Z看不到Z状态的进程)。
缺点:
signal()在不同平台可能自动 重置 为默认处理器,需要再次注册(POSIX 已经不再有此行为,但老系统仍然)。- 处理函数里只能使用 异步安全 的系统调用,
printf在这里不完全安全(但常用于教学)。- 如果一次产生 多个 SIGCHLD ,旧实现可能只会调用一次处理函数,导致 未回收全部子进程 。我们在代码里用
while(waitpid(...,WNOHANG)>0)解决了此问题。
3.3.2 sigaction() 版(推荐)
c
/* demo_chld_sigaction.c */
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <stdlib.h>
void child_reaper(int sig)
{
int saved_errno = errno; // 记录当前 errno 防止被修改
int status;
pid_t pid;
/* 循环收割所有已结束的子进程,防止一次 SIGCHLD 只回收一个 */
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
if (WIFEXITED(status))
printf("✔ child %d exited, code=%d\n", pid, WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("✘ child %d killed by signal %d\n", pid, WTERMSIG(status));
}
errno = saved_errno; // 恢复 errno,保持父进程业务透明
}
/* 一个演示子进程的函数 */
void spawn_child(int n)
{
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(1);
}
if (pid == 0) { // 子进程
printf("[child %d] will sleep %d sec then exit %d\n",
getpid(), n, 100 + n);
sleep(n);
_exit(100 + n);
}
/* 父进程返回继续运行 */
}
int main(void)
{
/* 1️⃣ 注册 SIGCHLD 处理函数 */
struct sigaction sa;
sa.sa_handler = child_reaper;
sigemptyset(&sa.sa_mask); // 处理期间不阻塞其他信号
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
/* SA_NOCLDSTOP:子进程被 SIGSTOP / SIGCONT 不触发 SIGCHLD */
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}
/* 2️⃣ 创建若干子进程 */
for (int i = 1; i <= 3; ++i)
spawn_child(i * 2); // 2s、4s、6s 后退出
/* 3️⃣ 父进程做自己的事(这里用循环打印) */
for (int i = 0; i < 12; ++i) {
printf("parent working %02d/12\n", i+1);
sleep(1);
}
printf("parent finished, all children should be reaped.\n");
return 0;
}
关键点
sigaction的sa_flags常用组合
SA_RESTART:系统调用被信号中断后自动重启,避免sleep/read立刻返回-1/EINTR。SA_NOCLDSTOP:子进程只要 退出 (exit/_exit/_exit)才触发SIGCHLD,防止SIGSTOP/SIGCONT干扰。SA_NOCLDWAIT(不常用)可以让子进程直接 在内核中被回收 ,父进程永远收不到SIGCHLD,但这种方式不让父进程获取子进程的返回码。- 保存 & 恢复
errno是信号安全的好习惯,因为在信号处理函数里系统调用可能会修改它。while(waitpid(...,WNOHANG)>0)能够一次性 收割所有 已结束的子进程,防止 "丢失" 某些退出的子进程。child_reaper不使用printf(理论上不安全),但在教学演示里仍然用它,实际项目请改为write()或日志库的异步安全版。
运行后 使用 ps -ef | grep Z,你不会看到任何 Z(僵尸)进程。
4️⃣ 代码串联:把三种信号放在一起
下面的完整示例展示 如何在同一个进程里同时:
- 使用 闹钟 (
SIGALRM)实现定时任务。 - 捕获 Ctrl‑C (
SIGINT)做优雅退出。 - 通过
SIGCHLD自动回收子进程,防止僵尸。
c
/* demo_all.c - 一个综合演示 */
#define _POSIX_C_SOURCE 200809L // 为了使用 sigaction、pselect 等
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
/* ---------- 1. 信号处理函数 ---------- */
/* SIGALRM:每 5 秒打印一次心跳 */
void alarm_handler(int sig)
{
if (sig != SIGALRM) return;
write(STDOUT_FILENO, "[ALRM] heartbeat\n", 18);
alarm(5); // 重新预约下一个闹钟
}
/* SIGINT:Ctrl‑C 被按下,执行清理后退出 */
volatile sig_atomic_t quit_requested = 0;
void int_handler(int sig)
{
if (sig != SIGINT) return;
write(STDOUT_FILENO, "[INT] Ctrl-C, shutting down...\n", 33);
quit_requested = 1; // 主循环里检测到后安全退出
}
/* SIGCHLD:回收子进程 */
void chld_handler(int sig)
{
(void)sig; // 未使用
int saved_errno = errno;
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
char buf[128];
int len = snprintf(buf, sizeof(buf),
"[CHLD] pid=%d exit=%d\n",
pid, WIFEXITED(status) ? WEXITSTATUS(status) : -1);
write(STDOUT_FILENO, buf, len);
}
errno = saved_errno;
}
/* ---------- 2. 注册所有信号 ---------- */
void setup_signal(void)
{
struct sigaction sa;
/* ---- SIGALRM ---- */
memset(&sa, 0, sizeof(sa));
sa.sa_handler = alarm_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGALRM, &sa, NULL);
alarm(5); // 第一次心跳 5 秒后
/* ---- SIGINT ---- */
memset(&sa, 0, sizeof(sa));
sa.sa_handler = int_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGINT, &sa, NULL);
/* ---- SIGCHLD ---- */
memset(&sa, 0, sizeof(sa));
sa.sa_handler = chld_handler;
sigemptyset(&sa.sa_mask);
/* SA_NOCLDSTOP:子进程停止不产生 SIGCHLD */
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
}
/* ---------- 3. 主业务:创建一些子进程 ---------- */
void spawn_children(void)
{
for (int i = 1; i <= 3; ++i) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(1);
}
if (pid == 0) { // 子进程
char msg[64];
snprintf(msg, sizeof(msg),
"[child %d] I will sleep %d sec then exit %d\n",
getpid(), i*3, 200+i);
write(STDOUT_FILENO, msg, strlen(msg));
sleep(i * 3);
_exit(200 + i);
}
}
}
/* ---------- 4. 主循环 ---------- */
int main(void)
{
setup_signal();
spawn_children();
/* 这里演示父进程仍然可以做自己的工作 */
while (!quit_requested) {
write(STDOUT_FILENO, "parent doing work ...\n", 23);
sleep(2);
}
write(STDOUT_FILENO, "parent exiting now.\n", 21);
/* 正常退出前再等 1 秒,确保尚未回收的子进程能被 SIGCHLD 捕获 */
sleep(1);
return 0;
}
运行要点
- 每 5 秒 打印一次
[ALRM] heartbeat(演示SIGALRM)- 按下 Ctrl‑C 时,立即打印提示并在下一轮循环退出(演示
SIGINT)- 子进程分别在 3、6、9 秒后退出,父进程收到
SIGCHLD并打印[CHLD]信息,没有僵尸
练习:
- 将
alarm(5)改成timer_create/timer_settime(POSIX 定时器)实现更精细的周期。- 把
write换成自定义的 线程安全日志,观察多线程环境下的兼容性。
5️⃣ 进阶:常见陷阱与最佳实践
| 陷阱 | 现象 | 正确做法 |
|---|---|---|
信号处理函数中使用 printf / scanf |
可能导致 死锁 (内部使用 malloc、fcntl,在信号期间被阻塞) |
只使用 异步信号安全函数 (write, _exit, sigaction, sigprocmask, kill, waitpid(带 WNOHANG)) |
忘记 WNOHANG 导致 阻塞 |
waitpid 在 SIGCHLD 处理函数里阻塞,导致父进程卡住 |
必须使用 waitpid(-1, &status, WNOHANG),并循环回收所有子进程 |
| 一次 SIGCHLD 丢失多个子进程 | 子进程几乎同时退出,信号只触发一次,导致剩余子进程变成僵尸 | 在处理函数里 循环 调用 waitpid,直至返回 0 或 -1 |
错误使用 SA_RESTART 影响 read/accept |
期望 read 在收到信号后返回 -1/EINTR,但被自动重启,导致业务层无法感知超时 |
根据需求决定是否使用 SA_RESTART,或在 pselect/poll 中自行处理 EINTR |
| 在信号处理函数里修改全局变量 | 可能出现 数据竞争(非原子写) | 使用 volatile sig_atomic_t 类型的变量,或在处理函数里仅设置标记,主循环再做实际工作 |
忘记 sigemptyset 或错误的 sa_mask |
处理函数期间意外收到同类信号导致递归(堆栈溢出) | 常规做法:sigemptyset(&act.sa_mask);(若需要阻塞其他信号,使用 sigaddset) |
5.1 信号安全函数一览(POSIX.1‑2008)
| 类别 | 函数 | 备注 |
|---|---|---|
| 进程控制 | _exit, _Exit, abort, raise, kill |
|
| 文件 I/O | write, writev, pwrite, pwritev, close, fsync, fdatasync, fchmod, fchown, fstat, fstatat, lseek, read(仅在 POSIX.1‑2008 标记为 async‑safe) |
|
| 信号 | sigaction, sigprocmask, sigpending, sigsuspend, sigwait, kill, raise |
|
| 内存 | malloc/free 不安全 ,请使用 固定缓冲区 或 mmap |
|
| 时间 | alarm, setitimer, timer_*(部分实现安全) |
|
| 其他 | getpid, getppid, getuid, getgid, time, clock_gettime |
小技巧 :如果一定要在信号处理函数里记录日志,最安全的做法是 写入一个环形缓冲区 (使用
volatile sig_atomic_t索引),在主循环里统一把缓冲区内容刷到磁盘或终端。
6️⃣ 小结 & 下一步
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1️⃣ | 了解信号概念:异步、软中断、系统内核向进程发送的通知 | 为后续编程奠定理论基础 |
| 2️⃣ | 掌握 signal() 基本用法 ,尝试 SIGALRM、SIGINT 示例 |
快速上手,但要知道局限 |
| 3️⃣ | 学习 sigaction() :结构体、sa_flags、sa_mask |
编写跨平台、可靠的信号处理代码 |
| 4️⃣ | 实现子进程回收 :SIGCHLD + waitpid(WNOHANG) |
防止僵尸进程,保持系统健康 |
| 5️⃣ | 综合项目:把闹钟、Ctrl‑C、子进程回收整合到同一进程 | 熟练掌握多信号并发处理 |
| 6️⃣ | 实践练习 :写一个守护进程,使用 sigaction + SA_NOCLDWAIT/SA_RESTART 实现日志轮询、热重载等功能 |
迁移到真实业务场景 |
| 7️⃣ | 阅读 POSIX 标准 或 Linux man pages (man 2 signal, man 2 sigaction, man 7 signal) |
深入细节、了解各平台差异 |
推荐阅读
《Advanced Programming in the Unix Environment》(APUE)第 11 章:信号
《Linux Programming Interface》(LPI)第 4 章:进程 & 信号
官方 man 手册:
man 2 sigaction,man 7 signal
实验环境现代 Linux(Ubuntu 22.04、Debian、CentOS 8+)均已完整支持
sigaction。若在 macOS 上编译,
SIGCHLD行为相同,但注意SA_NOCLDWAIT可能有差异。