I/O多路复用之select:从原理到实战

说到网络编程,很多新手第一个接触的I/O模型就是select。我记得刚入行那会儿,看到select的代码总觉得有点玄乎——一堆宏、一个位图、还有那个让人摸不着头脑的fd_set。后来在项目中踩过几次坑,才真正理解了它的设计哲学。

今天我们就来彻底搞懂select。我会从函数原型讲起,再到实际编码,最后带你写一个完整的TCP回显服务器。

select函数原型与参数

先看原型,这是最核心的东西:

#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 监听可读事件的集合 可传入NULL,表示不关心可读
writefds 监听可写事件的集合 可传入NULL
exceptfds 监听异常事件的集合 一般传NULL,用得少
timeout 超时时间 NULL表示阻塞等待,0表示立即返回

这里有个坑,我当年就犯过。nfds参数传的是最大文件描述符 + 1,不是文件描述符的总数。比如你监听了fd 3、5、7,那nfds就得传8。为什么?因为select内部要遍历从0到nfds-1的所有fd,传大了浪费性能,传小了漏掉监听。

fd_set操作:位图的秘密

fd_set本质上是一个位图。在Linux内核里,它就是一个long类型的数组。每个bit对应一个文件描述符。比如bit 0对应fd 0,bit 1对应fd 1,以此类推。

操作fd_set有四个宏,记住就行:

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是否在集合中

嗯,这里要注意:select返回后,fd_set会被内核修改。只有那些就绪的fd才会保留在集合中,没就绪的会被清除。所以每次调用select前,都得重新把关心的fd加进去。这个特性让很多人写代码时容易忘,我见过不少线上bug就是这么来的。

避坑指南:select返回后,fd_set会被改写。如果你需要保留原始集合,记得用临时变量拷贝一份。我曾经在项目中因为没注意这个,导致一个连接断开后,select一直返回可读,结果读出来是空数据,白白浪费CPU。

select的优缺点

说实话,select是个老古董了。它诞生于BSD系统,距今快40年了。但老不代表没用,很多嵌入式系统、老旧代码还在用。我们来客观分析一下:

优点

  • 跨平台性好:几乎所有操作系统都支持select
  • 编程模型简单:就几个宏,容易上手
  • 超时控制精准:可以精确到微秒级
  • 同时监听多种事件:读、写、异常一次搞定

缺点

  • 文件描述符数量有限:默认1024,改内核参数可以调大,但本质还是受限
  • 每次都要重新设置fd_set:麻烦,容易出错
  • O(n)的轮询复杂度:不管有多少fd就绪,都要遍历全部
  • fd_set有拷贝开销:每次调用select,都要把fd_set从用户态拷贝到内核态

说白了,select适合连接数不多(几百个以内)的场景。如果你要处理上万连接,还是用epoll吧。但作为学习I/O多路复用的入门,select是绕不开的。

我的建议:新手先搞懂select,再学epoll。因为select的编程思想是基础,理解了select的痛点,你才能明白epoll为什么那样设计。

实战:基于select的TCP回显服务器

光说不练假把式。我们来写一个完整的TCP回显服务器。所谓回显,就是客户端发什么,服务器原样返回什么。

先画个流程图,看看整体逻辑:

select回显服务器流程图 1. 创建socket 2. bind + listen 3. 初始化fd_set 4. while(1) { select(...) } 5. 判断是监听fd还是客户端fd 监听fd → accept 客户端fd → recv/send 循环回到步骤4,继续select

好,现在上代码。我会把关键部分拆开讲:

#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 PORT 8888
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024

int main() {
    int listen_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];
    
    // 1. 创建监听socket
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket");
        exit(1);
    }
    
    // 2. 设置端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    // 3. 绑定地址
    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);
    }
    
    // 4. 开始监听
    if (listen(listen_fd, MAX_CLIENTS) < 0) {
        perror("listen");
        close(listen_fd);
        exit(1);
    }
    
    printf("服务器启动,监听端口 %d\n", PORT);
    
    // 5. 初始化fd_set
    fd_set read_fds, all_fds;
    int max_fd = listen_fd;
    int client_fds[MAX_CLIENTS];
    
    FD_ZERO(&all_fds);
    FD_SET(listen_fd, &all_fds);
    
    for (int i = 0; i < MAX_CLIENTS; i++) {
        client_fds[i] = -1;
    }
    
    // 6. 主循环
    while (1) {
        read_fds = all_fds;  // 每次都要重新拷贝
        
        struct timeval timeout;
        timeout.tv_sec = 5;
        timeout.tv_usec = 0;
        
        int ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
        if (ret < 0) {
            perror("select");
            break;
        } else if (ret == 0) {
            printf("select超时,继续等待...\n");
            continue;
        }
        
        // 7. 检查监听fd是否有新连接
        if (FD_ISSET(listen_fd, &read_fds)) {
            client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
            if (client_fd < 0) {
                perror("accept");
                continue;
            }
            
            printf("新客户端连接: %s:%d\n", 
                   inet_ntoa(client_addr.sin_addr), 
                   ntohs(client_addr.sin_port));
            
            // 把新客户端加入数组
            int i;
            for (i = 0; i < MAX_CLIENTS; i++) {
                if (client_fds[i] == -1) {
                    client_fds[i] = client_fd;
                    break;
                }
            }
            
            if (i == MAX_CLIENTS) {
                printf("客户端数量已达上限,拒绝连接\n");
                close(client_fd);
            } else {
                FD_SET(client_fd, &all_fds);
                if (client_fd > max_fd) {
                    max_fd = client_fd;
                }
            }
        }
        
        // 8. 检查所有客户端fd
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int fd = client_fds[i];
            if (fd == -1) continue;
            
            if (FD_ISSET(fd, &read_fds)) {
                memset(buffer, 0, BUFFER_SIZE);
                int n = recv(fd, buffer, BUFFER_SIZE - 1, 0);
                
                if (n <= 0) {
                    // 客户端断开连接
                    printf("客户端 %d 断开连接\n", fd);
                    close(fd);
                    FD_CLR(fd, &all_fds);
                    client_fds[i] = -1;
                } else {
                    // 回显数据
                    printf("收到数据: %s", buffer);
                    send(fd, buffer, n, 0);
                }
            }
        }
    }
    
    // 清理
    close(listen_fd);
    return 0;
}
编码技巧:我习惯用两个fd_set变量——all_fds保存所有需要监听的fd,read_fds作为select的入参。每次select前把all_fds拷贝到read_fds,这样就不用每次都重新FD_SET了。这个小技巧能减少不少代码量。

代码写完了,我们来跑一下。启动服务器后,用telnet或者nc连接:

# 终端1:启动服务器
$ ./select_echo_server
服务器启动,监听端口 8888

# 终端2:连接客户端
$ nc localhost 8888
hello
hello
world
world

看到没?客户端发什么,服务器就回什么。这就是回显服务器。

代码要点总结

  • 端口复用:SO_REUSEADDR一定要设,否则服务器重启时会报"Address already in use"
  • fd_set拷贝:每次select前都要重新拷贝,因为select会修改原集合
  • 超时处理:select返回0表示超时,可以根据业务需求做心跳检测
  • 客户端管理:用数组保存所有客户端fd,方便遍历和管理
  • 断开检测:recv返回0表示对方关闭连接,记得清理资源
注意:select的fd_set默认大小是1024。如果你要支持更多连接,要么改内核参数FD_SETSIZE,要么换epoll。我个人建议,超过500连接就别用select了,性能会明显下降。

好了,select的内容就讲到这里。代码虽然简单,但麻雀虽小五脏俱全。你把这个例子吃透了,后面学epoll会轻松很多。


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