【Linux开发】 Linux 信号处理——预防僵尸进程

学习目标

  1. 理解信号是什么、为什么会产生。
  2. 掌握 signalsigaction 两套 API 的用法及区别。
  3. 能用 SIGALRM、SIGINT、SIGCHLD 编写完整示例。
  4. sigaction + SIGCHLD 实现 自动回收子进程(消灭僵尸进程)
  5. 掌握常见陷阱(可重入、信号安全函数、并发竞争)并知道如何规避。

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个参数 :信号编号(如 SIGCHLDSIGALRM)。
  • 第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
};
  • 优点
    1. 行为一致:POSIX 明确规定,所有主流 Linux/Unix 均实现相同。
    2. 可选特性
      • SA_RESTART:系统调用被信号打断后自动重启。
      • SA_NOCLDSTOP:子进程 停止 (SIGSTOP)不触发 SIGCHLD
      • SA_NOCLDWAIT:子进程退出后直接被内核回收,不产生僵尸进程。
    3. 更安全 :可以在 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_RESTARTsleep() 在被信号中断后 自动重新开始 ,所以即使我们不在处理函数里调用 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;
}

关键点

  • sigactionsa_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️⃣ 代码串联:把三种信号放在一起

下面的完整示例展示 如何在同一个进程里同时

  1. 使用 闹钟SIGALRM)实现定时任务。
  2. 捕获 Ctrl‑CSIGINT)做优雅退出。
  3. 通过 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;
}

运行要点

  1. 每 5 秒 打印一次 [ALRM] heartbeat(演示 SIGALRM
  2. 按下 Ctrl‑C 时,立即打印提示并在下一轮循环退出(演示 SIGINT
  3. 子进程分别在 3、6、9 秒后退出,父进程收到 SIGCHLD 并打印 [CHLD] 信息,没有僵尸
    练习
  • alarm(5) 改成 timer_create/timer_settime(POSIX 定时器)实现更精细的周期。
  • write 换成自定义的 线程安全日志,观察多线程环境下的兼容性。

5️⃣ 进阶:常见陷阱与最佳实践

陷阱 现象 正确做法
信号处理函数中使用 printf / scanf 可能导致 死锁 (内部使用 mallocfcntl,在信号期间被阻塞) 只使用 异步信号安全函数write, _exit, sigaction, sigprocmask, kill, waitpid(带 WNOHANG))
忘记 WNOHANG 导致 阻塞 waitpidSIGCHLD 处理函数里阻塞,导致父进程卡住 必须使用 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() 基本用法 ,尝试 SIGALRMSIGINT 示例 快速上手,但要知道局限
3️⃣ 学习 sigaction() :结构体、sa_flagssa_mask 编写跨平台、可靠的信号处理代码
4️⃣ 实现子进程回收SIGCHLD + waitpid(WNOHANG) 防止僵尸进程,保持系统健康
5️⃣ 综合项目:把闹钟、Ctrl‑C、子进程回收整合到同一进程 熟练掌握多信号并发处理
6️⃣ 实践练习 :写一个守护进程,使用 sigaction + SA_NOCLDWAIT/SA_RESTART 实现日志轮询、热重载等功能 迁移到真实业务场景
7️⃣ 阅读 POSIX 标准Linux man pagesman 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 可能有差异。

相关推荐
A小辣椒18 小时前
TShark:Wireshark CLI 功能
linux
A小辣椒1 天前
TShark:基础知识
linux
AlfredZhao1 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao2 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334662 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
大树883 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质3 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush43 天前
嵌入式linux学习记录十四、术语
linux·嵌入式