简介这是一份面向Windows平台C网络编程学习者的实战代码资源聚焦“单线程同时监听多个端口”这一典型场景帮助开发者摆脱为每个端口单独开线程的传统做法降低系统资源消耗与上下文切换开销。资源以IOCPI/O完成端口为核心涉及套接字创建与绑定、CreateIoCompletionPort关联、异步accept请求、GetQueuedCompletionStatus事件循环以及OVERLAPPED结构体管理等关键知识点适合具备一定socket基础、希望深入理解高并发I/O模型的中级开发者。压缩包共2个文件包含1个cpp源文件与1个h头文件整体约3KB代码结构紧凑便于直接阅读与移植到实际项目。目前已有1647人学习下载。通过研读源码读者可掌握多端口统一调度、完成状态分发与错误处理的实现思路并在此基础上扩展线程安全与内存管理细节为构建高并发Windows网络服务打下基础。1. 单线程监听多端口一个被低估的 Windows C 实战场景很多做 Windows 网络编程的同行第一次听到「单线程同时监听多个端口」都会下意识觉得别扭——accept 不是阻塞的吗一个线程怎么可能同时盯着好几个监听套接字我当初也这么想直到在一个采集网关项目里被逼着把这条路走通设备侧要同时暴露 8001、8002、8003 三个端口分别对接配置下发、数据上报和心跳而现场机器资源紧张开三个线程各自 accept 会带来锁竞争和退出时的线程回收麻烦。后来用select把三个监听套接字塞进同一个 fd_set一个线程轮询就全搞定了代码量反而比多线程版本少。这篇笔记拆的就是这份 Windows 平台 C 代码包它用select模型在单线程里同时监听多个端口核心 API 就是socket、bind、listen、select、accept。适合正在写 Windows 服务端、做协议网关、或者被「一个端口一个线程」拖累到想重构的 C 从业者。下面从选型理由讲到能直接抄的代码再到我踩过的坑一步步来。2. 为什么是 select 而不是多线程Windows 下的选型账2.1 多线程 accept 的真实代价先算一笔账。假设你要监听 N 个端口最直觉的做法是每个端口起一个线程线程里死循环accept。这个方案在 N2、3 的时候看着没问题但问题会在几个地方冒出来。第一是线程生命周期管理。Windows 下CreateThread或std::thread创建出来的线程退出时要join或CloseHandle如果主线程要优雅关闭服务得给每个 accept 线程发信号让它从阻塞中醒来。而accept是阻塞调用你没法从外部直接打断它常见做法是closesocket监听套接字强制让它返回错误——这个操作本身就有竞态处理不好就是退出时卡死或者句柄泄漏。第二是资源开销。每个线程默认栈 1MBWindows 默认提交栈大小N 个端口就是 N MB 虚拟内存虽然不全是物理内存但在嵌入式或老机器上依然敏感。线程切换也有成本虽然 accept 阻塞时线程不占 CPU但唤醒和调度是实打实的。第三是共享状态。多个 accept 线程如果都要往同一个连接列表、同一个日志缓冲里写东西就得加锁。锁一多代码复杂度就上去了出 bug 的概率也跟着涨。2.2 select 模型的核心机制select是 Winsock 提供的 I/O 多路复用接口原型是int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timeval *timeout)。它的工作方式是你把关心的套接字放进fd_set调用select后线程阻塞直到集合里任意一个套接字「就绪」——对监听套接字来说就绪意味着有新连接进来了可以accept而不阻塞。关键点在于select一次能盯住多个套接字返回后你遍历集合哪个还在集合里就说明哪个就绪。这样单线程就能轮流处理多个端口的连接请求。Windows 下select的nfds参数被忽略只为兼容 Berkeley 套接字保留fd_set内部是一个套接字数组加计数默认上限FD_SETSIZE是 64对监听几个端口来说绰绰有余。提示select返回后fd_set会被内核修改只保留就绪的套接字。所以每次调用select前必须重新用FD_ZERO和FD_SET构建集合不能复用上一轮的。2.3 和 WSAAsyncSelect、IOCP 的边界Windows 上还有WSAAsyncSelect基于窗口消息和 IOCP完成端口。WSAAsyncSelect需要消息循环适合带 UI 的程序纯服务端用它反而别扭。IOCP 是 Windows 下性能最强的模型但它是为高并发大量连接设计的代码复杂度高出一大截监听几个端口用 IOCP 属于杀鸡用牛刀。所以选型结论很清晰端口数量在几个到几十个、连接量中等、想要代码简单可控select单线程是性价比最高的方案。这份代码包走的就是这条路。3. 把代码跑起来从 WSAStartup 到 select 主循环3.1 环境准备与工程配置代码是标准 C依赖 Winsock2。用 Visual Studio 的话新建空项目把源文件加进去然后在项目属性里链接ws2_32.lib。这一步新手最容易漏漏了就是一堆unresolved external symbol链接错误。链接方式有两种一是在代码里写#pragma comment(lib, ws2_32.lib)二是项目属性 → 链接器 → 输入 → 附加依赖项里加ws2_32.lib。我一般用前者代码自包含换机器不用重新配。头文件顺序有个经典坑winsock2.h必须在windows.h之前包含否则会拉进老版本的winsock.h导致一堆重定义错误。代码里这么写// 顺序不能反winsock2.h 必须在 windows.h 之前 #include winsock2.h #include ws2tcpip.h #include windows.h #include iostream #include vector #pragma comment(lib, ws2_32.lib)ws2tcpip.h提供了inet_pton等新式地址转换函数比老的inet_addr更安全。vector用来存监听套接字列表方便遍历。3.2 初始化 Winsock 并创建多个监听套接字Winsock 使用前必须WSAStartup用完WSACleanup。创建监听套接字的流程是socket→setsockopt设 SO_REUSEADDR→bind→listen。多个端口就是把这套流程循环执行每个端口一个套接字。// 初始化 Winsock版本 2.2 WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) { std::cerr WSAStartup failed: WSAGetLastError() std::endl; return -1; } // 要监听的端口列表 std::vectorint ports { 8001, 8002, 8003 }; std::vectorSOCKET listenSockets; for (int port : ports) { SOCKET s socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s INVALID_SOCKET) { std::cerr socket failed: WSAGetLastError() std::endl; continue; } // 允许地址复用避免重启时 TIME_WAIT 导致 bind 失败 BOOL opt TRUE; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (const char*)opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr INADDR_ANY; // 监听所有网卡 addr.sin_port htons((u_short)port); if (bind(s, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { std::cerr bind port port failed: WSAGetLastError() std::endl; closesocket(s); continue; } if (listen(s, SOMAXCONN) SOCKET_ERROR) { std::cerr listen port port failed: WSAGetLastError() std::endl; closesocket(s); continue; } listenSockets.push_back(s); std::cout listening on port port std::endl; }几个参数说明。SO_REUSEADDR设成TRUE是为了服务重启时不被TIME_WAIT状态卡住Windows 下它的行为和 Linux 略有差异但设上没坏处。INADDR_ANY表示绑定所有本地网卡地址如果只想监听某个特定 IP把inet_pton转换后的地址填进sin_addr即可。listen的第二个参数是等待队列长度SOMAXCONN让系统选一个合理值。3.3 select 主循环与 accept 分发核心循环来了。每一轮先把所有监听套接字塞进fd_set调select阻塞等待返回后遍历集合对就绪的套接字调accept。fd_set readSet; timeval tv{ 1, 0 }; // 超时 1 秒便于周期性检查退出标志 while (g_running) { FD_ZERO(readSet); SOCKET maxSock 0; // 每轮都要重新构建集合因为 select 会修改它 for (SOCKET s : listenSockets) { FD_SET(s, readSet); if (s maxSock) maxSock s; } // Windows 下第一个参数被忽略传 0 也行这里传 maxSock1 保持习惯 int ret select((int)maxSock 1, readSet, nullptr, nullptr, tv); if (ret SOCKET_ERROR) { std::cerr select failed: WSAGetLastError() std::endl; break; } if (ret 0) { continue; // 超时回去检查退出标志 } // 遍历监听套接字看哪个有新连接 for (SOCKET s : listenSockets) { if (!FD_ISSET(s, readSet)) continue; sockaddr_in clientAddr{}; int addrLen sizeof(clientAddr); SOCKET client accept(s, (sockaddr*)clientAddr, addrLen); if (client INVALID_SOCKET) { std::cerr accept failed: WSAGetLastError() std::endl; continue; } char ip[INET_ADDRSTRLEN] { 0 }; inet_ntop(AF_INET, clientAddr.sin_addr, ip, sizeof(ip)); std::cout new connection from ip : ntohs(clientAddr.sin_port) on listen socket (int)s std::endl; // 这里把 client 交给后续处理逻辑示例里直接关闭 // 实际项目应放入连接管理或工作队列 closesocket(client); } }逻辑说明select的timeout设成 1 秒是为了让循环有机会检查g_running退出标志否则select会一直阻塞程序没法优雅退出。这是单线程 select 服务端的一个常见设计取舍——用一点轮询延迟换退出可控性。如果追求极致响应可以把 timeout 设成nullptr永久阻塞退出时靠closesocket打断但那样退出逻辑更绕。FD_ISSET判断套接字是否就绪就绪的才accept。注意accept返回的新连接套接字和监听套接字是两回事监听套接字继续留在listenSockets里等下一轮新连接套接字要单独管理。3.4 资源清理与优雅退出程序结束前要关闭所有套接字并WSACleanup。顺序是反着来的先关连接套接字再关监听套接字最后WSACleanup。// 关闭所有监听套接字 for (SOCKET s : listenSockets) { closesocket(s); } listenSockets.clear(); // 清理 Winsock WSACleanup();closesocket对已经关闭的套接字再调一次会返回错误但不会崩溃所以清理时不用太担心重复关闭。WSACleanup每个成功的WSAStartup对应一次多次调用要配对。4. 避坑与排查那些让我加班到凌晨的细节4.1 bind 返回 10048端口被占用现象程序启动时bind失败WSAGetLastError()返回 10048WSAEADDRINUSE。原因端口已经被别的进程占用或者上一次程序退出后套接字还在TIME_WAIT状态没释放。解决先确认端口占用情况命令行netstat -ano | findstr :8001找到占用进程的 PID任务管理器里看是哪个程序。如果是自己的程序残留等几十秒TIME_WAIT超时或者一开始就设SO_REUSEADDR。注意SO_REUSEADDR在 Windows 下不能解决两个活跃进程绑同一端口的问题它主要解决TIME_WAIT复用。4.2 select 返回后集合被清空现象第一轮select正常第二轮开始就再也检测不到新连接。原因select会原地修改传入的fd_set只保留就绪的套接字。如果第二轮直接复用上一轮的readSet里面可能已经空了。解决每轮循环开头必须FD_ZERO清空再重新FD_SET所有监听套接字。这是 select 模型最经典的坑代码里那两行FD_ZERO和循环FD_SET不能省。4.3 头文件顺序导致重定义现象编译报大量redefinition错误涉及fd_set、timeval等类型。原因windows.h默认会包含老版winsock.h和winsock2.h冲突。解决确保winsock2.h在windows.h之前包含。如果项目里其他头文件间接包含了windows.h可以在项目属性 → C/C → 预处理器 → 预处理器定义里加WIN32_LEAN_AND_MEAN减少windows.h拉进来的东西。4.4 accept 返回的客户端套接字没设非阻塞现象单线程处理一个客户端读写时其他端口的新连接被卡住服务像死了一样。原因accept返回的客户端套接字默认是阻塞的如果你在主循环里直接对它recv一旦这个客户端不发数据整个线程就卡在recv上select循环转不动。解决要么把客户端套接字也加入select的readfds统一管理要么设成非阻塞配合状态机。这份代码包聚焦监听逻辑客户端处理部分留了接口实际项目里必须把客户端套接字纳入 select 集合否则单线程的优势就没了。4.5 退出时 select 不返回现象按 CtrlC 或设置退出标志后程序卡在select里不退出。原因select阻塞时不会主动检查你的退出标志如果 timeout 设的是nullptr它会一直等。解决给select设一个合理的 timeout比如 1 秒超时返回后检查退出标志。或者用一个自连接的套接字「唤醒」select但那样代码更复杂。我一般用 timeout 方案简单可靠。5. 进阶技巧把监听套接字和客户端套接字统一进 select5.1 统一事件循环的设计前面说了只监听不处理客户端单线程的意义只发挥了一半。真正实用的做法是把监听套接字和所有已连接的客户端套接字都放进select的readfds一个循环里同时处理「新连接」和「已有连接的数据到达」。思路是维护两个集合listenSockets存监听套接字clientSockets存客户端套接字。每轮循环把两者都FD_SET进readSetselect返回后先遍历监听套接字做accept再遍历客户端套接字做recv。std::vectorSOCKET clientSockets; while (g_running) { FD_ZERO(readSet); SOCKET maxSock 0; for (SOCKET s : listenSockets) { FD_SET(s, readSet); if (s maxSock) maxSock s; } for (SOCKET c : clientSockets) { FD_SET(c, readSet); if (c maxSock) maxSock c; } int ret select((int)maxSock 1, readSet, nullptr, nullptr, tv); if (ret 0) continue; // 先处理新连接 for (SOCKET s : listenSockets) { if (FD_ISSET(s, readSet)) { SOCKET client accept(s, nullptr, nullptr); if (client ! INVALID_SOCKET) { clientSockets.push_back(client); } } } // 再处理已有连接的数据 for (auto it clientSockets.begin(); it ! clientSockets.end(); ) { SOCKET c *it; if (FD_ISSET(c, readSet)) { char buf[1024]; int n recv(c, buf, sizeof(buf), 0); if (n 0) { // 连接关闭或出错移除 closesocket(c); it clientSockets.erase(it); continue; } // 处理收到的数据示例里回显 send(c, buf, n, 0); } it; } }这个结构下单线程同时管着多个监听端口和多个客户端连接没有锁没有线程切换逻辑线性可读。代价是如果某个客户端处理耗时比如做磁盘 IO会拖慢其他连接。所以它适合「处理快、连接数中等」的场景重计算任务还是得丢给工作线程。5.2 验证方法用 telnet 和 netstat 确认行为代码跑起来后怎么确认真的在同时监听多个端口两个手段。一是netstat -ano | findstr 8001 8002 8003能看到三个端口都处于LISTENING状态且 PID 是同一个进程这就证明单进程单线程在监听多个端口。二是用telnet 127.0.0.1 8001、telnet 127.0.0.1 8002分别连观察控制台输出。如果三个端口都能连上并打印出连接来源说明 select 分发逻辑正确。telnet 在 Windows 10 之后默认没装可以在「启用或关闭 Windows 功能」里勾选 Telnet 客户端或者用Test-NetConnection -ComputerName 127.0.0.1 -Port 8001这个 PowerShell 命令代替。5.3 一个我常用的调试习惯select模型出问题时最难查的是「哪个套接字就绪了但没被处理」。我的习惯是在每轮循环开头打印一行日志把listenSockets和clientSockets的数量打出来select返回后再打印ret的值。如果ret大于 0 但没有任何FD_ISSET命中那基本就是集合构建或遍历逻辑写错了。还有一点select的第一个参数在 Windows 下虽然被忽略但传maxSock 1是个好习惯代码移植到 Linux 时不用改。Linux 下这个参数必须传最大文件描述符加一传错会导致部分套接字被忽略。从那以后我每次写 select 循环都强制在FD_ZERO之后、FD_SET之前把集合数量打出来对一遍这个习惯帮我省了至少三次通宵排查。希望这份代码包和这些踩坑记录能帮你在 Windows 单线程多端口监听这条路上少走点弯路。本文还有配套的精品资源点击获取