IO多路复用基础:从阻塞到非阻塞,再到select的实战之路

大家好,我是你们的老朋友。今天咱们来聊聊IO多路复用。说实话,这玩意儿刚接触的时候我也觉得挺绕的,但搞懂了之后,你会发现它其实就是个“管家”——帮你同时盯着多个IO通道,哪个有动静就处理哪个。

一、IO多路复用的概念

先说说什么是IO多路复用。说白了,就是一个进程能同时监控多个文件描述符(比如socket、管道、文件等),当其中某个描述符就绪(可读、可写或发生异常)时,就通知你去处理。这样你就不用为每个连接都开一个线程或进程了。

我刚开始做嵌入式开发时,遇到一个需要同时处理多个串口数据的项目。那时候我傻乎乎地给每个串口开了一个线程,结果系统资源吃紧,还经常出问题。后来用了select,一个线程搞定所有串口,省心多了。

核心思想: 一个进程/线程同时监控多个IO通道,避免为每个通道创建独立的进程/线程。

二、阻塞IO与非阻塞IO

要理解IO多路复用,得先搞清楚阻塞和非阻塞的区别。

阻塞IO

你调用read()读数据,如果数据没准备好,进程就挂在那等。就像你去取快递,快递没到,你就一直站在快递柜前不走。这效率太低了,尤其是多个IO通道时。

非阻塞IO

你调用read(),不管数据有没有,都立即返回。如果没数据,返回-1并设置errno为EAGAIN。这样你可以轮询,但轮询太浪费CPU了——你想想看,不停地问“有数据吗?有数据吗?”,CPU全耗在这上面了。

嗯,这里要注意:非阻塞IO虽然不阻塞进程,但轮询的开销很大。我有个同事曾经用非阻塞IO轮询了100个socket,结果CPU占用率直接飙到90%以上。后来改成select,CPU占用率降到了5%。

IO多路复用的定位

IO多路复用就是介于阻塞和非阻塞之间的一种方案。它让你可以“同时等”多个IO通道,哪个就绪处理哪个。既不会像阻塞那样死等一个通道,也不会像非阻塞那样疯狂轮询。

三、IO多路复用的应用场景

哪些场景适合用IO多路复用?我总结了几种常见情况:

  • 网络服务器: 同时处理大量客户端连接,比如Web服务器、聊天服务器。
  • 嵌入式设备: 同时监控多个传感器、串口、网络接口。
  • GUI程序: 同时处理鼠标、键盘、网络事件。
  • 多协议处理: 同时监听TCP和UDP端口。

我在做智能家居网关时,就用了select同时处理WiFi连接、蓝牙数据和本地按键输入。一个循环搞定所有事情,代码结构清晰,维护起来也方便。

四、select函数的使用

select是IO多路复用中最经典的函数。它的原型长这样:

#include <sys/select.h>
#include <sys/time.h>

int select(int nfds, 
           fd_set *readfds, 
           fd_set *writefds, 
           fd_set *exceptfds, 
           struct timeval *timeout);

参数说明:

  • nfds: 最大文件描述符值+1。别搞错了,是最大值+1,不是数量。
  • readfds: 要监控可读的文件描述符集合。
  • writefds: 要监控可写的文件描述符集合。
  • exceptfds: 要监控异常的文件描述符集合。
  • timeout: 超时时间。NULL表示一直等,直到有就绪;{0,0}表示立即返回(轮询模式)。

返回值:成功返回就绪的文件描述符数量,超时返回0,出错返回-1。

小技巧: 我习惯把nfds设置为所有监控描述符的最大值+1,这样select只需要检查0到nfds-1范围内的描述符,效率更高。

五、select的优缺点

优点 缺点
跨平台性好,几乎所有Unix/Linux系统都支持 监控的描述符数量有限制(通常1024个)
使用简单,API直观 每次调用都要把fd_set从用户态拷贝到内核态,开销大
超时控制灵活 返回后需要遍历所有描述符才能知道哪些就绪
支持三种事件(读、写、异常) fd_set是位图,操作起来不够直观

我曾经在一个项目中用select监控了800多个连接,性能还行。但如果你要监控几千上万个连接,那还是考虑epoll吧。select在小规模场景下完全够用。

六、select的fd_set操作

fd_set是一个位图,每个bit对应一个文件描述符。操作它要用专门的宏:

void FD_ZERO(fd_set *set);        // 清空集合
void FD_SET(int fd, fd_set *set); // 将fd加入集合
void FD_CLR(int fd, fd_set *set); // 将fd从集合中移除
int  FD_ISSET(int fd, fd_set *set); // 检查fd是否在集合中

使用示例:

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
FD_SET(pipefd, &readfds);

// 调用select后,用FD_ISSET检查哪些描述符就绪
if (FD_ISSET(sockfd, &readfds)) {
    // 处理sockfd
}
if (FD_ISSET(pipefd, &readfds)) {
    // 处理pipefd
}

注意: select返回后,fd_set会被修改,只保留就绪的描述符。所以每次调用select前都要重新设置fd_set。我曾经因为这个bug排查了一整天,切记!

七、select的超时机制

timeout参数是一个struct timeval:

struct timeval {
    long tv_sec;  // 秒
    long tv_usec; // 微秒
};

三种超时模式:

  • 永久等待: timeout = NULL,直到有描述符就绪才返回。
  • 固定超时: 设置tv_sec和tv_usec,超时后返回0。
  • 立即返回: tv_sec = 0, tv_usec = 0,相当于非阻塞轮询。

我一般用固定超时,比如设置100ms的超时,这样既能及时响应,又不会让CPU空转。在嵌入式设备上,这个策略特别实用。

八、select综合案例

下面是一个完整的例子,同时监控标准输入和网络socket:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];
    
    // 创建socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket");
        exit(1);
    }
    
    // 绑定端口
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    if (bind(server_fd, (struct sockaddr*)&server_addr, 
             sizeof(server_addr)) < 0) {
        perror("bind");
        exit(1);
    }
    
    // 监听
    listen(server_fd, 5);
    printf("Server listening on port %d\n", PORT);
    
    fd_set readfds;
    int max_fd;
    
    while (1) {
        // 每次都要重新设置fd_set
        FD_ZERO(&readfds);
        FD_SET(server_fd, &readfds);
        FD_SET(STDIN_FILENO, &readfds);
        
        max_fd = (server_fd > STDIN_FILENO) ? server_fd : STDIN_FILENO;
        
        // 设置5秒超时
        struct timeval timeout;
        timeout.tv_sec = 5;
        timeout.tv_usec = 0;
        
        int ret = select(max_fd + 1, &readfds, NULL, NULL, &timeout);
        
        if (ret < 0) {
            perror("select");
            break;
        } else if (ret == 0) {
            printf("Timeout... no activity for 5 seconds\n");
            continue;
        }
        
        // 检查标准输入
        if (FD_ISSET(STDIN_FILENO, &readfds)) {
            fgets(buffer, BUFFER_SIZE, stdin);
            printf("Input from stdin: %s", buffer);
        }
        
        // 检查新连接
        if (FD_ISSET(server_fd, &readfds)) {
            client_fd = accept(server_fd, 
                              (struct sockaddr*)&client_addr, 
                              &addr_len);
            if (client_fd >= 0) {
                printf("New connection from %s:%d\n",
                       inet_ntoa(client_addr.sin_addr),
                       ntohs(client_addr.sin_port));
                close(client_fd);
            }
        }
    }
    
    close(server_fd);
    return 0;
}

这个例子展示了select的基本用法:同时监控标准输入和网络连接。你可以在此基础上扩展,监控更多的描述符。

九、知识体系图

下面这张图总结了本章的核心内容:

IO多路复用知识体系 IO多路复用 核心概念 阻塞IO vs 非阻塞IO 应用场景 select函数使用 优缺点分析 fd_set操作 超时机制 综合案例 核心:一个进程同时监控多个IO通道

十、避坑指南

最后,分享几个我踩过的坑:

  • 忘记重新设置fd_set: select返回后fd_set会被修改,下次调用前必须重新设置。我曾经因为这个bug排查了一整天。
  • nfds设置错误: 一定要是最大描述符值+1,不是描述符数量。设小了select会忽略后面的描述符。
  • 超时时间被修改: 在某些系统上,select返回后timeout的值会被修改。所以每次调用前都要重新设置。
  • 信号中断: select可能会被信号中断,返回-1并设置errno为EINTR。需要判断并重新调用。

好了,关于IO多路复用和select的基础知识就讲到这里。记住,select虽然老,但在小规模场景下依然好用。搞懂了select,后面学poll和epoll会轻松很多。


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