资讯动态

C++ select IO多路复用实战:从阻塞式服务端到多客户端并发

发布时间:2026/10/21 14:05:01 来源:尧图企业网站定制
1. 从一个阻塞式服务端说起为什么需要select很多刚接触C网络编程的朋友第一个能跑通的程序大概率是阻塞式的一问一答服务端主线程调用accept等客户端连上来连上之后调用recv等数据处理完再回去等下一个连接。这个模型写起来确实简单代码不超过五十行就能跑但它有个致命问题——同一时刻只能服务一个客户端。第一个客户端连上之后如果迟迟不发数据整个服务端就卡在那里第二个客户端连都连不进来。我在早期做内部工具的时候就被这个模型坑过。当时写了个小服务端给几个同事传文件结果一个人连上之后去泡了杯咖啡其他人全在那边干等。后来才意识到阻塞式模型在真实场景里基本没法用必须让服务端能同时盯着多个连接。解决这个问题的思路有几条多进程、多线程、IO多路复用。多进程太重每个连接fork一个进程资源开销大不说进程间通信也麻烦多线程稍微好一点但连接数一上来线程切换的成本也很可观而且共享数据的同步是个大坑。IO多路复用则是另一条路——用单个线程同时监视多个文件描述符哪个有事件就去处理哪个这就是select、poll、epoll这一系列机制的核心思想。select是这里面最古老、最通用、也最容易上手的一个。它几乎在所有主流操作系统上都能用Windows下有Winsock版本的selectLinux下就是标准的系统调用。虽然在高并发场景下它的性能不如epoll但对于入门理解IO多路复用这件事来说select是最合适的起点。你把它搞明白了后面再看poll和epoll基本就是换个API的事情底层思路完全一致。这篇文章就围绕一个用select实现的简单服务端程序展开把整体设计思路、核心API的用法、完整的实操过程、以及我踩过的那些坑都讲清楚。适合已经会写基本C、了解socket概念、但还没接触过IO多路复用的朋友。看完之后你应该能自己写出一个能同时处理多个客户端连接的服务端并且理解它背后的运行机制。2. 整体设计思路与select的核心机制2.1 为什么选select而不是多线程在动手写代码之前先把方案选型的逻辑说清楚。假设我们要写一个服务端能同时接收多个客户端连接每个客户端发来的消息都要能收到并做简单处理。可选方案有这么几种方案优点缺点适用场景阻塞式单连接代码极简只能服务一个客户端学习demo多进程隔离性好资源开销大通信复杂连接数少且需要隔离多线程编程模型直观线程切换开销同步复杂中等连接数select多路复用单线程管理多连接跨平台有文件描述符数量上限每次要重新设置集合入门学习、连接数不多epoll高并发性能好仅LinuxAPI稍复杂高并发生产环境选select的理由很直接它是理解IO多路复用概念的最佳入口。你不需要考虑线程同步、锁、竞态条件这些让人头大的问题只需要专注在怎么同时监视多个fd这一件事上。而且select的API设计非常直白——你告诉它我关心哪些fd的哪些事件它就阻塞在那里直到有事件发生或者超时然后告诉你这些fd有动静了。这里有个关键概念需要先建立起来文件描述符集合。在Linux里一切皆文件socket连接也是一个文件描述符fd。select的工作方式就是维护几个fd集合分别表示我要读这些fd、我要写这些fd、我要监视这些fd的异常。每次调用select内核会检查这些集合里的fd有没有就绪的如果有就返回没有就阻塞等待。2.2 select的函数原型与参数含义先把select的函数原型摆出来#include sys/select.h int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);五个参数逐个拆解nfds需要监视的最大文件描述符值加一。注意是最大值加一不是fd的个数。比如你监视的fd是3、5、7那这个参数要填8。这个设计初看有点反直觉但理解之后就习惯了——内核需要遍历从0到nfds-1的所有fd所以必须告诉它上界在哪。readfds读事件集合。你往里面放fd表示这些fd有数据可读的时候通知我。对于服务端的监听socket来说有可读事件意味着有新连接进来了对于已连接的客户端socket来说有可读事件意味着对方发数据了或者断开了。writefds写事件集合。表示这些fd可写的时候通知我。一般来说socket刚创建的时候都是可写的所以这个集合用得相对少除非你要做非阻塞发送且发送缓冲区满了需要等。exceptfds异常事件集合。这个用得最少一般传NULL。timeout超时时间。传NULL表示永久阻塞直到有事件传一个timeval结构表示最多等这么久传一个全零的timeval表示立即返回不阻塞轮询模式。返回值方面大于0表示就绪的fd总数等于0表示超时小于0表示出错比如被信号中断。2.3 fd_set的操作宏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用法的关键。每次调用select之前你都要重新用FD_ZERO和FD_SET构建集合因为select返回后会把没有就绪的fd从集合里清掉。这一点是新手最容易踩的坑后面会详细说。2.4 服务端的整体流程设计把上面的机制串起来服务端的整体流程是这样的创建监听socket绑定端口开始监听。把监听socket加入读集合。进入主循环每次循环重新构建读集合因为上一轮select可能修改了它。调用select等待事件。如果监听socket可读说明有新连接调用accept接受连接把新socket加入读集合。如果某个客户端socket可读调用recv读取数据处理如果返回0说明对方关闭了连接从集合中移除并关闭socket。循环往复。这个流程看起来简单但每一步都有细节需要注意。比如accept之后新socket要不要设非阻塞recv返回0和返回-1分别代表什么集合的最大fd怎么维护这些都会在实操部分展开。3. 核心API细节与实操要点3.1 监听socket的创建与配置先看监听socket的创建。这部分和普通的阻塞式服务端没有区别int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return -1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(8888); if (bind(listen_fd, (struct sockaddr*)addr, sizeof(addr)) 0) { perror(bind); close(listen_fd); return -1; } if (listen(listen_fd, 128) 0) { perror(listen); close(listen_fd); return -1; }这里有几个点值得说明。SO_REUSEADDR这个选项建议一定要设它的作用是允许服务端重启时立即复用之前用过的端口。如果不设服务端关闭后端口会进入TIME_WAIT状态大概要等一两分钟才能重新绑定调试的时候会非常烦。listen的第二个参数是backlog表示已完成连接队列的最大长度设128是个比较稳妥的值设太小在高并发下会丢连接。注意listen_fd本身不需要设非阻塞。虽然accept在阻塞模式下如果没有新连接会卡住但在select模型里我们只在select通知监听socket可读的时候才调用accept所以它不会阻塞。3.2 客户端socket的管理用数组还是用vector服务端需要维护所有已连接的客户端socket。最直接的方式是用一个数组#define MAX_CLIENTS 1024 int client_fds[MAX_CLIENTS]; int client_count 0;但数组管理起来比较麻烦删除元素要移动后面的元素。用std::vectorint会方便很多std::vectorint client_fds;不过这里有个性能上的考量select每次都要遍历所有fd来构建集合如果客户端数量多遍历vector的开销也不小。但对于入门demo来说这点开销完全可以忽略。真正生产环境用epoll的话根本不需要自己维护这个列表内核会帮你管理。我个人的习惯是用std::vectorint配合std::find和erase来增删。代码清晰不容易出错。3.3 最大文件描述符的维护前面说过select的第一个参数是最大fd值加一。这个值必须准确否则可能漏掉某些fd的事件。维护方式很简单每次往集合里加fd的时候如果这个fd比当前记录的最大值还大就更新最大值。int max_fd listen_fd; // 初始时只有监听socket // 添加新客户端时 client_fds.push_back(new_fd); if (new_fd max_fd) { max_fd new_fd; }但这里有个坑当最大fd对应的客户端断开连接后max_fd不会自动变小。如果你不重新计算select会继续遍历一个已经不存在的大范围虽然不会出错但会浪费一点性能。更严重的是如果max_fd对应的fd被关闭后又被系统分配给新的socket可能会出问题。稳妥的做法是每次循环都重新计算max_fdmax_fd listen_fd; for (int fd : client_fds) { if (fd max_fd) max_fd fd; }这样虽然多了一次遍历但保证了正确性。对于入门demo来说正确性比那点性能重要得多。3.4 每次循环都要重建fd_set这是select使用中最容易出错的地方我见过不止一个新手在这里翻车。select返回后readfds集合里只剩下那些就绪的fd没就绪的会被清掉。所以你不能把fd_set定义在循环外面然后一直用必须在每次调用select之前重新构建。正确的做法是定义两个fd_set一个作为模板保存所有要监视的fd另一个作为每次调用的副本fd_set master_set, read_set; FD_ZERO(master_set); FD_SET(listen_fd, master_set); while (true) { read_set master_set; // 每次从模板复制 int ret select(max_fd 1, read_set, NULL, NULL, NULL); // ... }这样master_set始终保持完整read_set每次都是新的副本。这个模式在select编程里非常经典建议直接记住。提示fd_set的赋值操作read_set master_set在C里是可以的因为fd_set是个结构体支持默认的拷贝赋值。但在某些平台上fd_set可能被定义为数组那就不能用赋值得用memcpy。为了跨平台兼容用memcpy(read_set, master_set, sizeof(fd_set))更保险。3.5 accept新连接的处理当select返回且FD_ISSET(listen_fd, read_set)为真时说明有新连接。调用accept接受struct sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); int new_fd accept(listen_fd, (struct sockaddr*)client_addr, addr_len); if (new_fd 0) { perror(accept); continue; // 不要退出继续处理其他连接 } // 把新连接加入管理列表和master_set client_fds.push_back(new_fd); FD_SET(new_fd, master_set); if (new_fd max_fd) max_fd new_fd; // 打印客户端信息 char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, ip, sizeof(ip)); printf(New client from %s:%d, fd%d\n, ip, ntohs(client_addr.sin_port), new_fd);这里有个细节accept失败的时候不要直接return或break应该continue。因为accept失败可能只是这一个连接的问题比如客户端在三次握手后立刻断开不影响其他连接的处理。3.6 recv读取数据与断开检测对于客户端socket的可读事件处理逻辑要区分几种情况char buf[1024]; ssize_t n recv(fd, buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; printf(Received from fd %d: %s\n, fd, buf); // 这里可以做业务处理比如回显 send(fd, buf, n, 0); } else if (n 0) { // 对方关闭了连接 printf(Client fd %d disconnected\n, fd); close(fd); FD_CLR(fd, master_set); // 从client_fds中移除 client_fds.erase(std::remove(client_fds.begin(), client_fds.end(), fd), client_fds.end()); } else { // n 0出错了 if (errno EAGAIN || errno EWOULDBLOCK) { // 非阻塞模式下没有数据了正常情况 } else { perror(recv); close(fd); FD_CLR(fd, master_set); client_fds.erase(std::remove(client_fds.begin(), client_fds.end(), fd), client_fds.end()); } }recv返回0是对方正常关闭连接的标志这个必须处理否则会一直收到可读事件但读不到数据陷入死循环。返回-1时要看errno如果是EAGAIN或EWOULDBLOCK说明是非阻塞socket暂时没数据不是错误其他情况才是真正的错误。注意如果socket是阻塞模式recv在select通知可读之后调用一般不会阻塞。但如果对方发了一半数据然后暂停recv可能会阻塞等待剩余数据。所以更稳妥的做法是把客户端socket设为非阻塞这样recv最多读到当前可用的数据就返回。4. 完整实操过程与代码实现4.1 环境准备与编译命令先确认环境。Linux下需要g和基本的开发库一般系统都自带了。检查一下g --version如果没装用包管理器装一下就行。代码保存为select_server.cpp编译命令g -stdc11 -O2 -o select_server select_server.cpp-stdc11是因为我用了std::vector和std::remove这些C标准库的东西。-O2开个基本优化虽然对demo来说无所谓。4.2 完整代码实现把前面所有的片段拼起来完整的服务端代码#include stdio.h #include stdlib.h #include string.h #include errno.h #include unistd.h #include arpa/inet.h #include sys/socket.h #include sys/select.h #include netinet/in.h #include vector #include algorithm #define PORT 8888 #define MAX_BUF 1024 int main() { // 1. 创建监听socket int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return -1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(listen_fd, (struct sockaddr*)addr, sizeof(addr)) 0) { perror(bind); close(listen_fd); return -1; } if (listen(listen_fd, 128) 0) { perror(listen); close(listen_fd); return -1; } printf(Server listening on port %d, listen_fd%d\n, PORT, listen_fd); // 2. 初始化fd_set fd_set master_set, read_set; FD_ZERO(master_set); FD_SET(listen_fd, master_set); int max_fd listen_fd; std::vectorint client_fds; // 3. 主循环 while (true) { read_set master_set; int ret select(max_fd 1, read_set, NULL, NULL, NULL); if (ret 0) { if (errno EINTR) continue; // 被信号中断重试 perror(select); break; } // 4. 检查监听socket if (FD_ISSET(listen_fd, read_set)) { struct sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); int new_fd accept(listen_fd, (struct sockaddr*)client_addr, addr_len); if (new_fd 0) { perror(accept); } else { client_fds.push_back(new_fd); FD_SET(new_fd, master_set); if (new_fd max_fd) max_fd new_fd; char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, ip, sizeof(ip)); printf(New client from %s:%d, fd%d\n, ip, ntohs(client_addr.sin_port), new_fd); } } // 5. 检查客户端socket for (auto it client_fds.begin(); it ! client_fds.end(); ) { int fd *it; if (FD_ISSET(fd, read_set)) { char buf[MAX_BUF]; ssize_t n recv(fd, buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; printf(Received from fd %d: %s\n, fd, buf); send(fd, buf, n, 0); // 回显 it; } else if (n 0) { printf(Client fd %d disconnected\n, fd); close(fd); FD_CLR(fd, master_set); it client_fds.erase(it); } else { if (errno EAGAIN || errno EWOULDBLOCK) { it; } else { perror(recv); close(fd); FD_CLR(fd, master_set); it client_fds.erase(it); } } } else { it; } } } // 清理 for (int fd : client_fds) close(fd); close(listen_fd); return 0; }4.3 测试方法与验证步骤编译运行之后开几个终端用nc或者telnet来测试# 终端1启动服务端 ./select_server # 终端2连接 nc 127.0.0.1 8888 # 终端3再连接一个 nc 127.0.0.1 8888在终端2里输入一些文字回车服务端会打印收到的内容并回显终端2能看到自己发的内容被回显回来。同时在终端3里操作两个连接互不影响。这就验证了服务端能同时处理多个客户端。再测试断开在终端2里按CtrlC或CtrlD关闭连接服务端会打印Client fd X disconnected然后终端3继续操作不受影响。4.4 关键参数的选择与计算代码里有几个参数值得说一下选择依据MAX_BUF设为1024这是单次recv读取的最大字节数。设太小会导致大消息被截断设太大浪费栈空间。1024对于文本消息来说足够了。如果要传大文件应该用循环读取的方式而不是一味加大缓冲区。listen的backlog设为128。这个值表示内核已完成连接队列的长度。如果服务端处理accept的速度跟不上客户端连接的速度队列会满新的连接会被拒绝或忽略。128对于demo来说绰绰有余生产环境可能需要根据并发量调整。select的timeout传NULL表示永久阻塞。这意味着如果没有事件主循环会一直卡在select调用上CPU占用为0。这是最高效的方式。如果你需要定期做一些后台任务比如心跳检测可以传一个非NULL的timeval比如1秒超时这样每秒都会醒一次。5. 常见问题与排查技巧实录5.1 select返回-1且errno为EINTR这是最常见的问题之一。当程序收到信号比如CtrlC产生的SIGINT或者子进程退出产生的SIGCHLD时select会被中断返回-1并设置errno为EINTR。这不是真正的错误应该重新调用select。处理方式很简单if (ret 0) { if (errno EINTR) continue; perror(select); break; }我见过有人的代码里没处理EINTR结果程序偶尔莫名其妙退出排查半天才发现是信号中断导致的。5.2 客户端断开后没有从集合中移除如果recv返回0对方关闭连接后你只close(fd)但没有FD_CLR那么master_set里还留着这个fd。下次select会继续监视它而它已经是一个无效的fd可能导致select返回错误或者更糟——这个fd被系统分配给新的socket后select会错误地报告它可读。所以断开连接时一定要做三件事close(fd)、FD_CLR(fd, master_set)、从client_fds中移除。缺一不可。5.3 遍历client_fds时删除元素的陷阱在遍历client_fds的过程中删除元素如果用普通的for循环加索引很容易出错。比如// 错误写法 for (int i 0; i client_fds.size(); i) { if (需要删除) { client_fds.erase(client_fds.begin() i); // 删除后后面的元素前移i不调整会漏掉一个 } }正确的做法是用迭代器并且erase会返回下一个有效的迭代器for (auto it client_fds.begin(); it ! client_fds.end(); ) { if (需要删除) { it client_fds.erase(it); } else { it; } }这个模式在C里非常经典处理vector遍历删除时一定要用。5.4 常见问题速查表现象可能原因解决方法select返回-1errnoEINTR被信号中断判断errno后continue重试客户端断开后服务端CPU飙升fd未从集合移除select一直返回可读close后FD_CLR并从列表移除新连接无法接入backlog太小或accept处理太慢增大backlog优化accept逻辑收到数据不完整单次recv读不完循环recv或加大缓冲区服务端重启bind失败端口处于TIME_WAIT设置SO_REUSEADDRselect监视不到某个fdmax_fd计算错误确保max_fd是最大fd值加一程序遍历client_fds时崩溃遍历中删除元素导致迭代器失效用erase返回的迭代器继续遍历5.5 独家避坑经验说几个文档里不会写但实际会遇到的坑。第一个是fd_set的大小限制。FD_SETSIZE在Linux下默认是1024意味着select最多只能监视1024个fd。如果你要监视的fd超过这个数FD_SET会越界写内存程序可能崩溃或者行为异常。对于入门demo来说1024够用但要知道这个限制的存在。真要高并发得上epoll。第二个是send的阻塞问题。在回显逻辑里我直接调用了send如果发送缓冲区满了send会阻塞导致整个服务端卡住。对于小消息来说不会遇到但如果客户端一直不收数据服务端又一直发缓冲区迟早会满。稳妥的做法是把客户端socket设为非阻塞send返回EAGAIN时把数据缓存起来等socket可写时再发。这就涉及到写事件集合的使用了复杂度会上升不少。第三个是accept的惊群问题。在单线程select模型里不存在这个问题因为只有一个线程在accept。但如果以后你改成多线程多个线程同时select同一个监听socket新连接到来时所有线程都会被唤醒但只有一个能accept成功其他的白跑一趟。这是epoll的EPOLLEXCLUSIVE或者SO_REUSEPORT要解决的问题现在了解即可。第四个是关于inet_ntop的。这个函数比老的inet_ntoa安全因为inet_ntoa返回的是静态缓冲区多次调用会覆盖。inet_ntop需要你自己提供缓冲区就不会有这个问题。打印客户端IP的时候记得用inet_ntop。6. 从select到epoll的演进思路把select版本跑通之后你可能会想如果连接数上千这个模型还撑得住吗答案是撑不住但原因不是代码写错了而是select本身的机制限制。select有三个硬伤。第一是FD_SETSIZE的限制默认1024改起来麻烦且影响其他代码。第二是每次调用都要把整个fd集合从用户态拷贝到内核态连接数越多拷贝开销越大。第三是内核要线性遍历所有监视的fd来检查就绪状态返回后用户态还要再遍历一次找出就绪的fd。这三重开销叠加起来连接数一多性能就急剧下降。epoll就是为解决这些问题设计的。它用epoll_create创建一个epoll实例用epoll_ctl增删改要监视的fd用epoll_wait等待事件。关键区别在于epoll在内核里维护了一棵红黑树来管理fd不需要每次调用都拷贝整个集合就绪的fd会被放到一个就绪链表里epoll_wait直接返回这个链表不需要遍历所有fd。所以epoll的性能不随连接数增加而线性下降。从select迁移到epoll代码结构变化不大主要是把FD_SET/FD_CLR换成epoll_ctl的EPOLL_CTL_ADD/EPOLL_CTL_DEL把select换成epoll_wait把FD_ISSET换成检查epoll_event的events字段。思路完全一致只是API换了。但我的建议是先把select版本写熟。理解了监视多个fd、等待事件、处理就绪fd这个核心循环再看epoll就是水到渠成的事。反过来如果直接上epoll很容易被它那些特有的概念边缘触发、水平触发、EPOLLONESHOT等绕晕反而理解不透IO多路复用的本质。我个人在实际操作中的体会是select就像手动挡汽车操作繁琐但能让你清楚感受到每一个机械动作epoll像自动挡开起来轻松但底层的换挡逻辑被隐藏了。先用手动挡把原理搞明白再换自动挡提升效率这个学习路径是最扎实的。等你把select版本的多客户端服务端跑通并且能自己解释清楚每一次select调用前后fd集合的变化那IO多路复用这一关就算过了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑