ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

select多路IO转接:Linux高并发服务器的入门与实践

select多路IO转接:Linux高并发服务器的入门与实践 刚接触Linux网络编程的人拿到同时服务几十上百个客户端这种需求时第一反应往往是来一个连接就fork一个进程或者起一个线程。我最早写的一版多客户端服务器也是这样一个客户端对应一个线程逻辑确实好写但人一多就开始出现各种问题。后来把项目重构成基于select函数的单进程事件驱动模型代码量不升反降稳定性反而上来了。select解决的根本问题概括成一句话就是**单进程/线程内同时监听多个文件描述符fd的读写事件让服务器用一个循环处理全部连接的IO。**这是Linux下多路IO转接IO Multiplexing的经典入门方案也是最容易理解、最容易复现的。这篇文章不适合完全不懂socket编程的人但你只要会socket、bind、listen、accept这四件套跟着我把这篇文章看完就能拿到一个可编译、可压测、可扩展的单进程多客户端服务器骨架顺带把select背后的机制和实际项目里真正会踩的坑讲透。1. 为什么需要select从阻塞困局说起1.1 阻塞I/O的一对一死局要理解select的价值得先弄清楚默认的阻塞式socket有多别扭。默认创建的socket是阻塞模式。accept()会一直等到有新连接进来才返回recv()/read()会一直等到对端发来数据才返回。单线程串行处理时一旦你在recv上等数据后面所有新连接请求都会被堵住。这是最典型的一对一死局一个忙等数据的连接卡死整个服务器。之前我用多线程方案解决这个问题主线程accept到新连接后立刻创建一个线程去处理这个连接的收发。这个方案在客户端数量少的时候确实好用但它的代价并不直观等并发量上来之后才会集中暴露。1.2 多进程多线程方案被忽略的隐藏成本多线程方案大家在面试里都会说线程池并发量高但真正算过账的人不多。我列几个实际数字给你参考。**线程栈内存开销。**每个线程默认栈大小通常是8MBulimit -s查到的单位是KB这是虚拟内存空间。1000个连接就是1000个线程光栈空间就是8GB虚拟内存。实际物理内存按需分配不至于那么夸张但数量级摆在那里内存压力很实在。**上下文切换成本。**线程数超过CPU核数后操作系统需要在就绪线程之间频繁切换上下文。一次上下文切换还好但大量线程同时处于等数据→被唤醒→处理→再等数据的循环里切换开销会被无限放大CPU大量时间花在调度上真正干活的占比反而下降。**编程复杂度。**多个线程同时访问共享数据肯定要加锁锁竞争一旦严重性能不升反降。还有accept惊群问题多个阻塞在accept上的线程被同一个新连接同时唤醒虽然通常只有一个能拿到连接但这个唤醒风暴本身就是浪费。相比之下select模型的核心思路非常优雅**把轮询等待这件事交给内核让内核告诉我们哪些fd就绪了然后单进程按顺序处理这些就绪fd。**一个循环处理全部连接没有锁、没有大量线程、没有上下文切换风暴。连接就绪才处理没就绪不空转这就是多路IO转接的含义。2. select函数的核心参数与运行机制2.1 fd_set的位图组织方式四个宏就够了select能同时监听多个fd底层依赖的是fd_set这个结构体。它的本质是一个位图bit array每个bit代表一个fd。用户态通过四个宏操作它。#include sys/select.h fd_set readfds; FD_ZERO(readfds); // 清空整个位图 FD_SET(fd, readfds); // 把fd对应的bit置1表示要监听 FD_CLR(fd, readfds); // 把fd对应的bit清零表示取消监听 FD_ISSET(fd, readfds); // 测试fd对应的bit是否为1判断该fd是否就绪你完全可以把fd_set想象成小区门口的LED面板每户一个灯FD_SET就是点亮某户的灯表示我在等这户的消息FD_ISSET就是看看这户的灯亮没亮来判断有没有消息来了。select一次调用相当于保安把所有点亮的灯扫一遍回来告诉你哪些灯亮了。这里有个非常关键的坑**select返回后内核会把未就绪fd对应的bit全部清零只保留就绪的bit。**所以fd_set不能复用每次调用select之前必须重建。我见过不少新手把FD_SET放在select之前执行一次就完了第二次循环开始FD_ISSET怎么都不对就是这个原因。标准写法是每次循环开头都执行一遍FD_ZEROFD_SET把完整监听集合重新构建出来。2.2 nfds这个参数为什么是最大fd1select函数签名如下int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);很多人不理解nfds为什么这么烦人直接传FD_SETSIZE不行吗技术上可以但没这个必要。fd本质是整数在当前进程里是按0、1、2这样分配的文件描述符编号通常新连接的fd总是当前可用范围内最大的那个。select在内核里要遍历的是从0到nfds-1这个区间传FD_SETSIZE意味着就算你只有3个fd内核也要扫1024个bit纯浪费。所以nfds的正确取值是当前监听集合里最大的fd加1。注意是加1因为fd是从0开始的如果最大fd是9要检查的是0~9共10个bitnfds10。每次有新连接加入时记得更新这个值每次有连接关闭移除时如果关闭的恰好是当前最大fd也要重新扫描一遍集合算出新的最大值。timeout参数有三种常见用法对应三种询问方式传NULL无限期阻塞直到至少一个fd就绪。传{0, 0}立即返回相当于一次非阻塞轮询轮一遍就绪集合没就绪立刻走人。传{5, 0}等具体值等待最多5秒超时返回0。还有一个移植性陷阱Linux上select不会修改timeout结构体但POSIX标准并没有严格保证这一点老版本的Linux或部分Unix系统会把它改成剩余等待时间。跨平台代码里正确姿势是每次循环都重新初始化timeout绝不依赖上一次的结果。2.3 返回值不是简单的有几个fd准备好了select返回值有三种情况返回-1出错常见是信号打断errnoEINTR这时应该continue继续循环而不是退出。返回0超时没有任何fd就绪。这是实现心跳检测超时踢人功能的入口。返回正整数就绪fd的总个数。最后这个正整数需要小心它是readfds、writefds、exceptfds三个集合中就绪fd数量的总和同一个fd如果在读集合和写集合里同时就绪会被重复计数。所以你不能依赖这个值精确知道循环处理几次只能知道有东西来了。实际代码里统一用FD_ISSET逐个判断遍历时可以拿这个值做剪枝优化但逻辑上不能依赖它。3. 一个可编译的select单进程回显服务器3.1 设计思路监听fd也进集合我给你写一个完整可编译的单进程多客户端服务器功能是回显客户端发什么服务器原样返回什么。这个例子麻雀虽小五脏俱全从新连接接入、数据收发、客户端断开到fd管理全部覆盖。三个关键设计决策提前说清楚第一监听socket也要放进fd_set。select监听的是可读事件而有新客户端连进来对于监听socket来说就是一种可读事件可以执行accept而不会阻塞。把监听fd放进集合才能在一个select调用里同时处理新连接和旧连接的数据。**第二用数组管理客户端fd。**连接总数可控的前提下数组比链表简单。关闭一个连接时采用尾部交换删除把数组最后一个fd挪到被删位置数组长度减一。因为fd数组不关心顺序这种删法时间复杂度O(1)还避免了大段内存拷贝。**第三初始只监听读事件。**回显服务不需要主动往外推数据writefds不监听写直接调用write()即可。后面如果做广播推送才需要考虑把写事件也交给select管理。3.2 服务器完整代码#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/select.h #define PORT 8888 #define MAX_CLIENTS (FD_SETSIZE - 5) #define BUF_SIZE 1024 int main(void) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(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_port htons(PORT); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, 32) 0) { perror(listen); exit(1); } int client_fds[MAX_CLIENTS]; int client_num 0; int max_fd listen_fd; printf(server start, listening on port %d\n, PORT); while (1) { fd_set readfds; FD_ZERO(readfds); FD_SET(listen_fd, readfds); for (int i 0; i client_num; i) { FD_SET(client_fds[i], readfds); } int ready select(max_fd 1, readfds, NULL, NULL, NULL); if (ready 0) { if (errno EINTR) { continue; } perror(select); break; } /* 1. 新连接就绪 */ if (FD_ISSET(listen_fd, readfds)) { struct sockaddr_in cli_addr; socklen_t cli_len sizeof(cli_addr); int cli_fd accept(listen_fd, (struct sockaddr *)cli_addr, cli_len); if (cli_fd 0) { perror(accept); continue; } if (client_num MAX_CLIENTS) { printf(client array full, reject %s:%d\n, inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port)); close(cli_fd); } else { client_fds[client_num] cli_fd; if (cli_fd max_fd) { max_fd cli_fd; } printf(new client connected: %s:%d, fd%d, total%d\n, inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), cli_fd, client_num); } ready--; if (ready 0) { continue; } } /* 2. 客户端fd就绪逐个处理 */ for (int i 0; i client_num; ) { int fd client_fds[i]; if (FD_ISSET(fd, readfds)) { char buf[BUF_SIZE]; ssize_t n read(fd, buf, sizeof(buf) - 1); if (n 0) { /* 对端关闭或出错移除fd */ printf(client fd%d closed, remove it, total%d\n, fd, client_num - 1); close(fd); client_fds[i] client_fds[client_num - 1]; client_num--; continue; /* 不递增i继续检查换过来的fd */ } buf[n] \0; printf(recv from fd%d: %s, fd, buf); write(fd, buf, n); /* 回显 */ ready--; if (ready 0) { break; } } i; } } for (int i 0; i client_num; i) { close(client_fds[i]); } close(listen_fd); return 0; }复制下来gcc -o select_server select_server.c编译./select_server运行服务就起来了。3.3 用nc实测验证核心逻辑测试工具就用nc比写测试客户端省事得多。开一个终端运行服务器再开三个终端分别执行nc 127.0.0.1 8888三个nc都连上后服务器日志会打印三条new client connected每个连接分配一个独立的fd编号。随便挑一个终端输入一行字服务器马上回显相同内容日志同时打印recv from fd...。断开其中一个nc按CtrlC注意服务器的日志输出它会打印client fdxx closed并且把集合里的fd数量减一。这验证了两个关键行为**第一select能正确感知对端关闭。**对端关闭连接时fd变成可读状态read()返回0服务器借此发现连接断开。**第二删除fd后select不会脏。**因为每次循环都重建fd_set移除的fd不会再被放进去监听集会自动变干净。我自己调试时还喜欢用strace看系统调用比如strace -p 服务器pid -e traceselect,read,write能直观看到服务器阻塞在select上有事件到达才唤醒执行read/write。这比任何措辞都更有说服力地解释了阻塞在select而不是阻塞在某个连接上。4. 从能跑到高可用单进程IO服务器必须处理的四个细节上面这个demo能跑但距离高可用还差四件事。这四件事是在真实项目里一定会碰到的也是我从踩坑中总结出来的。4.1 非阻塞read与EAGAINselect说可读不代表一次读完select返回可读唯一能保证的是**对这个fd执行一次read不会阻塞。**它不保证你一次能读完所有数据。客户端一次发10KB你的缓冲区只有1024字节read只拿走前1024字节剩下的数据还留在内核缓冲区里下一次select会立刻再次返回可读你再读下一块。从机制上说只要代码结构是每次select返回后只读一次循环复用数据总能读完不会丢。真正的坑出在阻塞socket上。很多人写着写着嫌一次读一次效率低改成while循环里一直read直到读完while (read(fd, buf, sizeof(buf)) 0) { handle(buf); }这代码如果在阻塞socket上跑select说可读了第一次read确实不阻塞但要是这个连接只发了1024字节且第一个read恰好读完了第二次read就会阻塞等待新数据。**整个进程卡在某个客户端的while里其他fd全部瘫痪。**那种为什么我的select服务器只能服务一个客户端的帖子十有八九是这个问题。正确解法把客户端fd设为非阻塞read返回-1且errnoEAGAIN时才说明本次数据确实读完了。int flags fcntl(cli_fd, F_GETFL, 0); fcntl(cli_fd, F_SETFL, flags | O_NONBLOCK);accept返回新连接后立刻设置非阻塞配合select使用才是黄金搭档。同理write也可能遇到缓冲区满返回-1且EAGAIN这时应该等select报告该fd可写再继续写而不是死循环重试。4.2 客户端连接过多但无数据别让僵尸连接拖死服务器demo里select的timeout传的是NULL意味着永久阻塞。实际项目里这会有问题客户端建立连接后一直不发数据这个fd就永远躺在监听集合里占用fd名额和数组位置。时间长了一堆僵尸连接堆积新用户反而连不进来。高可用的服务器必须做空闲超时断开。思路很简单维护一个last_active[]数组记录每个连接最后一次有数据的时间select设置一个合理的超时时间比如30秒每次select返回后无论是0还是大于0都检查一遍所有连接的活跃时间超过阈值的直接关闭并从数组移除。struct timeval tv; tv.tv_sec 5; tv.tv_usec 0; int ready select(max_fd 1, readfds, NULL, NULL, tv); if (ready 0) { /* 超时统一检查一次所有连接的活跃时间 */ time_t now time(NULL); for (int i 0; i client_num; ) { if (now - last_active[i] 30) { close(client_fds[i]); client_fds[i] client_fds[client_num - 1]; last_active[i] last_active[client_num - 1]; client_num--; } else { i; } } }每次read到数据后更新last_active[i] time(NULL)即可。这样即使select等不到任何数据服务器也会定期醒来清扫僵尸连接实现看起来没有任何连接但服务器活着且健康。4.3 尾部交换删除的隐藏细节换过来的fd也要重新检查demo里删除fd时用了尾部交换client_fds[i] client_fds[client_num - 1]; client_num--; continue;注意这个continue它的作用是不递增i因为被交换到i位置的是原来数组最后一个fd。它可能也在本轮的就绪集合里如果不重新检查这个连接的数据就要等到下一轮select才处理白白多等一次轮询。在就绪事件密集时这种漏处理累计起来就是明显的延迟。很多人写删除逻辑时图省事用memmove把后面的元素整体前移一位也能工作但O(N)的代价在高频断开连接场景下会放大。接口层面如果业务上根本不在乎fd的顺序尾部交换就是最优解代价只是需要多人注意删除后当前位置还要重新处理这个细节。4.4 消息边界与粘包select不是消息解析器select只回答一个问题**这个fd上有没有数据可读。**它不回答读到的数据是不是一条完整消息。TCP是字节流协议没有天然的消息边界。客户端可能发来半条消息也可能一次发来多条消息。很多从socket编程入门到select的人第二个大坑就在这里假设一次read就是一条消息。实际做法是设计应用层协议行协议消息以\n结尾服务器缓冲收到的字节凑到\n才认为是一条完整消息。长度字段协议消息头4字节存长度大端/小端约定好服务器先读满4字节解析长度再读满对应字节。选行协议简单我给你一个缓冲拼接的示意char recv_buf[8192]; size_t recv_len 0; /* 每次read到数据后 */ memcpy(recv_buf recv_len, buf, n); recv_len n; /* 检查是否有完整行 */ char *newline; while ((newline memchr(recv_buf, \n, recv_len)) ! NULL) { size_t line_len newline - recv_buf; recv_buf[line_len] \0; handle_message(recv_buf); /* 处理一行完整消息 */ memmove(recv_buf, newline 1, recv_len - line_len - 1); recv_len - line_len 1; }缓冲区大小要按最大消息长度设计超出部分要么截断要么报错别让缓冲数组越界。这个逻辑本身简单但它是select服务器能不能从demo变成可商用程序的分水岭。5. select的边界与替代方案什么场景不该硬撑老实说select不是完美的方案。它有两个绕不开的硬伤理解了这两个硬伤你才知道什么时候该用它、什么时候应该上epoll。5.1 FD_SETSIZE上限与内核O(N)扫描select的天花板fd_set的位图大小由FD_SETSIZE宏决定Linux上通常是1024。也就是说一个进程用select最多同时监听1024个fd包含标准输入输出和监听fd之后实际能给客户端用的不到1024个。想突破这个限制需要修改FD_SETSIZE再重新编译不仅麻烦还违背直接能用的初衷。另一个隐藏成本是O(N)扫描。select每次调用内核都要遍历你传入的0到nfds-1范围不管这些fd是不是活跃的。即使100个连接里只有1个有数据内核也要把100个bit全部过一遍。这个开销在几百个连接时感觉不出来到几千个连接时就开始明显刺眼了。5.2 poll和epoll的定位差异选型参照表poll和epoll是select的两大主要替代方案它们之间的差异非常适合用一张表说清楚维度selectpollepollfd集合存储fd_set位图大小受FD_SETSIZE限制pollfd数组无数量上限内核事件表无数量上限用户态→内核态拷贝每次调用都拷贝整个fd_set每次调用都拷贝整个pollfd数组通过epoll_ctl增量注册无重复拷贝就绪检测方式内核线性扫描0~nfds-1内核线性扫描全部pollfd回调机制只遍历就绪链表返回结果位图就地修改需要FD_ISSET逐个查每个pollfd有revents字段遍历数组查直接返回就绪fd列表复杂度O(N)O(N)O(就绪数)跨平台Windows/Unix/Linux都有Unix/LinuxLinux专属核心区别一句话总结**select和poll是每个轮回把所有fd重新问一遍epoll是提前在内核登记兴趣有事件时内核主动把就绪的fd丢给你。**连接数少时差别不大连接数上千后前两者的O(N)扫描和位图拷贝成本就压不住了。5.3 我的选型建议别什么场景都上epoll写了这么多年网络服务我的经验是**连接数在200以内、业务逻辑不复杂、需要跨平台或跑在嵌入式设备上select完全够用。**它的优势是极度简单一套FD_ZERO/FD_SET/FD_ISSET逻辑所有操作系统通用debug起来也很直观——strace打到select上清清楚楚看到每次唤醒发生在哪个时刻。**连接数上千、高并发长连接的场景或者QPS要求很高直接上epoll。**它的回调机制让内核只在有事件时通知用户态系统调用次数更少CPU占用更友好。这也是nginx、redis这些高性能服务最终都走事件驱动、部分实现使用epoll的原因。**不想自己维护fd集合和事件循环可以看libevent、libev这类事件库。**它们底层封装了select/epoll/kqueue暴露统一的event API相当于社区帮你把select和epoll的边界差异抹平了。但在这个之前我仍然建议至少手写一次select服务器把多路IO转接的基本功练扎实否则直接用事件库很容易不知道为什么这个API要这么设计。最后的最后分享一个我实际排查过的案例。曾经一个服务莫名其妙日志刷屏发现某个客户端异常退出后fd的read一直返回0但代码里没有在n0时正确删除fd导致该fd永远处于可读状态select每次都立即返回服务器陷入忙等状态CPU直接打满。排查半天才发现是read返回值的处理顺序问题——必须先处理n0的情况再处理正常数据。这类问题本质上是对select语义理解不透**就绪不代表连接一直健康恰恰因为就绪你才更要优先处理连接关闭这种异常就绪。**希望这篇文章能帮你少走这段弯路。
返回列表