简介:这是一份面向Windows平台C++网络编程学习者的实战代码资源,聚焦“单线程同时监听多个端口”这一典型场景,帮助开发者理解如何借助IOCP(I/O完成端口)机制,在单线程内高效处理多个套接字的连接请求与异步I/O,从而减少多线程模型带来的上下文切换开销。资源包共2个文件,包含1个cpp源文件与1个h头文件,压缩包约3KB,源码结构紧凑,便于直接阅读与移植到自己的工程中。代码覆盖套接字创建与绑定、IOCP创建及套接字关联、异步accept请求投递、GetQueuedCompletionStatus循环处理完成事件、OVERLAPPED结构体使用以及错误处理等关键环节,适合具备一定socket基础的读者对照学习。目前已有1647人学习下载,可作为理解Windows高并发网络模型、掌握单线程多端口监听实现思路的参考范例。
1. 单线程同时监听多个端口:Windows 下 C++ 的 select 模型到底怎么落地
一个后台服务要同时接 HTTP 管理口、WebSocket 推送口、内部 RPC 口,三个端口各跑一个线程当然能跑,但线程一多,锁竞争、上下文切换、调试难度全上来了。很多做 Windows 桌面端或轻量服务端的同行都会问:能不能只用一个线程,同时监听多个端口?答案是可以,Windows 平台下最经典的做法就是select多路复用。它把多个监听套接字和已连接套接字放进同一个fd_set,单线程轮询哪个端口有事件就处理哪个,不需要为每个端口开线程。这套方案适合连接数在几百以内、逻辑不复杂的场景,比如设备管理后台、本地工具服务、嵌入式网关。下面从原理到代码,把这条路走通。
2. 为什么单线程多端口在 Windows 上首选 select:原理与选型理由
2.1 select 的多路复用机制到底在做什么
select的核心思想是:把一批套接字交给内核,让内核帮你盯着,只要其中任意一个可读、可写或出错,函数就返回,并告诉你哪些套接字就绪。单线程里写一个循环,每次调用select,返回后遍历就绪集合,分别处理监听套接字的accept和已连接套接字的recv/send。这样多个端口共享同一个线程,没有线程创建开销,也没有跨线程数据同步问题。
Windows 的select和 Linux 在语义上基本一致,但有几个平台差异必须注意。第一,Windows 的fd_set是一个结构体,内部是套接字数组加计数,不是位图,所以FD_SETSIZE默认是 64,意味着单个fd_set最多放 64 个套接字。第二,Windows 下select的第一个参数nfds被忽略,传 0 即可。第三,Windows 的select对空集合的处理和 Linux 略有不同,如果三个集合都为空,行为未定义,必须至少放一个套接字。
单线程多端口的关键在于:监听套接字本身也是套接字,也可以放进readfds。当某个端口有新连接时,对应的监听套接字变为可读,select返回后你调用accept就能拿到新连接。新连接再被加入readfds,下一轮继续监听它的数据。整个过程只有一个线程,事件驱动,逻辑清晰。
2.2 和 IOCP、多线程阻塞模型的对比
Windows 下做网络服务,绕不开 IOCP。IOCP 是真正的异步 I/O 模型,性能上限高,适合高并发。但它复杂度也高:需要理解完成端口、重叠 I/O、工作线程池、引用计数,代码量至少是select的三到五倍。对于连接数几百以内、业务逻辑不重的场景,IOCP 属于杀鸡用牛刀,调试成本反而拖慢进度。
多线程阻塞模型则是另一个极端:每个端口一个线程,每个线程里accept后为每个连接再开线程或丢进线程池。这种写法入门快,但线程数随连接数增长,Windows 下线程切换开销明显,而且共享数据要加锁,稍不注意就死锁。单线程select正好卡在中间:比多线程省资源,比 IOCP 简单,适合中小规模、逻辑集中的服务。
选型建议很直接:连接数预期在 500 以内、单连接数据量不大、业务逻辑不涉及阻塞调用,就用单线程select。如果连接数上千、或者有大量磁盘 I/O、数据库查询等阻塞操作,老老实实上 IOCP 或线程池,别硬扛。
2.3 最小可运行框架:WSAStartup 与多端口监听初始化
先把 Windows Socket 的初始化和多端口监听的骨架搭起来。下面这段代码创建三个监听套接字,分别绑定 8080、8081、8082,并全部设为非阻塞模式。非阻塞是必须的,否则accept在select返回后仍可能阻塞,破坏单线程事件循环。
#include <winsock2.h> #include <ws2tcpip.h> #include <iostream> #include <vector> #pragma comment(lib, "ws2_32.lib") const int PORT_COUNT = 3; const int PORTS[PORT_COUNT] = { 8080, 8081, 8082 }; // 创建一个监听套接字并绑定到指定端口 SOCKET CreateListenSocket(int port) { SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock == INVALID_SOCKET) { std::cerr << "socket failed: " << WSAGetLastError() << std::endl; return INVALID_SOCKET; } // 设置端口复用,避免重启时 TIME_WAIT 导致绑定失败 BOOL reuse = TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(static_cast<u_short>(port)); if (bind(listenSock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { std::cerr << "bind port " << port << " failed: " << WSAGetLastError() << std::endl; closesocket(listenSock); return INVALID_SOCKET; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { std::cerr << "listen port " << port << " failed: " << WSAGetLastError() << std::endl; closesocket(listenSock); return INVALID_SOCKET; } // 设为非阻塞,保证 accept 不会卡住事件循环 u_long nonBlocking = 1; ioctlsocket(listenSock, FIONBIO, &nonBlocking); std::cout << "listening on port " << port << std::endl; return listenSock; } int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { std::cerr << "WSAStartup failed" << std::endl; return 1; } std::vector<SOCKET> listenSockets; for (int i = 0; i < PORT_COUNT; ++i) { SOCKET s = CreateListenSocket(PORTS[i]); if (s != INVALID_SOCKET) { listenSockets.push_back(s); } } if (listenSockets.empty()) { std::cerr << "no listen socket created" << std::endl; WSACleanup(); return 1; } // 后续事件循环在这里展开 // ... for (SOCKET s : listenSockets) { closesocket(s); } WSACleanup(); return 0; }这段代码做了四件事:初始化 Winsock、为每个端口创建套接字、绑定并监听、设为非阻塞。SO_REUSEADDR是血泪经验,不加的话服务重启时如果端口处于TIME_WAIT,bind会直接失败,调试时反复重启会让人抓狂。ioctlsocket的FIONBIO参数把套接字切成非阻塞,这是单线程事件循环的前提。listen的第二个参数SOMAXCONN让系统决定等待队列长度,一般够用。
参数说明:AF_INET表示 IPv4,SOCK_STREAM表示 TCP,IPPROTO_TCP指定协议。INADDR_ANY表示绑定所有网卡。端口用htons转成网络字节序。如果只想监听本机,把INADDR_ANY换成inet_addr("127.0.0.1")。
3. 把多个监听套接字塞进 fd_set:事件循环的完整实现
3.1 fd_set 的构建与 select 调用细节
fd_set在 Windows 下是一个结构体,用FD_ZERO清空,用FD_SET添加套接字。每次调用select前必须重新构建readfds,因为select会修改集合内容,只保留就绪的套接字。这是新手最容易翻车的地方:如果复用上一次的fd_set,会漏掉事件。
下面把事件循环补全。维护两个集合:listenSockets存所有监听套接字,clientSockets存所有已连接套接字。每轮循环把两者都加入readfds,调用select,然后先处理监听套接字的accept,再处理客户端套接字的recv。
// 事件循环主体 std::vector<SOCKET> clientSockets; while (true) { fd_set readfds; FD_ZERO(&readfds); // 把所有监听套接字加入读集合 for (SOCKET s : listenSockets) { FD_SET(s, &readfds); } // 把所有客户端套接字加入读集合 for (SOCKET s : clientSockets) { FD_SET(s, &readfds); } // Windows 下第一个参数忽略,传 0 // 超时设为 1 秒,避免空转占满 CPU timeval timeout{ 1, 0 }; int ret = select(0, &readfds, nullptr, nullptr, &timeout); if (ret == SOCKET_ERROR) { std::cerr << "select error: " << WSAGetLastError() << std::endl; break; } if (ret == 0) { // 超时,继续下一轮 continue; } // 先处理监听套接字:有新连接进来 for (SOCKET listenSock : listenSockets) { if (FD_ISSET(listenSock, &readfds)) { sockaddr_in clientAddr{}; int addrLen = sizeof(clientAddr); SOCKET clientSock = accept(listenSock, (sockaddr*)&clientAddr, &addrLen); if (clientSock != INVALID_SOCKET) { u_long nonBlocking = 1; ioctlsocket(clientSock, FIONBIO, &nonBlocking); clientSockets.push_back(clientSock); std::cout << "new client on port " << ntohs(clientAddr.sin_port) << std::endl; } } } // 再处理客户端套接字:有数据可读 for (auto it = clientSockets.begin(); it != clientSockets.end(); ) { SOCKET clientSock = *it; if (FD_ISSET(clientSock, &readfds)) { char buffer[4096]; int bytes = recv(clientSock, buffer, sizeof(buffer), 0); if (bytes > 0) { // 回显收到的数据,实际业务在这里分发 send(clientSock, buffer, bytes, 0); ++it; } else { // bytes == 0 表示对端关闭,bytes < 0 表示出错 closesocket(clientSock); it = clientSockets.erase(it); } } else { ++it; } } }这段循环的逻辑顺序很重要:先accept新连接,再处理已有连接的数据。如果反过来,新连接要等到下一轮才被处理,延迟增加。select的超时设为 1 秒,既不会空转烧 CPU,又能及时响应事件。FD_ISSET用来判断某个套接字是否在就绪集合里。
参数说明:select第一个参数在 Windows 下无意义,传 0。第二个参数是读集合,第三个是写集合,第四个是异常集合,不需要就传nullptr。第五个参数是超时,timeval的tv_sec是秒,tv_usec是微秒。传nullptr表示无限等待,单线程服务里不建议,因为无法处理定时任务。
3.2 客户端连接的生命周期管理与 FD_SETSIZE 限制
clientSockets用std::vector管理,新连接push_back,断开时erase。这里有个性能细节:erase是 O(n) 操作,连接多的时候频繁增删会变慢。常见做法是用std::unordered_map<SOCKET, ClientInfo>或者数组加空闲链表。对于几百连接,vector够用,但要知道边界在哪。
Windows 的FD_SETSIZE默认是 64,意味着单个fd_set最多放 64 个套接字。如果你要监听 3 个端口,每个端口 20 个连接,加起来 63 个,刚好卡在边界。超过 64 个,FD_SET会越界写入,行为未定义,可能直接崩溃。这是单线程select在 Windows 上最大的硬限制。
解决办法有三个。第一,在包含winsock2.h之前定义#define FD_SETSIZE 1024,重新编译。注意必须放在#include <winsock2.h>之前,否则不生效。第二,如果连接数确实多,改用WSAPoll,它没有 64 的限制,语义和poll类似。第三,直接上 IOCP。我一般会在项目初期就定义FD_SETSIZE为 1024,给自己留余量,避免后期改起来麻烦。
// 必须放在 winsock2.h 之前 #define FD_SETSIZE 1024 #include <winsock2.h>这个宏定义会影响fd_set结构体的大小,进而影响select的行为。改完之后,单个fd_set能放 1024 个套接字。但注意,select的性能会随套接字数量增加而下降,因为每次调用都要把整个集合从用户态拷贝到内核态。连接数超过几百后,WSAPoll或 IOCP 更合适。
3.3 非阻塞 accept 与 recv 的错误处理
非阻塞套接字下,accept和recv返回SOCKET_ERROR是常态,必须区分「暂时没数据」和「真出错」。WSAGetLastError()返回WSAEWOULDBLOCK表示当前没有数据,不是错误,继续下一轮即可。返回其他错误码才需要关闭连接。
// 非阻塞 accept 的错误处理 SOCKET clientSock = accept(listenSock, (sockaddr*)&clientAddr, &addrLen); if (clientSock == INVALID_SOCKET) { int err = WSAGetLastError(); if (err != WSAEWOULDBLOCK) { std::cerr << "accept failed: " << err << std::endl; } // WSAEWOULDBLOCK 表示没有更多待处理连接,忽略 } else { // 正常处理新连接 } // 非阻塞 recv 的错误处理 int bytes = recv(clientSock, buffer, sizeof(buffer), 0); if (bytes > 0) { // 正常收到数据 } else if (bytes == 0) { // 对端正常关闭 closesocket(clientSock); } else { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { // 当前无数据,不是错误 } else { // 真出错,关闭连接 closesocket(clientSock); } }WSAEWOULDBLOCK是 Windows 下非阻塞 I/O 的核心错误码,等价于 Linux 的EAGAIN。不处理它,程序会在没有数据时误关连接,表现为客户端频繁掉线。另一个常见错误是WSAECONNRESET,表示对端强制关闭,这时候recv返回错误,直接关闭套接字即可,不要试图继续读写。
4. 单线程多端口服务的避坑与排查清单
4.1 端口绑定失败:SO_REUSEADDR 与 TIME_WAIT
现象:服务第一次启动正常,Ctrl+C 停止后立刻重启,bind返回WSAEADDRINUSE,提示地址已在使用。
原因:TCP 连接关闭后,主动关闭方会进入TIME_WAIT状态,持续 2MSL,Windows 默认约 4 分钟。这段时间内端口不能被重新绑定。
解决:在bind之前设置SO_REUSEADDR。代码里已经加了,但要注意setsockopt必须在bind之前调用,顺序反了不生效。如果还不行,检查是否有其他进程占用了端口,用netstat -ano | findstr 8080查 PID,再用任务管理器结束。
4.2 select 返回后套接字不在就绪集合:fd_set 复用错误
现象:明明有数据到达,select也返回了大于 0 的值,但FD_ISSET判断为假,数据没被处理。
原因:select会修改传入的fd_set,只保留就绪的套接字。如果下一轮循环没有重新FD_ZERO和FD_SET,而是复用上一轮的集合,就会丢失监听目标。
解决:每轮循环开头必须重新构建readfds。这是select的固定套路,没有捷径。我见过有人把fd_set定义在循环外面,结果调试了一下午,最后发现是这个原因。
4.3 连接数超过 64 导致崩溃:FD_SETSIZE 默认值陷阱
现象:服务运行一段时间后突然崩溃,或者select返回奇怪的值,FD_SET写入时栈被破坏。
原因:Windows 的FD_SETSIZE默认 64,fd_set结构体里的数组只有 64 个元素。超过 64 个套接字继续FD_SET,就是越界写。
解决:在#include <winsock2.h>之前#define FD_SETSIZE 1024。注意这个宏必须放在所有 Winsock 头文件之前,放在后面无效。如果连接数确实超过 1024,换WSAPoll或 IOCP。
4.4 客户端断开后套接字泄漏:recv 返回 0 未处理
现象:服务运行几小时后,select返回错误,提示套接字数量过多,新连接无法接入。
原因:客户端关闭连接时,recv返回 0,代码里没有closesocket和从clientSockets移除,导致套接字句柄泄漏。Windows 下套接字是内核对象,泄漏多了会耗尽句柄。
解决:recv返回 0 或负数(非WSAEWOULDBLOCK)时,必须closesocket并从集合中删除。用vector管理时注意迭代器失效,erase会返回下一个有效迭代器,代码里已经处理了。
4.5 单线程里的阻塞调用:一个慢查询拖垮所有端口
现象:某个端口的请求处理变慢,其他端口也跟着卡住,整体响应时间飙升。
原因:单线程事件循环里如果调用了阻塞函数,比如同步数据库查询、文件读写、Sleep,整个循环被卡住,其他套接字的事件无法处理。
解决:把阻塞操作移到工作线程,或者用异步接口。单线程select的前提是每个事件处理都足够快,毫秒级完成。如果业务逻辑里有耗时操作,要么改架构,要么用线程池把结果回传。这是单线程模型的边界,越过去就得换方案。
5. 进阶技巧:用 WSAPoll 突破 64 限制并保持单线程
select在 Windows 上的 64 套接字限制是硬伤,虽然可以通过FD_SETSIZE宏缓解,但select的性能随套接字数量线性下降。WSAPoll是 Windows 提供的另一个多路复用接口,语义类似 Linux 的poll,没有 64 的限制,而且不需要每轮重新构建集合。
WSAPoll的核心是一个WSAPOLLFD数组,每个元素包含套接字、关注的事件和返回的事件。调用前设置events,调用后检查revents。数组只需要构建一次,后续修改对应元素即可。
#include <winsock2.h> #include <vector> #include <iostream> // 用 WSAPoll 重写事件循环 std::vector<WSAPOLLFD> pollfds; // 初始化:加入所有监听套接字 for (SOCKET s : listenSockets) { WSAPOLLFD pfd{}; pfd.fd = s; pfd.events = POLLRDNORM; // 关注可读事件 pollfds.push_back(pfd); } while (true) { // 超时 1000 毫秒 int ret = WSAPoll(pollfds.data(), static_cast<ULONG>(pollfds.size()), 1000); if (ret == SOCKET_ERROR) { std::cerr << "WSAPoll error: " << WSAGetLastError() << std::endl; break; } if (ret == 0) { continue; // 超时 } // 遍历所有就绪的套接字 for (size_t i = 0; i < pollfds.size(); ) { if (pollfds[i].revents & POLLRDNORM) { SOCKET s = pollfds[i].fd; // 判断是监听套接字还是客户端套接字 bool isListen = false; for (SOCKET ls : listenSockets) { if (ls == s) { isListen = true; break; } } if (isListen) { // 处理新连接 SOCKET clientSock = accept(s, nullptr, nullptr); if (clientSock != INVALID_SOCKET) { u_long nonBlocking = 1; ioctlsocket(clientSock, FIONBIO, &nonBlocking); WSAPOLLFD pfd{}; pfd.fd = clientSock; pfd.events = POLLRDNORM; pollfds.push_back(pfd); } } else { // 处理客户端数据 char buffer[4096]; int bytes = recv(s, buffer, sizeof(buffer), 0); if (bytes > 0) { send(s, buffer, bytes, 0); } else { closesocket(s); pollfds.erase(pollfds.begin() + i); continue; // 不递增 i,因为当前元素已被移除 } } } ++i; } }WSAPoll的优势在于:没有 64 的限制,数组大小只受内存约束;不需要每轮重新构建集合,只需要在连接增删时修改数组;性能比select更稳定。代价是WSAPoll在 Windows 上的实现细节和select略有不同,比如POLLRDNORM和POLLIN的语义差异,需要查文档确认。
参数说明:WSAPoll第一个参数是WSAPOLLFD数组指针,第二个是元素个数,第三个是超时毫秒数。events字段指定关注的事件,常用POLLRDNORM表示普通数据可读,POLLWRNORM表示可写。revents是返回的事件,用位与判断。
一个容易忽略的细节:WSAPoll在连接被对端关闭时,revents会包含POLLHUP或POLLERR,这时候也要关闭套接字。代码里简化成recv返回非正数就关闭,实际项目里建议同时检查revents的标志位。
从select迁移到WSAPoll的时机:连接数稳定超过 64,或者select的 CPU 占用明显升高。迁移成本不高,主要是把fd_set的构建和检查换成数组遍历。我一般在新项目里直接用WSAPoll,省得后期改。但如果团队里有人对select更熟,用select加FD_SETSIZE宏也能撑到几百连接,不必强求。
最后说一个习惯:单线程多端口服务上线前,我一定会用netstat -ano确认端口监听状态,再用脚本模拟 100 个并发连接跑半小时,观察句柄数和内存是否稳定。这套方案不复杂,但边界清楚,用对了场景就很省心。希望帮到你。
本文还有配套的精品资源,点击获取