20、信号驱动I/O:SIGIO信号处理、fcntl设置属主、异步通知机制

信号驱动I/O,说白了就是让内核在数据准备好的时候主动通知你,而不是你一遍遍去问它。我刚开始接触这个概念时,觉得它挺玄乎的——怎么让内核主动来找我?后来做高并发网络服务时,才发现这玩意儿在某些场景下是真香。

什么是信号驱动I/O

传统的I/O模型里,不管是阻塞还是非阻塞,都是应用程序主动去检查数据是否到达。信号驱动I/O则反过来——你告诉内核:「数据来了给我发个信号」,然后你就可以去干别的事了。等SIGIO信号一来,你再处理数据。

这有点像你去餐厅吃饭。阻塞I/O是你站在柜台前等着厨师做好;非阻塞I/O是你每隔几秒跑去问一次「好了没」;而信号驱动I/O是你找个座位坐下,厨师做好了喊你一声。你想想看,哪个更省心?

核心思想: 将I/O事件的检测从「主动轮询」变为「被动通知」,减少不必要的系统调用和CPU空转。

信号驱动I/O的工作流程

实现信号驱动I/O,需要走这么几步:

  1. 建立信号处理函数——处理SIGIO信号
  2. 设置文件描述符属主——告诉内核这个fd的信号发给哪个进程
  3. 开启信号驱动模式——用fcntl设置O_ASYNC标志
  4. 允许接收信号——解除对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);
⚠️ 重要警告: 信号处理函数中不要调用printf、malloc等非异步信号安全的函数。我曾经在信号处理里用了printf,结果程序偶尔崩溃,查了两天才发现是信号处理函数的问题。老老实实用write吧。

完整示例代码

下面是一个完整的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场景:除非你很清楚自己在做什么,否则别用
💡 小技巧: 如果你非要在信号处理函数里做复杂操作,可以用自管道(self-pipe)技巧——信号处理函数往管道写一个字节,主循环从管道读,然后处理实际逻辑。这样就把信号处理函数中的「禁区」操作转移到了主循环中。

知识体系总览

下面这张图展示了信号驱动I/O的核心逻辑和与其他I/O模型的对比:

信号驱动I/O知识体系 核心流程 注册SIGIO处理函数 F_SETOWN设属主 F_SETFL加O_ASYNC 解除SIGIO阻塞 内核数据就绪 → 发送SIGIO → 调用处理函数 四种I/O模型对比 模型 特点 适用场景 缺点 阻塞I/O 进程挂起等待 低并发场景 浪费CPU资源 非阻塞I/O 轮询检查 多路复用场景 系统调用频繁 信号驱动I/O 内核主动通知 UDP/单socket 信号丢失/限制多 epoll 事件驱动回调 高并发TCP Linux only

总结

信号驱动I/O是一种「被动等待」的I/O模型,适合特定场景。它的核心价值在于:让应用程序从轮询中解放出来,把精力放在真正的业务处理上。但受限于信号机制本身的缺陷,它并不是万能的。

如果你在做UDP相关的网络程序,或者只需要监控一个文件描述符,信号驱动I/O是个不错的选择。但如果是高并发TCP服务器,我建议你还是用epoll——那才是真正成熟的解决方案。


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