
简介在Windows平台上用C实现单线程同时监听多个端口是一种基于IOCP的高性能网络编程思路面向需要减少线程切换、提升并发吞吐量的socket开发者。资源以zip压缩包提供共2个文件包括1个cpp与1个h源码压缩包仅3KB代码量精简便于快速定位核心逻辑。已有1649人学习下载。IocpAccept.cpp与IocpAccept.h完整展示了创建IOCP、将多个监听套接字关联到同一完成端口、通过GetQueuedCompletionStatus循环分发事件等关键流程同时涉及WSARecvFrom/WSAConnect异步接收设置、OVERLAPPED结构体管理I/O状态、错误处理与内存管理细节可帮助读者在真实项目中避开常见坑。适合已掌握基本socket编程、希望深入理解Windows异步I/O模型的开发者参考。1. 一个反直觉的问题单线程怎么同时等住多个端口先把结论放在前面Windows 平台上一个线程确实可以同时监听多个端口而且代码量比你想象的小得多。核心不是把 accept 变成非阻塞而是换一套等待机制——select。很多第一次写 socket 服务的开发者都会卡在这里accept 明明是阻塞的调了第一个就卡死后面的端口怎么等如果把 accept 放到多线程里每个端口一个线程又觉得太重了。其实 C 里用 select 这套 API可以做到一个线程同时盯住多个监听 socket哪个端口来了连接就处理哪个。这系列代码适合谁适合正在写 Windows 下的网络服务、想避免「一个端口一个线程」这种笨办法的开发者也适合想弄懂 select 到底怎么用、fd_set 到底是什么的人。看完你会得到一个能直接编译运行的多端口监听骨架还能学会怎么把它改造成实际能用的服务框架。2. 技术底座为什么是 select 而不是其他模型2.1 核心矛盾accept 是阻塞的怎么同时等多个端口问题的根源在于 accept 的阻塞特性。当你对一个监听 socket 调用 accept 时如果当前没有客户端连接进来这个调用会卡住直到有连接到达才返回。单线程下连续调用两次 accept只会处理第一个端口第二个端口永远等不到。这是一个「事件等待」的问题而不是「连接处理」的问题。既然 accept 会阻塞那就不能直接在主逻辑里调用它需要有一个机制让线程同时等待多个 socket 上是否有「可接受」事件。select 就是干这个的你把一堆 socket 交给它它帮你盯着一旦其中有任何一个变得「可读」它就返回告诉你哪个 socket 有动静。放在多端口监听的场景里「可读」的含义对监听 socket 来说是「有新的连接请求到达」对已连接的 socket 来说是「对端发来了数据」。select 一次调用能同时处理这两种情况。2.2 Windows 上为什么选择 select 而不是 IOCP 或 WSAEventSelect提到 Windows 高性能网络编程很多人第一反应是 IOCP完成端口。但 IOCP 是重量级方案适合几千上万个连接的服务端代码复杂度高涉及完成键、工作者线程、重叠 I/O 这些概念。对于「同时监听几个端口」这个需求IOCP 是杀鸡用牛刀。WSAEventSelect 是把 socket 事件绑定到事件对象上然后用 WaitForMultipleObjects 等待事件。这套模型比 select 精细但有个限制WaitForMultipleObjects 最多同时等待 64 个事件对象超过就麻烦。而且代码里要管理事件对象数组和 socket 的映射关系复杂度不低。select 的取舍很明确上限是 FD_SETSIZE默认 64个 socket对「同时监听多个端口」够用代码短可读性强调试时通过 WSAGetLastError 拿错误码心智负担小。注意一点Windows 的 select 和 Linux 的 select 行为基本一致但 Windows 上 select 的第一个参数会被忽略传 0 就行不需要像 Linux 那样传最大 fd 加一。2.3 打开非阻塞模式让 accept 不会傻等select 能告诉我们「有连接来了」但调用 accept 时还是可能因为竞态条件而阻塞。常见做法是把监听 socket 设为非阻塞模式这样即使 select 返回了但连接已经被别的线程或别的 select 轮次取走accept 也会立即返回失败而不是卡死线程。代码块设置非阻塞模式#include winsock2.h #include windows.h bool SetNonBlocking(SOCKET s) { u_long mode 1; // 1 表示非阻塞0 表示阻塞 int ret ioctlsocket(s, FIONBIO, mode); if (ret SOCKET_ERROR) { // 这里要立刻用 WSAGetLastError 拿错误码常见的是 WSAENOTSOCK return false; } return true; }先用ioctlsocket配合FIONBIO指令把 socket 切到非阻塞模式。mode 1是非阻塞mode 0是恢复阻塞。这个调用失败时多半是 socket 句柄已经失效或者传入的不是 socket 而是普通文件句柄。设置失败不要继续往下走否则后面 accept 会不可控地卡住。3. 核心实现select fd_set 的单线程监听器3.1 基础结构用数组维护多个监听 socket既然要监听多个端口首先得把这些 socket 放在一个统一的地方管理。同时为了复用同一个 select 循环我习惯把「监听 socket」和「已接受的客户端 socket」放在同一个 socket 数组里统一交给 select 去盯。代码块初始化监听 socket 数组#include winsock2.h #include stdio.h #pragma comment(lib, ws2_32.lib) #define MAX_SOCKETS 64 // 数组上限必须 FD_SETSIZE #define LISTEN_PORTS_COUNT 3 SOCKET g_sockets[MAX_SOCKETS] {0}; // 0 是无效 socket 句柄 int g_socketCount 0; SOCKET CreateListenSocket(int port) { SOCKET s socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s INVALID_SOCKET) return INVALID_SOCKET; // 允许端口重用避免 TIME_WAIT 状态下重启失败 BOOL opt TRUE; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (const char*)opt, sizeof(opt)); sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port htons((u_short)port); if (bind(s, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { closesocket(s); return INVALID_SOCKET; } if (listen(s, SOMAXCONN) SOCKET_ERROR) { closesocket(s); return INVALID_SOCKET; } return s; }g_sockets数组里的元素 0 表示空位这是我自己约定的无效值。Windows 的 socket 句柄从 1 开始分配所以可以用 0 当占位。SO_REUSEADDR这个选项值得说一下服务端程序如果不设置它重启时如果旧连接还处在 TIME_WAIT 状态bind 可能失败。开发调试阶段这个选项能省掉很多「明明关了程序却起不来」的莫名其妙的报错。3.2 主循环select 返回后怎么判断是哪个端口核心的监听循环分为三步清空并重建 fd_set调用 select 等待遍历 fd_set 找到有事件的 socket 并处理。注意每次循环都要重建 fd_set因为 select 返回时会把没有事件的 socket 从集合中剔除。代码块select 多端口监听主循环void RunLoop() { while (true) { fd_set readSet; FD_ZERO(readSet); // 把当前所有 socket 加入 select 集合 for (int i 0; i g_socketCount; i) { FD_SET(g_sockets[i], readSet); } // Windows 下第一个参数忽略timeout 为 NULL 表示永久等待 int ret select(0, readSet, nullptr, nullptr, nullptr); if (ret SOCKET_ERROR) { int err WSAGetLastError(); // 常见原因是 WSAENOTSOCK集合里混进了已关闭的句柄 printf(select error: %d\n, err); break; } if (ret 0) { continue; // 超时返回实际上 timeout 为 NULL 时不会发生 } // 遍历所有 socket检查哪个有事件 for (int i 0; i g_socketCount; i) { if (!FD_ISSET(g_sockets[i], readSet)) { continue; // 这个 socket 没有事件 } HandleSocketEvent(i); } } }这段循环的执行流程是每次先清空readSet再把所有管理的 socket 重新塞进去。select返回后readSet里只保留了「有事发生」的 socket然后逐个用FD_ISSET判断。这里有个最常见的坑不能在每次 select 前把所有 socket 重新加入集合后还用上一次 select 返回后的readSet继续判断。因为你加入的readSet是全新的上一次返回结果已经被覆盖了。continue的分支就是防止这种错误。3.3 accept 与数据读取区分监听端口与已连接 socketselect 只负责「通知」真正干活的是HandleSocketEvent函数。它需要区分两类 socket监听 socket 出现可读事件意味着有新连接已连接 socket 出现可读事件意味着有数据到达或对端关闭了连接。代码块事件分发处理void HandleSocketEvent(int index) { SOCKET s g_sockets[index]; // 判断是不是监听 socket记录监听 socket 的起始下标范围 // 这里有简化约定前 LISTEN_PORTS_COUNT 个是监听 socket if (index LISTEN_PORTS_COUNT) { // 新连接到达接受它 sockaddr_in clientAddr {0}; int addrLen sizeof(clientAddr); SOCKET client accept(s, (sockaddr*)clientAddr, addrLen); if (client INVALID_SOCKET) { int err WSAGetLastError(); // 非阻塞模式下这里常见 WSAEWOULDBLOCK表示连接已被抢走可忽略 printf(accept failed: %d\n, err); return; } // 把新客户端 socket 加入数组 if (g_socketCount MAX_SOCKETS) { g_sockets[g_socketCount] client; g_socketCount; printf(new connection from %d.%d.%d.%d:%d, assigned index %d\n, (clientAddr.sin_addr.S_un.S_addr 0) 0xFF, (clientAddr.sin_addr.S_un.S_addr 8) 0xFF, (clientAddr.sin_addr.S_un.S_addr 16) 0xFF, (clientAddr.sin_addr.S_un.S_addr 24) 0xFF, ntohs(clientAddr.sin_port), g_socketCount - 1); } else { // 数组满了直接关闭连接 closesocket(client); } } else { // 已连接 socket尝试读取数据 char buf[1024] {0}; int n recv(s, buf, sizeof(buf) - 1, 0); if (n 0) { printf(recv from client: %s\n, buf); // 回显给客户端 send(s, buf, n, 0); } else if (n 0) { // 对端正常关闭 printf(client closed, index %d\n, index); closesocket(s); g_sockets[index] 0; // 标记空位 } else { int err WSAGetLastError(); if (err ! WSAEWOULDBLOCK) { printf(recv error: %d\n, err); closesocket(s); g_sockets[index] 0; } } } }逻辑上index LISTEN_PORTS_COUNT的是监听 socket否则是客户端 socket。当客户端关闭连接时recv返回 0这时就要关闭 socket 并清理数组位置。清理后的空位我没有做数组压缩直接改成 0。这样做的问题是下次 select 循环会把 0 也加进 fd_set。所以主循环里要加一个判断跳过为 0 的元素这一步很多人会漏掉。3.4 修正让主循环跳过空位上面代码中g_sockets里的 0 元素如果不处理select 调用时FD_SET(0, readSet)会出错因为 0 不是合法 socket。修正的方式很简单在主循环里检查值是否为 0。代码块剔除空位后的安全重建FD_ZERO(readSet); for (int i 0; i g_socketCount; i) { if (g_sockets[i] ! 0) { FD_SET(g_sockets[i], readSet); } }这个细节不显眼但实际跑起来很容易翻车如果你在前面关闭了一个客户端 socket 但没有清零select 会直接返回WSAENOTSOCK。把g_sockets[i] ! 0作为前置条件后关闭连接和事件分发两个环节都安全了。select 默认最多管理 64 个 socket。如果你的监听端口 客户端连接总数会超过 64有两种处理方式一是接受这个上限客户端连接满了就拒绝新的二是重新定义FD_SETSIZEWindows 的 winsock2.h 是允许在包含头文件之前自行定义这个宏来扩大的但最大不建议超过 512。4. 避坑指南单线程多端口开发的常见问题与排查4.1 崩溃在 select集合里混进了失效 socket现象程序运行一段时间后突然select返回SOCKET_ERROR错误码是WSAENOTSOCK。之前明明好好的也没改动代码。原因某个客户端断开时代码只调用了closesocket没有把数组里的值重置为 0。于是下一次 select 把一个已关闭的句柄塞进了 fd_set内核直接不想理你。解决关闭 socket 后立即把数组元素置 0同时循环重建时加g_sockets[i] ! 0的保护判断。这一步是必须的不是可选的。4.2 只能监听一个端口select 永远只在第一个端口返回现象起了三个监听端口但只有第一个端口的连接能被 accept其他端口的连接一点反应都没有。原因大概率没有设置非阻塞模式。在第一次 accept 时如果用的是默认阻塞 socket又没有 select 通知或通知了但 accept 卡住线程就阻塞在那里了。加上监听 socket 是非阻塞后即使事件丢失accept 也会立刻返回WSAEWOULDBLOCK而不是卡住主循环。解决对每个监听 socket 在 listen 之后立刻调用SetNonBlocking。并且 accept 失败时把WSAEWOULDBLOCK当作正常情况处理不要当成致命错误打印一堆日志。4.3 压测发现接不住连接fd_set 被 select 改动过现象写了个压测脚本同时开 20 个连接但服务器只 accept 了十几个剩下的连接没有响应。断开几个后再连又有几个能进来。原因select 返回后readSet会把没有事件的 socket 清除掉。如果你在 select 返回后直接复用同一个readSet再次调用 select某些没有事件的 socket 会永久性地从集合里「消失」它们的连接永远等不到触发。解决每次 select 前必须重新从零构建fd_set。不要在进入循环前建一次然后循环里一直复用。这是 select 模型里最经典的认知差我在自己项目里就踩过这个坑。4.4 WSAStartup 版本协商不彻底现象在 Windows 老版本系统上运行WSAStartup返回成功但某些 socket 操作直接返回错误。原因请求版本号是 2.2但系统只支持 1.1。老系统会返回一个可接受的降级版本程序没检查这个版本号就直接往下走用到了 2.2 才有的 API。解决调用WSAStartup后要检查返回的wVersionRequested字段如果系统给出的主版本号小于 2直接报错退出。代码很简单但很多示例代码都没写这一层保护。4.5 退出时卡死主循环无法及时终止现象想要优雅退出程序把 while(true) 改成了 while(running)但关闭监听 socket 后 select 依然阻塞在等待中。原因select 在没有任何事件时会一直等下去。把 socket 关闭并不会让已经阻塞的 select 立即返回Windows 上它会等到超时或下一个事件。如果你的代码不打算做超时控制退出时只能靠外部强行终止线程。解决给 select 设置一个超时时间比如 500ms超时返回 0 之后检查 running 标志位。这样「响应退出」和「等待事件」就能兼得。超时参数用timeval{0, 500000}这类结构体传入。5. 从监听器到服务框架扩展与二次开发思路5.1 把单线程模型扩展成「单线程监听 工作线程处理」上面的代码是纯单线程select 到事件后直接在当前线程里 recv、发回显。这样做的缺点是如果某个客户端发来一条超大消息recv 要循环很多次才能收完期间其他端口的连接全部被晾着。常见的改造思路是主线程只负责 accept 然后把新 socket 放进一个队列工作线程从队列里取 socket 做完整的数据收发。但这样做「单线程监听」这个标题就不成立了。更贴合原意、同时又能提升吞吐的做法是保留单线程事件循环在 recv 事件到达后把数据处理部分投递到一个小型线程池。代码块事件循环 投递任务// 投递方式示意把 socket 交给线程池处理 void HandleSocketEvent(int index) { SOCKET s g_sockets[index]; if (index LISTEN_PORTS_COUNT) { // 接受新连接后只入队不做业务 SOCKET client accept(s, nullptr, nullptr); if (client ! INVALID_SOCKET) { g_sockets[g_socketCount] client; } } else { // 委托给线程池避免阻塞事件循环 g_threadPool-PostTask([s]() { char buf[4096]; int n recv(s, buf, sizeof(buf), 0); if (n 0) { // 业务处理解析协议、存储、响应 } else if (n 0) { closesocket(s); } }); } }这种改造下select 循环只负责「监测事件 快速分发」每个 socket 里的数据读取和业务逻辑在别的线程完成。注意线程池里的 recv 也应该是非阻塞的或者做好完整的阻塞式读取循环否则 socket 可能被并发读到。5.2 支持更多的端口从 64 到 256 的调整如果你要监听超过 64 个端口或者预期并发连接超过 64可以考虑在包含 winsock2.h 之前重新定义宏// 在代码文件最顶部养成习惯先定义这个宏 #define FD_SETSIZE 256 #include winsock2.hFD_SETSIZE决定了fd_set的容量。Windows 的实现里 fd_set 使用了SOCKET数组来存句柄所以调大这个宏只会增加内存占用不会限制性能。把MAX_SOCKETS也同步改成 256数组就不会越界。这个自由度是 select 模型留给你的最大的优化空间。5.3 数据结构升级用 vector 代替固定数组固定数组SOCKET g_sockets[MAX_SOCKETS]写起来直观但维护成本高。关闭一个 socket 后留下空位每次循环都要跳过 0。更顺手的做法是用std::vectorSOCKET关闭时用erase直接移除配合一个std::unordered_mapSOCKET, int反向索引判断 socket 类型。代码块vector 管理 socket 集合std::vectorSOCKET listenSockets; // 监听 socket std::vectorSOCKET clientSockets; // 客户端 socket void RemoveClient(SOCKET s) { auto it std::find(clientSockets.begin(), clientSockets.end(), s); if (it ! clientSockets.end()) { closesocket(s); clientSockets.erase(it); // 真正的移除不留空位 } }用vector管理后遍历 select 集合时只需要for (SOCKET s : clientSockets) FD_SET(s, readSet)。移除时也不用留 0 占位因为erase已经把元素彻底删掉。要注意的是select 返回后readSet依然会改变你传入的集合所以每次循环还是要重建这一点和数组版本完全一致。5.4 增加超时管理防止连接堆积单线程模型里如果某个客户端连接上了却从不发数据这个 socket 会一直占着fd_set的名额。连接一多名额很快被睡着的连接占满新的监听端口就等不到了。解决办法是在事件循环里周期性扫描所有客户端 socket记录每张 socket 的最后活跃时间超过 30 秒没有数据的就主动关闭。这个逻辑用 select 的 timeval 参数驱动每次 select 超时返回 0 时就做一次清理扫描不会打断正常的事件处理。这就是上面提到设置超时时间的实用的场景之一。5.5 关于优雅关闭的一个重要习惯服务端需要退出时不能直接 return 跳出主循环让进程结束。Windows 下进程结束后 socket 会被系统回收但如果代码里还有工作线程正在读写突然结束可能造成数据丢失或句柄泄漏。我习惯的做法是三步第一步把每个监听 socket 用shutdown(sd, SD_BOTH)禁止收发这一下 select 就会感知到异常并快速返回第二步遍历所有客户端 socket用同样的方式关闭第三步最后统一closesocket并把 vector 清空。这样事件循环不会卡死工作线程也能正确退出。6. 最后一层跑起来之前必做的验证清单写完了代码别急着交付先按下面步骤自测一遍。这套流程是我自己每次写 Windows socket 服务都用熟了的能拦住大部分问题。第一步用netstat -ano确认三个端口都在监听状态。如果只有第一个端口在监听说明监听循环写的问题多半出在 select 的 fd_set 复用上回查第 3 节的重建逻辑。第二步开三个终端窗口分别用 telnet 连接三个端口。连接成功后除了第一个端口另外两个端口如果没有任何反应检查监听 socket 有没有设置非阻塞以及 accept 之后的 socket 有没有正常加入管理数组。第三步在一个端口里输入数据服务端要能原样回显。如果收不到回显但连接没断看 recv 的返回值和 WSAGetLastError最常见的WSAEWOULDBLOCK10035说明非阻塞模式下暂时没有数据可读这是正常的不是错误。第四步断开某个客户端连接观察服务端事件循环是否还能继续处理其他端口的连接。如果其他端口跟着失灵就说明清理逻辑有漏洞socket 数组里有残留句柄回查关闭连接后的清理代码。第五步反复快速开关连接二十次然后在任务管理器里看进程句柄数是否持续增长。如果只增不减说明closesocket被跳过了很可能是 recv 返回 0 的分支没有走到清理逻辑。第六步验证服务端重启不能 bind 失败。如果每次都报WSAEADDRINUSE检查SO_REUSEADDR是否真的设置在了 bind 之前。最后有个习惯性的建议在面对 Windows socket 编程时别把SocketError和SOCKET_ERROR混为一谈去比较。select返回SOCKET_ERROR是灾难性的但accept返回INVALID_SOCKET之后再去查WSAEWOULDBLOCK才是正常流程。很多调试半天都找不到原因的问题最后都出在这两种返回值的判断混乱上。从那以后我每次写完 socket 代码都会强制自己把每个调用的返回类型和错误码判断走到一块儿。希望帮到你。本文还有配套的精品资源点击获取