IO多路复用(上):select系统调用

聊到高并发服务,就绕不开IO多路复用。很多新手问我:「老师,为什么不用多线程?每个连接一个线程不就行了?」

我刚开始做网络编程时也这么想。直到有一次,我负责一个在线棋牌游戏的后端,高峰期同时在线3000多人。如果用「一个连接一个线程」的模式,光线程切换就能把CPU吃满。那会儿我才真正意识到——IO多路复用不是可选项,是必选项。

select系统调用:最朴素的IO多路复用

select是IO多路复用的老前辈。它诞生于BSD系统,后来被POSIX标准化。说白了,它的作用就是:让一个进程同时监控多个文件描述符,等其中某个fd就绪了再通知你。

先看它的原型:

#include <sys/select.h>

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

参数看着多,其实不难理解:

参数 含义 我踩过的坑
nfds 最大fd编号 + 1 很多人直接传FD_SETSIZE,其实传实际最大值效率更高
readfds 监控可读事件的fd集合 每次调用前必须重新设置,select会修改这个集合
writefds 监控可写事件的fd集合 别把写事件和「数据已发送」搞混了
exceptfds 监控异常事件的fd集合 实际用得少,但OOB数据必须靠它
timeout 超时时间,NULL表示阻塞等待 传{0, 0}就是轮询,小心CPU跑满

操作fd_set的宏也很关键:

FD_ZERO(fd_set *set);      // 清空集合
FD_SET(int fd, fd_set *set);  // 把fd加入集合
FD_CLR(int fd, fd_set *set);  // 把fd从集合移除
FD_ISSET(int fd, fd_set *set); // 判断fd是否在集合中
⚠️ 重要提醒: select返回后,fd_set会被内核修改——只有就绪的fd还留在集合里。所以每次调用select前,必须重新设置所有要监控的fd。我见过太多人在这上面栽跟头。

select的优缺点:没有银弹

任何技术都有两面性。select用了几十年,它的优缺点我总结一下:

优点

  • 跨平台性好:几乎所有Unix/Linux系统都支持,Windows也有类似实现
  • 接口简单:就几个宏加一个函数,上手快
  • 超时控制精准:timeval结构体可以精确到微秒

缺点

  • fd数量受限:FD_SETSIZE默认1024,改内核参数才能扩大
  • 每次都要拷贝fd_set:从用户态到内核态,来回拷贝1024位,连接多了开销很大
  • 线性扫描效率低:内核不知道哪个fd就绪了,只能遍历所有fd
  • 就绪fd需要遍历查找:select返回后,你得遍历所有fd调用FD_ISSET

我的经验: select适合连接数在几百以内的场景。超过500个连接,我建议直接上epoll。曾经有个项目,用select扛800个连接,CPU占用率一直在30%以上,换成epoll后直接降到5%。

select实战:多客户端聊天室

光说不练假把式。咱们写一个简单的聊天室服务器,用select管理所有客户端连接。

核心逻辑就三步:

  1. 创建监听socket,加入readfds
  2. select等待事件
  3. 处理新连接或客户端消息

先看服务器主循环:

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

#define MAX_CLIENTS 30
#define BUFFER_SIZE 1024

int main() {
    int server_fd, client_fds[MAX_CLIENTS];
    fd_set readfds;
    int max_fd, activity, i, valread;
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    char buffer[BUFFER_SIZE] = {0};

    // 创建监听socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8888);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);

    // 初始化客户端数组
    for (i = 0; i < MAX_CLIENTS; i++) {
        client_fds[i] = 0;
    }

    printf("聊天室服务器启动,端口: 8888\n");

    while (1) {
        // 清空并设置fd集合
        FD_ZERO(&readfds);
        FD_SET(server_fd, &readfds);
        max_fd = server_fd;

        // 把活跃的客户端加入集合
        for (i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_fds[i];
            if (sd > 0) {
                FD_SET(sd, &readfds);
            }
            if (sd > max_fd) {
                max_fd = sd;
            }
        }

        // 调用select,阻塞等待
        activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("select error");
            exit(EXIT_FAILURE);
        }

        // 处理新连接
        if (FD_ISSET(server_fd, &readfds)) {
            int new_socket = accept(server_fd, 
                (struct sockaddr *)&address, 
                (socklen_t*)&addrlen);
            
            printf("新客户端加入: %s:%d\n", 
                inet_ntoa(address.sin_addr), 
                ntohs(address.sin_port));

            // 把新客户端加入数组
            for (i = 0; i < MAX_CLIENTS; i++) {
                if (client_fds[i] == 0) {
                    client_fds[i] = new_socket;
                    break;
                }
            }
        }

        // 处理客户端消息
        for (i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_fds[i];
            if (FD_ISSET(sd, &readfds)) {
                valread = read(sd, buffer, BUFFER_SIZE);
                if (valread == 0) {
                    // 客户端断开
                    getpeername(sd, (struct sockaddr*)&address, 
                        (socklen_t*)&addrlen);
                    printf("客户端断开: %s:%d\n", 
                        inet_ntoa(address.sin_addr), 
                        ntohs(address.sin_port));
                    close(sd);
                    client_fds[i] = 0;
                } else {
                    // 广播消息给所有其他客户端
                    buffer[valread] = '\0';
                    for (int j = 0; j < MAX_CLIENTS; j++) {
                        if (client_fds[j] != 0 && client_fds[j] != sd) {
                            send(client_fds[j], buffer, strlen(buffer), 0);
                        }
                    }
                }
            }
        }
    }

    return 0;
}

💡 小技巧: 实际项目中,我会把客户端数组改成链表或动态数组,避免MAX_CLIENTS写死。另外,广播消息时最好加个换行符,不然客户端收到的消息会粘在一起。

这个聊天室虽然简单,但已经体现了select的核心用法:

  • 一个主循环,管理所有连接
  • 每次循环重新设置fd_set
  • select返回后,遍历所有fd检查就绪状态
  • 新连接和消息处理都在同一个线程里完成

嗯,这里要注意——select返回后,一定要遍历所有fd。你不能假设只有第一个fd就绪了。我曾经犯过这个错,结果客户端发消息时断时续,排查了半天才发现是遍历逻辑写错了。

select多客户端聊天室流程图 服务器主循环 FD_ZERO + FD_SET 所有fd select() 阻塞等待 FD_ISSET 检查就绪fd accept() 新客户端 read() + 广播消息 每次循环重新设置fd_set,select返回后遍历所有fd检查就绪状态

这个流程图把select聊天室的核心逻辑串起来了。你仔细看——每次循环都要重新设置fd_set,这是select最烦人的地方,但也是必须遵守的规则。

核心要点回顾:

  • select用fd_set管理fd,每次调用前必须重新设置
  • 返回后fd_set被修改,只保留就绪的fd
  • 适合连接数几百以内的场景
  • 聊天室实战中,一个主循环搞定所有事情

select虽然老,但理解它对于学习epoll、kqueue这些高级IO多路复用机制非常有帮助。说白了,select就是IO多路复用的「入门课」,把这章吃透了,后面学epoll会轻松很多。