刚学网络编程的时候,我写过一个简单的聊天室服务端。每个客户端连进来,我就new一个线程。十个用户还行,一百个勉强能撑,一千个同时在线的时候,服务器直接卡成幻灯片。那时候我才意识到:一台机器根本开不了几千个线程。从那以后,我开始认真研究I/O多路复用,而select,就是第一道绕不过去的坎。

阻塞I/O为什么撑不住?

最原始的服务端模型是这样的:socket创建后,调用accept阻塞等待,客户端连上来之后,recv又阻塞等待数据。一个线程只能服务一个连接。为了支持多个客户端,只能多开线程。线程本身要占内存(默认栈空间1-8MB),1000个连接就要吃掉几个GB的内存。加上频繁的上下文切换,CPU大量时间耗在内核调度上,真正处理业务逻辑的时间少得可怜。

这就是阻塞I/O+多线程的窘境:并发能力和系统资源之间的矛盾。

select的解法:一次等待多个fd

select的思路很直接:把所有需要监视的文件描述符(fd)放到一个集合里,一次调用select,让内核帮我们盯着这些fd,任何一个有事件发生就返回。

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

用法大致分三步:

先把要监视的fd加到fd_set集合里,用FD_SET宏。然后调用select,传入读、写、异常三个集合。select返回后,用FD_ISSET遍历集合中的每个fd,检查哪个有事件,再分别处理。

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(listen_fd, &readfds);

while (1) {
    fd_set tmp = readfds;  // select会修改集合,每次要备份
    int ret = select(max_fd + 1, &tmp, NULL, NULL, NULL);
    
    for (int i = 0; i <= max_fd; i++) {
        if (FD_ISSET(i, &tmp)) {
            if (i == listen_fd) {
                // 新连接
                int client_fd = accept(listen_fd, ...);
                FD_SET(client_fd, &readfds);
                max_fd = max(max_fd, client_fd);
            } else {
                // 可读数据
                read(i, buf, sizeof(buf));
                // 处理业务...
            }
        }
    }
}

整个服务端只用一个线程,却能同时处理成百上千个连接。没有线程创建销毁的开销,没有上下文切换的损耗,CPU只花时间在有事件发生的fd上。

select的三个硬伤

理解清楚select的缺陷,才算真正领悟了I/O多路复用。

第一,fd_set是位图,大小固定。在Linux上默认是1024位,意味着最多只能监视1024个fd。虽然可以重新编译内核改大,但治标不治本。

第二,每次调用select,要把整个fd_set从用户态复制到内核态。如果有1000个fd,每次都要复制1000个bit,虽不算大,但select被设计成每次调用都重新复制一遍——毕竟内核要准确知道你在监视哪些fd。fd越多,复制开销越大。

第三,select返回后不知道哪些fd有事件,只能线性遍历整个集合。有1000个fd但只有1个活跃时,还是要跑完999次空循环。复杂度O(n),活跃连接越多效率越低。

这三个问题放到今天依然值得琢磨:位图限制暴露了早期设计的短视,每次全量复制暴露了用户态/内核态边界的高昂成本,而线性遍历暴露了“谁有事件”这件事本该由内核直接告诉用户,而不是让用户自己猜。

select到epoll的演进逻辑

理解了select的硬伤,后面poll和epoll的设计就很好懂了。poll用链表替换了位图,突破了1024限制。epoll更进一步——用红黑树管理fd,用就绪链表直接返回有事件的fd,用mmap共享内存省去复制开销,事件触发从“主动遍历”变成了“被动通知”。

从阻塞到select,是从“一个线程一个连接”到“一个线程所有连接”的跨越;从select到epoll,是从“每次全量复制+线性遍历”到“按需通知+就绪列表”的跨越。select不是终点,但它是一块必经的跳板,把I/O多路复用最朴素的思想刻进了每个学网络编程的人的脑子里。