19、信号驱动I/O:SIGIO信号、fcntl()设置异步I/O

信号驱动I/O,说白了就是让内核在数据准备好的时候,主动“通知”你,而不是你一遍遍去问它。

我刚开始接触网络编程时,用的最多的就是阻塞式I/O。后来做高并发服务,发现阻塞模型撑不住,就试了非阻塞+轮询。但轮询这玩意儿,CPU空转得厉害。直到我翻到了信号驱动I/O,才觉得“嗯,这才是我想要的”。

19.1 什么是信号驱动I/O?

信号驱动I/O的核心思路很简单:

  • 你告诉内核:“这个socket有数据了,给我发个SIGIO信号。”
  • 然后你该干嘛干嘛,不用管它。
  • 数据来了,内核发信号,你在信号处理函数里收数据。

你想想看,这像不像你让前台小哥有快递了喊你一声?你不用一直站在门口等,也不用隔几分钟去问一次。省心省力。

核心流程:

  1. 建立socket,绑定信号处理函数(SIGIO)。
  2. 用fcntl()把socket设为“属主进程”,并开启异步I/O标志。
  3. 进程继续做其他事,内核在数据到达时发送SIGIO信号。
  4. 信号处理函数中调用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 核心流程 1. 创建socket 2. signal(SIGIO, handler) 3. fcntl(F_SETOWN) 4. fcntl(F_SETFL, O_ASYNC) 5. 进程继续运行 数据到达内核 发送SIGIO 6. 信号处理函数 7. recv() 读取数据 内核缓冲区 有数据就绪

从这张图可以看得很清楚:信号驱动I/O的核心,就是把“主动问”变成了“等通知”。内核替你盯着socket,数据来了就发信号。

19.6 实际项目中的取舍

我在做物联网网关项目时,用过一次信号驱动I/O。当时设备数量不多,大概20个UDP连接,数据包每几秒来一次。用epoll有点杀鸡用牛刀,用阻塞又太浪费线程。信号驱动I/O正好合适。

但如果你要处理上千个连接,或者数据包每秒几百上千个,我建议你还是老老实实用epoll。信号驱动I/O在高频场景下,信号丢失和信号处理函数的限制会让你很头疼。

一句话总结: 信号驱动I/O适合“低频、少量、偶发”的I/O场景。它不是一个通用方案,但在特定场景下,它比轮询优雅,比epoll轻量。


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