20、信号驱动I/O:SIGIO信号处理、fcntl设置属主、异步通知机制
信号驱动I/O,说白了就是让内核在数据准备好的时候主动通知你,而不是你一遍遍去问它。我刚开始接触这个概念时,觉得它挺玄乎的——怎么让内核主动来找我?后来做高并发网络服务时,才发现这玩意儿在某些场景下是真香。
什么是信号驱动I/O
传统的I/O模型里,不管是阻塞还是非阻塞,都是应用程序主动去检查数据是否到达。信号驱动I/O则反过来——你告诉内核:「数据来了给我发个信号」,然后你就可以去干别的事了。等SIGIO信号一来,你再处理数据。
这有点像你去餐厅吃饭。阻塞I/O是你站在柜台前等着厨师做好;非阻塞I/O是你每隔几秒跑去问一次「好了没」;而信号驱动I/O是你找个座位坐下,厨师做好了喊你一声。你想想看,哪个更省心?
信号驱动I/O的工作流程
实现信号驱动I/O,需要走这么几步:
- 建立信号处理函数——处理SIGIO信号
- 设置文件描述符属主——告诉内核这个fd的信号发给哪个进程
- 开启信号驱动模式——用fcntl设置O_ASYNC标志
- 允许接收信号——解除对SIGIO的阻塞
嗯,这里要注意:步骤的顺序不能乱。我见过有人先设O_ASYNC再设属主,结果信号收不到,排查了半天。
fcntl设置属主
fcntl的F_SETOWN命令用来设置文件描述符的属主进程。说白了就是告诉内核:「这个fd上的事件,信号发给这个进程ID」。
#include <fcntl.h>
#include <unistd.h>
int fd = socket(AF_INET, SOCK_STREAM, 0);
// 设置当前进程为属主
fcntl(fd, F_SETOWN, getpid());
这里有个坑:对于socket,属主可以是进程也可以是进程组。如果设置成进程组,组内所有进程都会收到信号。我个人习惯用getpid()设置成当前进程,避免信号被多个进程抢着处理。
开启O_ASYNC标志
设置完属主后,用fcntl的F_SETFL命令添加O_ASYNC标志:
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_ASYNC);
这一步才是真正开启信号驱动模式。没有这个标志,内核不会发SIGIO信号。
SIGIO信号处理
信号处理函数需要注册到内核。用sigaction比signal更可靠:
#include <signal.h>
void sigio_handler(int signo) {
// 注意:信号处理函数中只能调用异步信号安全的函数
// 比如 write() 可以,printf() 不行
char msg[] = "Data available!\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}
struct sigaction sa;
sa.sa_handler = sigio_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGIO, &sa, NULL);
完整示例代码
下面是一个完整的TCP服务器,使用信号驱动I/O接收客户端连接:
#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 listen_fd;
void sigio_handler(int signo) {
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int conn_fd;
// 接受连接
conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);
if (conn_fd < 0) {
write(STDERR_FILENO, "accept failed\n", 14);
return;
}
// 读取数据
char buffer[BUFFER_SIZE];
ssize_t n = read(conn_fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
write(STDOUT_FILENO, buffer, n);
write(STDOUT_FILENO, "\n", 1);
}
close(conn_fd);
}
int main() {
struct sockaddr_in server_addr;
// 创建socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
// 设置地址重用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(listen_fd);
exit(1);
}
// 监听
if (listen(listen_fd, 5) < 0) {
perror("listen");
close(listen_fd);
exit(1);
}
printf("Server listening on port %d\n", PORT);
// 注册信号处理函数
struct sigaction sa;
sa.sa_handler = sigio_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGIO, &sa, NULL);
// 设置属主
fcntl(listen_fd, F_SETOWN, getpid());
// 开启信号驱动I/O
int flags = fcntl(listen_fd, F_GETFL, 0);
fcntl(listen_fd, F_SETFL, flags | O_ASYNC);
// 主循环:啥也不干,等信号
while (1) {
pause(); // 挂起进程,等待信号
}
close(listen_fd);
return 0;
}
信号驱动I/O的适用场景
说实话,信号驱动I/O在实际项目中用得并不多。为什么?因为它有几个硬伤:
| 优点 | 缺点 |
|---|---|
| 无需轮询,节省CPU | 信号处理函数限制多,不能调用很多库函数 |
| 实现简单,代码量少 | 高并发下信号可能丢失(SIGIO不支持排队) |
| 适合UDP等数据报协议 | TCP的多个事件(连接、数据、关闭)都用同一个信号,难以区分 |
| 延迟低,响应快 | 信号处理函数中做大量I/O操作容易出问题 |
我个人觉得,信号驱动I/O最适合的场景是UDP服务器。UDP一个数据报就是一个完整消息,收到信号后直接recvfrom就行,逻辑简单。TCP的话,还是用epoll或select更靠谱。
避坑指南
我曾经在一个项目中用信号驱动I/O处理多个socket,结果发现信号处理函数里根本不知道是哪个fd就绪了。后来查资料才知道,SIGIO信号不携带任何额外信息,你需要在信号处理函数里遍历所有fd,用非阻塞方式尝试读写。
这其实就有点尴尬了——本来想省事,结果更麻烦。所以我的建议是:
- 单socket场景:信号驱动I/O挺好用,代码简洁
- 多socket场景:老老实实用epoll或select
- UDP场景:信号驱动I/O是个不错的选择
- TCP场景:除非你很清楚自己在做什么,否则别用
知识体系总览
下面这张图展示了信号驱动I/O的核心逻辑和与其他I/O模型的对比:
总结
信号驱动I/O是一种「被动等待」的I/O模型,适合特定场景。它的核心价值在于:让应用程序从轮询中解放出来,把精力放在真正的业务处理上。但受限于信号机制本身的缺陷,它并不是万能的。
如果你在做UDP相关的网络程序,或者只需要监控一个文件描述符,信号驱动I/O是个不错的选择。但如果是高并发TCP服务器,我建议你还是用epoll——那才是真正成熟的解决方案。