信号应用: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;
}
这里有三步必须做:
- 用
fcntl(fd, F_SETOWN, getpid())设置文件描述符的属主进程 - 用
fcntl(fd, F_SETFL, flags | O_ASYNC)启用异步 I/O 模式 - 注册 SIGIO 信号的处理函数
我个人的经验是,信号驱动 I/O 在 socket 编程中特别有用。比如写一个聊天服务器,每个客户端连接都用信号驱动,数据来了自动处理,不用轮询。不过要注意,不是所有文件类型都支持信号驱动 I/O。像普通文件就不支持,只有 socket、终端、管道这些才支持。
小技巧: 用 SA_SIGINFO 标志注册信号处理函数时,可以获取到 siginfo_t 结构体,里面包含了触发信号的文件描述符、信号来源等信息。这样你就不用全局变量来传递 fd 了。
知识体系总览
下面这张图把今天讲的内容串起来了,你可以看看各个知识点之间的关系:
这张图把今天讲的四个知识点串起来了。你可以看到,alarm/pause 是最基础的定时方案,setitimer 提供了更高精度的控制,SIGCHLD 解决了子进程管理的痛点,而信号驱动 I/O 则是一种更高效的 I/O 模型。
在实际项目中,这些技术经常组合使用。比如我写过一个网络代理程序,用 setitimer 做连接超时检测,用 SIGCHLD 管理后端 worker 进程,用信号驱动 I/O 处理客户端请求。三个信号机制配合起来,代码既简洁又高效。
好了,今天的内容就到这里。信号的应用远不止这些,但掌握了这几个核心场景,你就能应对大部分系统编程的需求了。