17. 信号驱动I/O:让内核主动通知你
各位同学,今天我们来聊一个很有意思的I/O模型——信号驱动I/O。说实话,我在做网络编程的头两年,几乎没怎么用过这个模型。直到有一次做一个高并发的数据采集系统,传统的select/poll方式在大量空闲连接下性能堪忧,我才真正体会到信号驱动I/O的价值。
17.1 信号机制回顾
信号,说白了就是软件中断。进程收到信号,就像你正在看书时有人拍你肩膀——你得停下手里的事,去处理这个"打扰"。
Linux下信号有很多种,比如SIGINT(Ctrl+C)、SIGTERM(终止进程)。但今天我们重点关注的是SIGIO,这个信号专门用来通知I/O事件。
核心概念:信号驱动I/O的本质是——你告诉内核:"当这个文件描述符有数据可读时,给我发个SIGIO信号。"然后你就可以去做别的事了,不用傻等。
17.2 SIGIO信号详解
SIGIO信号,也叫I/O可用信号。它的触发条件很简单:
- 文件描述符变得可读
- 文件描述符变得可写
- 文件描述符发生异常
嗯,这里要注意:SIGIO默认行为是终止进程。所以使用前必须设置信号处理函数,否则进程收到信号就挂了。
我曾经踩过的坑:第一次用SIGIO时,忘了设置信号处理函数,结果程序一启动就莫名其妙退出。查了半天才发现是SIGIO默认动作是终止进程。从那以后,我每次用信号前都会先确认默认行为。
17.3 信号驱动I/O模型实现
实现信号驱动I/O,需要几个步骤。我个人习惯按这个顺序来:
- 建立socket并绑定
- 设置socket为信号驱动模式(F_SETFL + O_ASYNC)
- 设置socket的属主进程(F_SETOWN)
- 注册SIGIO信号处理函数
- 进入主循环,等待信号
来看一个完整的例子:
#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>
#define PORT 8888
#define BUFFER_SIZE 1024
int server_fd;
void sigio_handler(int sig) {
char buffer[BUFFER_SIZE];
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
// 注意:信号处理函数中应尽量少做事
// 这里只是演示,实际项目建议用自管道或事件队列
int client_fd = accept(server_fd,
(struct sockaddr*)&client_addr,
&addr_len);
if (client_fd > 0) {
int n = recv(client_fd, buffer, BUFFER_SIZE - 1, 0);
if (n > 0) {
buffer[n] = '\0';
printf("收到数据: %s\n", buffer);
send(client_fd, "OK", 2, 0);
}
close(client_fd);
}
}
int main() {
struct sockaddr_in server_addr;
// 1. 创建socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 3. 监听
listen(server_fd, 5);
// 4. 设置属主进程
fcntl(server_fd, F_SETOWN, getpid());
// 5. 设置为非阻塞 + 信号驱动
int flags = fcntl(server_fd, F_GETFL);
fcntl(server_fd, F_SETFL, flags | O_NONBLOCK | O_ASYNC);
// 6. 注册信号处理函数
signal(SIGIO, sigio_handler);
printf("服务器启动,等待连接...\n");
// 7. 主循环 - 啥也不干,等信号
while (1) {
pause(); // 挂起进程,等待信号
}
close(server_fd);
return 0;
}
我的经验:信号处理函数里尽量别做复杂操作。accept、recv这些函数在信号上下文中调用要特别小心。我一般会在信号处理函数里只写一个标志位,或者往管道写一个字节,然后在主循环里处理实际逻辑。
17.4 信号驱动I/O的优缺点
| 优点 | 缺点 |
|---|---|
| 无需轮询,节省CPU | 信号处理函数限制多 |
| 适合空闲连接多的场景 | 多个socket时难以区分 |
| 实现相对简单 | 信号可能丢失 |
| 实时性较好 | 可移植性差(不同Unix实现不同) |
说白了,信号驱动I/O适合那种"连接很多但大部分时间空闲"的场景。比如物联网设备的心跳服务,成千上万个设备,但每个设备几分钟才发一次数据。用select/poll的话,每次都要遍历所有socket,太浪费了。
17.5 信号驱动I/O的完整流程
下面这张图展示了信号驱动I/O的完整工作流程:
17.6 实际项目中的注意事项
我在实际项目中使用信号驱动I/O时,总结了几条经验:
- 信号处理函数要快——最好只设置标志位,别在里面做accept/recv
- 考虑信号丢失——如果短时间内多个连接到达,可能只收到一个SIGIO
- 配合非阻塞I/O使用——信号处理函数中尽量用非阻塞方式操作
- 多线程下要小心——信号是进程级别的,多线程环境下处理起来更复杂
避坑指南:我曾经在一个项目中用信号驱动I/O处理上千个UDP socket。结果发现高负载下信号丢失严重,导致数据延迟。后来改用epoll才解决问题。所以信号驱动I/O适合低到中等负载的场景,高并发还是得用epoll。
好了,关于信号驱动I/O就讲这么多。这个模型虽然不如epoll那么流行,但在特定场景下确实有它的优势。你想想看,如果你的应用大部分时间都在等数据,用信号驱动I/O能让CPU利用率降到最低。
公众号:蓝海资料掘金营,微信deep3321