刚学网络编程的时候,我写过一个简单的聊天室服务端。每个客户端连进来,我就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多路复用最朴素的思想刻进了每个学网络编程的人的脑子里。
