【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 可能有差异。

相关推荐
桌面运维家2 小时前
服务器负载均衡:策略选择与Session一致性保障指南
运维·服务器·负载均衡
dustcell.2 小时前
企业级高可用电商平台实战项目设计
运维·redis·nginx·docker·web·lvs·haproxy
NULL指向我2 小时前
信号处理学习笔记4:动态调整系数的一阶低通滤波
笔记·学习·信号处理
NULL指向我2 小时前
信号处理学习笔记3:限幅 + 中值 + 一阶 RC 三合一
学习·信号处理
CDN3602 小时前
中小站安全加速:360CDN + 高防服务器搭配使用
运维·网络安全
123过去10 小时前
wifi-honey使用教程
linux·网络·测试工具
志栋智能10 小时前
低成本自动化巡检:7×24小时守护业务稳定
运维·网络·自动化
ToB营销学堂11 小时前
MarketUP | B2B 自动化营销实战:如何打破“营-销”数据孤岛,构建高转化线索流?
运维·自动化
Deitymoon12 小时前
linux——孤儿进程和僵尸进程
linux