【Linux开发】I/O 复用:select 模型
一、为什么需要 I/O 复用1.1 多进程服务器的缺点之前我们实现过多进程并发服务器每个客户端连接就fork一个子进程来处理。这种方式虽然能实现并发但存在明显问题资源开销大每个进程都有独立的地址空间创建进程需要大量内存和时间。进程数量有限系统能同时运行的进程数受限于内存和 CPU几百个进程就可能让系统不堪重负。上下文切换频繁大量进程切换会降低 CPU 效率。一句话多进程服务器“重”不适合高并发场景如成千上万个客户端。1.2 I/O 复用的核心思想用一个进程线程同时监视多个文件描述符当其中任何一个有 I/O 事件可读、可写、异常时就通知程序去处理。就像餐厅里一个服务员同时照看多张桌子哪桌客人举手有事件就过去服务而不是每桌配一个服务员。1.3 I/O 复用的优势单进程处理多个连接节省内存和 CPU。无进程/线程创建销毁开销。易于管理代码逻辑相对简单。二、select 函数模型2.1 文件描述符集合fd_setselect使用fd_set结构体来保存一组待监视的文件描述符socket。fd_set内部是一个位图bitset每一位代表一个文件描述符最多支持FD_SETSIZE通常为 1024个。操作fd_set的四个宏宏作用FD_ZERO(fd_set *set)将集合清空所有位归零FD_SET(int fd, fd_set *set)将fd加入集合对应位置1FD_CLR(int fd, fd_set *set)将fd从集合中移除对应位置0FD_ISSET(int fd, fd_set *set)判断fd是否仍在集合中返回非0表示在2.2 select 函数原型#includesys/select.h#includesys/time.hintselect(intmaxfd,fd_set*readset,fd_set*writeset,fd_set*exceptset,structtimeval*timeout);maxfd监视的最大文件描述符1因为 select 会遍历 0 ~ maxfd-1。readset监视“可读”事件有数据可读、连接请求到达、连接关闭等。writeset监视“可写”事件缓冲区有空闲可写。exceptset监视“异常”事件一般不用传 NULL。timeout超时时间结构体如下structtimeval{longtv_sec;// 秒longtv_usec;// 微秒};返回值0发生事件的文件描述符数量。0超时没有事件发生。-1出错。2.3 select 的工作流程初始化一个fd_set加入要监视的套接字如监听套接字serv_sock。调用select程序阻塞等待。select返回时readset等集合会被修改只保留那些发生事件的 fd。遍历所有可能 fd用FD_ISSET检查哪个 fd 就绪。处理该 fd 上的事件accept、read、write。重复步骤 2~5。三、基于 select 的回声服务器实现下面我们实现一个单进程、非阻塞、支持多客户端的回声服务器收到什么就返回什么。3.1 服务器代码完整注释// 包含标准输入输出库用于打印错误信息#includestdio.h// 标准库用于 exit() 退出程序#includestdlib.h// 字符串操作库用于 memset、strlen 等#includestring.h// UNIX 标准函数用于 close、read、write#includeunistd.h// 网络地址转换函数如 inet_ntoa、htonl 等#includearpa/inet.h// socket 相关函数和结构体#includesys/socket.h// timeval 结构体用于 select 超时#includesys/time.h// select 函数及相关宏#includesys/select.h#defineBUF_SIZE100// 消息缓冲区大小// 错误处理函数声明voiderror_handling(char*buf);intmain(intargc,char*argv[]){intserv_sock,clnt_sock;// 服务器监听套接字 和 客户端连接套接字structsockaddr_inserv_adr,clnt_adr;// 服务器地址结构 和 客户端地址结构// 超时时间结构体用于 selectstructtimevaltimeout;// reads: 原始监视集合所有需要监视的套接字// cpy_reads: 每次 select 调用时使用的副本select 会修改它fd_set reads,cpy_reads;// 客户端地址结构大小用于 acceptsocklen_tadr_sz;// fd_max: 当前最大的文件描述符值用于 select 第一个参数// str_len: 读取数据的长度// fd_num: select 返回的就绪文件描述符数量intfd_max,str_len,fd_num,i;// 数据缓冲区charbuf[BUF_SIZE];// 检查命令行参数需要端口号if(argc!2){printf(Usage : %s port\n,argv[0]);exit(1);}// 1. 创建 TCP 套接字 // PF_INET: IPv4 协议族SOCK_STREAM: 面向连接的 TCP0: 自动选择协议serv_socksocket(PF_INET,SOCK_STREAM,0);// 初始化服务器地址结构全部置零memset(serv_adr,0,sizeof(serv_adr));serv_adr.sin_familyAF_INET;// IPv4 地址族serv_adr.sin_addr.s_addrhtonl(INADDR_ANY);// 本机任意可用 IPserv_adr.sin_porthtons(atoi(argv[1]));// 命令行指定的端口转网络字节序// 2. 绑定套接字到地址 if(bind(serv_sock,(structsockaddr*)serv_adr,sizeof(serv_adr))-1)error_handling(bind() error);// 3. 开始监听 // 第二个参数 5 表示等待连接队列的最大长度if(listen(serv_sock,5)-1)error_handling(listen() error);// ******** 4. 初始化文件描述符集合 ********// FD_ZERO: 将 reads 集合所有位清零FD_ZERO(reads);// FD_SET: 将监听套接字 serv_sock 加入到 reads 集合中FD_SET(serv_sock,reads);// 当前最大文件描述符就是监听套接字因为还没有客户端连接fd_maxserv_sock;// 5. 主循环反复调用 select 处理 I/O 事件 while(1){// 每次 select 调用前将原始集合复制一份给 cpy_reads// 因为 select 会修改传入的集合只保留就绪的 fdcpy_readsreads;// 设置超时时间5 秒 5000 微秒即 5.005 秒// 如果传 NULLselect 会一直阻塞直到有事件timeout.tv_sec5;timeout.tv_usec5000;// 6. 调用 select 函数 // 参数1: 最大文件描述符 1告诉内核要检查 0 到 fd_max 这些 fd// 参数2: 读集合我们只关心可读事件// 参数3,4: 写集合和异常集合这里不关心传 NULL// 参数5: 超时时间// 返回值: 就绪的文件描述符数量-1 表示出错0 表示超时fd_numselect(fd_max1,cpy_reads,NULL,NULL,timeout);if(fd_num-1)// 出错退出循环break;if(fd_num0)// 超时没有事件发生继续下一次循环continue;// 7. 遍历所有可能的文件描述符从 0 到 fd_max for(i0;ifd_max1;i){// FD_ISSET: 判断文件描述符 i 是否在 cpy_reads 集合中即是否就绪if(FD_ISSET(i,cpy_reads)){// 情况1: 是监听套接字 serv_sock 就绪 - 有新的客户端连接请求if(iserv_sock){adr_szsizeof(clnt_adr);// 接受连接返回新的客户端套接字clnt_sockaccept(serv_sock,(structsockaddr*)clnt_adr,adr_sz);// 将新客户端套接字加入 reads 集合以后也要监视它FD_SET(clnt_sock,reads);// 如果新套接字的值大于当前 fd_max则更新 fd_maxif(fd_maxclnt_sock)fd_maxclnt_sock;printf(connected client: %d \n,clnt_sock);}// 情况2: 是普通客户端套接字就绪 - 有数据到达或者连接关闭else{// 读取数据尝试读取最多 BUF_SIZE 字节str_lenread(i,buf,BUF_SIZE);if(str_len0)// read 返回 0 表示客户端正常关闭连接{// 从监视集合中移除该客户端套接字FD_CLR(i,reads);// 关闭套接字close(i);printf(closed client: %d \n,i);}else// 收到数据{// 将收到的数据原样写回回声write(i,buf,str_len);}}}}}// 关闭监听套接字实际上由于无限循环不会执行到这里close(serv_sock);return0;}// 错误处理函数输出错误信息并退出程序voiderror_handling(char*buf){fputs(buf,stderr);fputc(\n,stderr);exit(1);}3.2 代码关键点解析关键点解释fd_set reads与cpy_readsreads保存所有需要监视的套接字每次select前复制一份到cpy_reads因为select会修改传入的集合。fd_maxselect第一个参数是最大 fd 1所以需要动态跟踪当前最大 fd。select超时这里设为 5 秒 5 毫秒避免无限阻塞但实际服务器中通常传NULL永久等待。FD_ISSET(i, cpy_reads)判断 fd i 是否就绪。新连接处理对serv_sock调用accept获得新客户端套接字加入reads并更新fd_max。数据读取对普通客户端套接字调用read如果返回 0 表示对方关闭则关闭该套接字并从集合中移除否则将数据原样写回回声。3.3 客户端代码标准回声客户端#includestdio.h#includestdlib.h#includestring.h#includeunistd.h#includearpa/inet.h#includesys/socket.h#defineBUF_SIZE1024voiderror_handling(char*message);intmain(intargc,char*argv[]){intsock;// 客户端套接字charmessage[BUF_SIZE];// 发送和接收缓冲区intstr_len;// 实际接收的字节数structsockaddr_inserv_adr;// 服务器地址结构// 检查命令行参数需要服务器 IP 和端口if(argc!3){printf(Usage : %s IP port\n,argv[0]);exit(1);}// 1. 创建套接字 socksocket(PF_INET,SOCK_STREAM,0);if(sock-1)error_handling(socket() error);// 2. 初始化服务器地址结构 memset(serv_adr,0,sizeof(serv_adr));serv_adr.sin_familyAF_INET;// IPv4serv_adr.sin_addr.s_addrinet_addr(argv[1]);// 点分十进制 IP 转整数serv_adr.sin_porthtons(atoi(argv[2]));// 端口转网络字节序// 3. 连接服务器 if(connect(sock,(structsockaddr*)serv_adr,sizeof(serv_adr))-1)error_handling(connect() error!);elseputs(Connected...........);// 4. 循环发送消息并接收回声 while(1){// 提示用户输入fputs(Input message(Q to quit): ,stdout);// 从标准输入读取一行存入 messagefgets(message,BUF_SIZE,stdin);// 如果输入 q\n 或 Q\n退出循环if(!strcmp(message,q\n)||!strcmp(message,Q\n))break;// 发送消息给服务器不包括结尾的 \0write(sock,message,strlen(message));// 接收服务器返回的回声str_lenread(sock,message,BUF_SIZE-1);// 在字符串末尾添加 \0确保正确打印message[str_len]0;printf(Message from server: %s,message);}// 关闭套接字结束程序close(sock);return0;}voiderror_handling(char*message){fputs(message,stderr);fputc(\n,stderr);exit(1);}四、运行与测试4.1 编译gcc select_server.c-oserver gcc client.c-oclient4.2 运行服务器./server91904.3 运行多个客户端# 终端2./client127.0.0.19190# 终端3./client127.0.0.191904.4 测试结果每个客户端可以独立发送消息服务器会原样返回回声。服务器端打印连接和关闭信息。服务器只使用一个进程却能同时服务多个客户端。五、select 的局限与改进5.1 优点跨平台几乎所有操作系统都支持。代码简单易于理解。5.2 缺点缺点说明文件描述符上限默认 1024虽可修改但效率会下降。效率低每次调用都要把所有 fd 从用户态拷贝到内核态且内核需要遍历所有 fd。无法动态修改集合每次循环都要重新设置集合因为 select 会修改。O(n) 扫描应用程序需要遍历所有 fd 检查就绪位复杂度 O(n)。5.3 改进方案poll去除了 1024 限制但仍是 O(n) 扫描。epollLinux 特有事件驱动只返回就绪的 fd效率高适合海量连接。kqueueBSD 系统。六、总结I/O 复用用一个进程监视多个套接字避免多进程/多线程的高昂开销。select是最简单的 I/O 复用模型通过fd_set管理监视对象通过select等待事件。实现步骤创建监听套接字加入fd_set。循环调用select。根据FD_ISSET判断事件类型监听套接字 →accept新连接加入集合。客户端套接字 →read数据返回 0 表示断开否则处理数据。