信号应用:alarm与pause函数、setitimer定时器、SIGCHLD信号处理子进程、信号驱动I/O

各位同学,今天我们来聊聊信号在实际项目中的几个经典应用场景。信号这东西,说白了就是内核给进程发的一个异步通知。你正在干活呢,突然来了个信号,你得停下手头的事去处理它。嗯,这种机制在系统编程里太常用了。

我个人习惯把信号相关的API分成两类:一类是信号的发送和接收,另一类是信号的定时和控制。今天要讲的这几个函数,正好覆盖了这两类场景。

alarm与pause:最朴素的定时等待

alarm() 函数很简单,就是设置一个闹钟。你告诉内核:「n秒后给我发个SIGALRM信号」。然后进程继续干自己的事,等闹钟响了,信号处理函数就会被调用。

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

void handler(int sig) {
    printf("闹钟响了!信号编号:%d\n", sig);
}

int main() {
    signal(SIGALRM, handler);
    alarm(3);  // 3秒后触发SIGALRM
    
    // 这里可以干点别的
    pause();   // 挂起进程,等待信号
    
    printf("进程继续执行\n");
    return 0;
}

这里有个坑,我刚开始用的时候踩过。alarm() 如果之前已经设置了一个闹钟,新调用会覆盖旧的,并且返回上次闹钟剩余的秒数。如果参数传0,那就是取消之前的闹钟。

注意: alarm() 只能精确到秒级。如果你需要毫秒甚至微秒级的定时,就得用 setitimer() 了。

pause() 更简单——让进程休眠,直到收到一个信号。如果信号处理函数返回了,pause() 才返回。我曾经在写一个网络超时重传模块时,就用 alarm + pause 的组合来实现超时等待,代码非常简洁。

setitimer:高精度定时器

说实话,alarm() 在大多数场景下够用了。但如果你需要更精细的控制,比如每隔100毫秒触发一次,那就得上 setitimer()

#include <sys/time.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void timer_handler(int sig) {
    static int count = 0;
    printf("定时器触发 %d 次\n", ++count);
}

int main() {
    struct itimerval timer;
    
    signal(SIGALRM, timer_handler);
    
    // 初始间隔:1秒
    timer.it_value.tv_sec = 1;
    timer.it_value.tv_usec = 0;
    
    // 重复间隔:500毫秒
    timer.it_interval.tv_sec = 0;
    timer.it_interval.tv_usec = 500000;
    
    setitimer(ITIMER_REAL, &timer, NULL);
    
    while(1) {
        pause();
    }
    
    return 0;
}

这个结构体 itimerval 有两个字段:it_value 是第一次触发的时间,it_interval 是后续重复触发的间隔。如果 it_interval 设为0,那就只触发一次,跟 alarm() 效果一样。

定时器类型 触发信号 计时方式
ITIMER_REAL SIGALRM 真实时间(墙上时间)
ITIMER_VIRTUAL SIGVTALRM 进程用户态CPU时间
ITIMER_PROF SIGPROF 进程用户态+内核态CPU时间

我在项目中遇到过一个问题:用 ITIMER_REAL 做心跳检测,结果发现进程在 sleep 的时候定时器也在走。后来换成 ITIMER_VIRTUAL 才解决——因为 sleep 不消耗CPU时间,虚拟定时器就不会触发。

SIGCHLD:优雅地处理子进程退出

写多进程程序时,最头疼的就是子进程退出后的资源回收。你想想看,子进程退出了,内核会保留它的进程描述符,直到父进程调用 wait()waitpid()。如果父进程一直不调用,子进程就成了僵尸进程。

我以前写一个服务器程序,fork 了很多子进程处理请求。一开始我在主循环里轮询 waitpid(),结果发现性能很差。后来改用 SIGCHLD 信号处理,世界清净了。

#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>

void child_handler(int sig) {
    int status;
    pid_t pid;
    
    // 注意:要用 while 循环,一次处理所有退出的子进程
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        printf("子进程 %d 退出,状态:%d\n", pid, WEXITSTATUS(status));
    }
}

int main() {
    struct sigaction sa;
    sa.sa_handler = child_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    
    sigaction(SIGCHLD, &sa, NULL);
    
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程
        printf("子进程运行中...\n");
        _exit(42);
    }
    
    // 父进程继续干自己的事
    pause();
    
    return 0;
}

关键点: 信号处理函数里一定要用 WNOHANG 标志,并且用 while 循环。为什么?因为多个子进程同时退出时,SIGCHLD 信号可能会合并,只触发一次处理函数。如果不用循环,就会漏掉一些子进程,它们就变成僵尸了。

我曾经犯过这个错误,线上服务器跑了一周,发现僵尸进程堆了上千个。排查了半天,才发现是信号处理函数里只调了一次 waitpid()。从那以后,我写 SIGCHLD 处理函数必用 while 循环。

信号驱动I/O:让内核主动通知你

传统的 I/O 模型,要么阻塞等待,要么轮询检查。信号驱动 I/O 提供了一种更优雅的方式:你告诉内核:「这个文件描述符有数据可读时,给我发个信号」。然后你就可以去干别的事了。

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

int fd;

void io_handler(int sig, siginfo_t *info, void *context) {
    char buf[1024];
    int n = read(fd, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        printf("收到数据:%s", buf);
    }
}

int main() {
    struct sigaction sa;
    
    fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
    
    // 设置信号处理函数,使用 siginfo_t 获取更多信息
    sa.sa_sigaction = io_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_SIGINFO;
    sigaction(SIGIO, &sa, NULL);
    
    // 设置文件描述符的属主进程
    fcntl(fd, F_SETOWN, getpid());
    
    // 启用信号驱动 I/O
    int flags = fcntl(fd, F_GETFL);
    fcntl(fd, F_SETFL, flags | O_ASYNC);
    
    printf("等待输入...\n");
    pause();
    
    close(fd);
    return 0;
}

这里有三步必须做:

  1. fcntl(fd, F_SETOWN, getpid()) 设置文件描述符的属主进程
  2. fcntl(fd, F_SETFL, flags | O_ASYNC) 启用异步 I/O 模式
  3. 注册 SIGIO 信号的处理函数

我个人的经验是,信号驱动 I/O 在 socket 编程中特别有用。比如写一个聊天服务器,每个客户端连接都用信号驱动,数据来了自动处理,不用轮询。不过要注意,不是所有文件类型都支持信号驱动 I/O。像普通文件就不支持,只有 socket、终端、管道这些才支持。

小技巧:SA_SIGINFO 标志注册信号处理函数时,可以获取到 siginfo_t 结构体,里面包含了触发信号的文件描述符、信号来源等信息。这样你就不用全局变量来传递 fd 了。

知识体系总览

下面这张图把今天讲的内容串起来了,你可以看看各个知识点之间的关系:

信号应用知识体系 信号应用 alarm + pause setitimer 定时器 SIGCHLD 处理 信号驱动 I/O 秒级定时 进程挂起 微秒级精度 重复定时 避免僵尸 异步回收 F_SETOWN O_ASYNC

这张图把今天讲的四个知识点串起来了。你可以看到,alarm/pause 是最基础的定时方案,setitimer 提供了更高精度的控制,SIGCHLD 解决了子进程管理的痛点,而信号驱动 I/O 则是一种更高效的 I/O 模型。

在实际项目中,这些技术经常组合使用。比如我写过一个网络代理程序,用 setitimer 做连接超时检测,用 SIGCHLD 管理后端 worker 进程,用信号驱动 I/O 处理客户端请求。三个信号机制配合起来,代码既简洁又高效。

好了,今天的内容就到这里。信号的应用远不止这些,但掌握了这几个核心场景,你就能应对大部分系统编程的需求了。

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