19、信号驱动I/O:SIGIO信号、fcntl()设置异步I/O
信号驱动I/O,说白了就是让内核在数据准备好的时候,主动“通知”你,而不是你一遍遍去问它。
我刚开始接触网络编程时,用的最多的就是阻塞式I/O。后来做高并发服务,发现阻塞模型撑不住,就试了非阻塞+轮询。但轮询这玩意儿,CPU空转得厉害。直到我翻到了信号驱动I/O,才觉得“嗯,这才是我想要的”。
19.1 什么是信号驱动I/O?
信号驱动I/O的核心思路很简单:
- 你告诉内核:“这个socket有数据了,给我发个SIGIO信号。”
- 然后你该干嘛干嘛,不用管它。
- 数据来了,内核发信号,你在信号处理函数里收数据。
你想想看,这像不像你让前台小哥有快递了喊你一声?你不用一直站在门口等,也不用隔几分钟去问一次。省心省力。
核心流程:
- 建立socket,绑定信号处理函数(SIGIO)。
- 用fcntl()把socket设为“属主进程”,并开启异步I/O标志。
- 进程继续做其他事,内核在数据到达时发送SIGIO信号。
- 信号处理函数中调用recv()/read()读取数据。
19.2 fcntl() 设置异步I/O
fcntl()这个函数,我平时用得最多的就是设置非阻塞和文件锁。但它的另一个重要用途,就是配置信号驱动I/O。
要开启信号驱动,需要做两件事:
- 设置属主: 告诉内核,这个socket的信号发给哪个进程。
- 开启异步标志: 让内核知道,这个socket要用信号驱动模式。
代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/socket.h>
#include <netinet/in.h>
int sockfd;
void sigio_handler(int signo) {
char buf[1024];
int n;
// 注意:信号处理函数中尽量少做事
n = recv(sockfd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
buf[n] = '\0';
printf("收到数据: %s\n", buf);
}
}
int main() {
struct sockaddr_in addr;
int flags;
// 1. 创建socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
exit(1);
}
// 2. 绑定端口
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(1);
}
listen(sockfd, 5);
// 3. 注册信号处理函数
signal(SIGIO, sigio_handler);
// 4. 设置属主为当前进程
if (fcntl(sockfd, F_SETOWN, getpid()) < 0) {
perror("F_SETOWN");
exit(1);
}
// 5. 开启异步I/O标志
flags = fcntl(sockfd, F_GETFL);
if (flags < 0) {
perror("F_GETFL");
exit(1);
}
if (fcntl(sockfd, F_SETFL, flags | O_ASYNC) < 0) {
perror("F_SETFL O_ASYNC");
exit(1);
}
printf("等待客户端连接...\n");
// 这里可以accept,也可以做其他事
// 信号驱动I/O通常配合非阻塞使用
while (1) {
pause(); // 等待信号
}
close(sockfd);
return 0;
}
个人经验: 我早期写这个代码时,忘了调用F_SETOWN,结果信号死活收不到。查了半天才发现,内核不知道把信号发给谁。你想想看,快递到了,小哥不知道喊谁,那不就白搭了?
19.3 SIGIO信号处理注意事项
信号处理函数里,有几点要特别小心:
- 不要调用不可重入函数: 比如printf、malloc这些,在信号处理里用很危险。我建议只做最简单的数据接收和标记。
- 信号可能丢失: 如果数据来得太快,信号处理函数还没跑完,下一个信号就来了。标准信号不会排队,会丢。
- 配合非阻塞使用: 信号处理函数里调用recv()时,建议用MSG_DONTWAIT标志,防止阻塞住。
我曾经踩过的坑: 有一次我在信号处理函数里调用了printf,结果程序偶尔会莫名其妙地崩溃。后来才知道,printf内部用了锁,信号处理时主程序可能正拿着锁,就死锁了。从那以后,我信号处理函数里只用write()写日志,或者设一个全局标志位。
19.4 信号驱动I/O的适用场景
说实话,信号驱动I/O在实际项目中用得并不多。为什么?
| 优点 | 缺点 |
|---|---|
| 不需要轮询,节省CPU | 信号可能丢失,不适合高频率数据 |
| 编程模型简单 | 信号处理函数限制多,容易出错 |
| 适合低频率、偶发性的I/O | 多线程环境下信号处理更复杂 |
| 实时性较好 | 可移植性不如select/epoll |
我个人觉得,信号驱动I/O最适合的场景是:
- UDP服务,数据包到达频率不高。
- 监听少量socket,比如一个控制通道。
- 嵌入式环境,资源受限,不想用epoll。
如果是高并发TCP服务,我建议还是用epoll。信号驱动I/O在Linux上虽然能用,但远不如epoll成熟和高效。
19.5 知识体系总览
下面这张图,帮你理清信号驱动I/O的完整流程:
从这张图可以看得很清楚:信号驱动I/O的核心,就是把“主动问”变成了“等通知”。内核替你盯着socket,数据来了就发信号。
19.6 实际项目中的取舍
我在做物联网网关项目时,用过一次信号驱动I/O。当时设备数量不多,大概20个UDP连接,数据包每几秒来一次。用epoll有点杀鸡用牛刀,用阻塞又太浪费线程。信号驱动I/O正好合适。
但如果你要处理上千个连接,或者数据包每秒几百上千个,我建议你还是老老实实用epoll。信号驱动I/O在高频场景下,信号丢失和信号处理函数的限制会让你很头疼。
一句话总结: 信号驱动I/O适合“低频、少量、偶发”的I/O场景。它不是一个通用方案,但在特定场景下,它比轮询优雅,比epoll轻量。
公众号:蓝海资料掘金营,微信deep3321