简介:围绕 Windows 平台 C++ 网络编程中的多端口监听需求,这份示例代码以 I/O 完成端口(IOCP)为核心,演示如何在单个线程内同时处理多个监听套接字的连接请求,适合已掌握 socket 基础、希望理解异步 I/O 调度与 accept 流程的中级开发者参考。压缩包体量很小,共 2 个文件、约 3KB,由一个 cpp 源文件和一个 h 头文件组成,前者承担套接字创建、完成端口关联与事件循环的实现,后者给出接口与结构声明,便于整体阅读和移植。内容涵盖套接字绑定监听、将多个套接字挂到同一完成端口、投递异步接收请求、在主循环中用 GetQueuedCompletionStatus 取回完成事件,并借助 OVERLAPPED 结构跟踪操作状态,同时保留了必要的错误处理分支。已有 1652 人学习,说明该思路在实际项目中具备参考价值。读者可据此对比多线程模型与单线程 IOCP 在上下文切换开销上的差异,理解线程安全与资源释放要点,并将其作为自建服务端框架的起点。
1. 单线程监听多个端口:别急着开线程,先算清 select 的 64 个槽位
一个后台服务要同时接 8001、8002、8003 三个端口,很多人的第一反应是开三个线程,每个线程一个 accept。实际在 Windows 平台上,用 C++ 写单线程多端口监听,socket 加 select 就能把多个监听句柄一起管起来,代码量反而更少。反直觉的地方在于:真正卡住你的不是 CPU 调度,而是 fd_set 默认 64 个槽位、accept 只取一次连接,以及阻塞 socket 把整个循环拖死。这套代码解决的就是“一个循环里同时盯多个端口”的问题,适合端口数在几十个以内、不想引入线程池和锁的后台小服务、调试工具和协议网关。读完你至少能判断:你的场景该用 select,还是该换 WSAEventSelect,还是直接上 IOCP。
2. 端口监听骨架:从 WSAStartup 到 listen 的五个参数
单线程多端口不是把多个 accept 硬塞进一个循环就完事。监听 socket 的创建顺序、地址复用选项、阻塞模式,任何一处没对齐,后面 select 写得再漂亮也会在 bind 或 accept 上翻车。这一章先把每个端口独立的监听句柄立起来,下一章再把这些句柄塞进同一个 select 集合。
2.1 初始化 Winsock 与创建监听 socket
Windows 上跑 socket 代码,第一件事是 WSAStartup,不是直接 socket()。常见做法是请求 2.2 版本,并校验返回的 wVersion,避免运行环境只给出低版本实现。WSAStartup 失败返回的是错误码,不是 WSAGetLastError,这一点和后面的 socket 调用不一样,排查时要分开看。
#define WIN32_LEAN_AND_MEAN #include <winsock2.h> #include <ws2tcpip.h> #include <iostream> #include <vector> #include <map> #include <string> #pragma comment(lib, "ws2_32.lib") bool InitWinsock() { WSADATA wsaData{}; int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { std::cerr << "WSAStartup failed: " << ret << std::endl; return false; } // 确认实际协商到的版本,避免只拿到 1.1 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { std::cerr << "Winsock version mismatch" << std::endl; WSACleanup(); return false; } return true; }MAKEWORD(2, 2)的低字节是主版本,高字节是次版本;wVersion是系统实际给的版本。校验版本不是形式主义,有些老环境只支持到 1.1,后面用WSAPoll或某些选项会直接失败。WSACleanup要和成功的WSAStartup配对,进程退出前调用一次即可,不要在每个线程里反复初始化和清理。
创建监听 socket 用socket(AF_INET, SOCK_STREAM, IPPROTO_TCP),三个参数分别对应 IPv4、流式套接字、TCP 协议。返回值是SOCKET,失败返回INVALID_SOCKET,错误码用WSAGetLastError()取。这里不要用int接SOCKET,64 位下SOCKET是无符号句柄类型,用int会在高句柄值上出问题。
2.2 bind 的地址复用与端口占用排查
Windows 上SO_REUSEADDR的语义和很多人的直觉不一样:它允许绑定一个已被其他 socket 占用的地址,甚至可能让另一个进程把你的连接接走。这不是玄学,是血泪经验。更稳妥的做法是监听 socket 设置SO_EXCLUSIVEADDRUSE,明确独占端口,绑定冲突时直接失败,而不是悄悄把流量分给别人。
SOCKET CreateListenSocket(unsigned short port) { SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s == INVALID_SOCKET) { std::cerr << "socket failed: " << WSAGetLastError() << std::endl; return INVALID_SOCKET; } // Windows 下建议独占端口,避免被其他进程复用 BOOL exclusive = TRUE; if (setsockopt(s, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, reinterpret_cast<const char*>(&exclusive), sizeof(exclusive)) == SOCKET_ERROR) { std::cerr << "SO_EXCLUSIVEADDRUSE failed: " << WSAGetLastError() << std::endl; } sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(port); if (bind(s, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == SOCKET_ERROR) { std::cerr << "bind " << port << " failed: " << WSAGetLastError() << std::endl; closesocket(s); return INVALID_SOCKET; } if (listen(s, SOMAXCONN) == SOCKET_ERROR) { std::cerr << "listen " << port << " failed: " << WSAGetLastError() << std::endl; closesocket(s); return INVALID_SOCKET; } // 监听 socket 必须非阻塞,后面 accept 才能循环取干净 u_long nonBlocking = 1; if (ioctlsocket(s, FIONBIO, &nonBlocking) == SOCKET_ERROR) { std::cerr << "ioctlsocket failed: " << WSAGetLastError() << std::endl; closesocket(s); return INVALID_SOCKET; } return s; }sin_addr.s_addr = htonl(INADDR_ANY)表示绑定本机所有 IPv4 地址;如果服务只给内网或本机调试用,改成inet_addr("127.0.0.1")或指定网卡地址更安全。htons(port)把主机字节序转成网络字节序,端口号在协议头里是大端,漏掉这一步会 bind 到错误的端口。
SO_EXCLUSIVEADDRUSE必须在bind之前设置,而且不要和SO_REUSEADDR同时开。两者同时存在时行为取决于设置顺序和系统状态,排查起来非常难受。bind 失败常见错误码是WSAEADDRINUSE(10048),先看端口是不是被别的进程占了,再看是不是上一个实例没退干净。Windows 上低端口没有 Unix 那种 1024 以下必须 root 的限制,但防火墙和杀毒软件可能拦,现象是 bind 成功、客户端连不上,这种要用netstat -ano对照 PID 看。
2.3 listen 与 accept 的阻塞边界
listen的第二个参数是 backlog,常见做法直接给SOMAXCONN,让系统取最大值。这个值不是“最大连接数”,只是未完成握手队列和已完成握手队列的提示值,真正能接多少连接还受 fd_set 容量、内存和业务处理速度限制。单线程多端口场景里,listen 本身不是瓶颈,accept 的取连接方式才是。
accept返回一个新的已连接 socket,原来的监听 socket 继续留在监听状态。第三个参数addrlen是值-结果参数,传入时是缓冲区大小,返回时是实际地址长度,必须初始化成sizeof(sockaddr_in)。如果传 0 或没初始化,可能拿到截断的地址,日志里对端 IP 变成 0.0.0.0。
// 反例:单端口阻塞 accept,第二个端口没有机会被处理 SOCKET listenOne = CreateListenSocket(8001); while (true) { sockaddr_in peer{}; int peerLen = sizeof(peer); SOCKET client = accept(listenOne, reinterpret_cast<sockaddr*>(&peer), &peerLen); if (client == INVALID_SOCKET) { std::cerr << "accept failed: " << WSAGetLastError() << std::endl; break; } // 这里如果直接阻塞 recv,整个循环就停住了 closesocket(client); }这段代码不是拿来用的,是用来对照的:一个 accept 只能盯一个监听 socket,而且如果client后面直接recv,客户端不发数据,整个循环就卡死。单线程多端口的核心不是“把 accept 放进 while”,而是“先问 select 哪个监听 socket 可读,再对可读的那个循环 accept 到取干净”。下一章把这个顺序拆开。
3. 把多个监听 socket 塞进 select:fd_set 位图、超时分支与跨端口 accept
select 在 Windows 上的行为和 Linux 有一处关键差异:第一个参数nfds被忽略,写 0 就行;但fd_set的结构是“计数 + 数组”,默认容量由FD_SETSIZE决定,Windows 头文件里通常是 64。也就是说,你不改这个宏,第 65 个 socket 调FD_SET不会报错,而是静默不加入集合,客户端连上来后没有任何反应。这个坑在单线程多端口里特别常见,因为监听 socket 加客户端 socket 很容易超过 64。
3.1 fd_set 的容量、拷贝与 FD_SETSIZE 的真实含义
fd_set不是链表,是一个固定长度数组加一个计数。FD_SET宏在计数未超过FD_SETSIZE时把句柄写进数组并加一,超过时直接放弃。FD_ISSET只在这个数组里线性查找。所以“select 能监听多少 socket”首先取决于这个宏,而不是系统上限。
要扩容,必须在包含winsock2.h之前定义FD_SETSIZE,而且所有翻译单元保持一致。只在某一个 .cpp 里改,另一个 .cpp 里按默认 64 编译,两边看到的fd_set结构大小不同,运行时会写出界或读错位,这种问题用调试器都不好抓。
// 必须放在所有 Winsock 头文件之前 #define FD_SETSIZE 1024 #include <winsock2.h> #include <ws2tcpip.h>FD_SETSIZE改成 1024 只是把数组放大,不代表 1024 个连接都能高效处理。select 每次返回后要线性扫描集合,连接数上千时 CPU 会花在遍历上。单线程多端口在几十个监听 socket、几百个客户端以内是舒服的;再往上,应该考虑 WSAEventSelect 或 IOCP,而不是继续加宏。
另一个细节是fd_set的拷贝。select 会修改传入的集合,把不可读的句柄清掉。所以不能把master集合直接传进去,否则一轮之后监听 socket 可能被清没。常见做法是维护一个master,每轮循环复制一份readSet传给 select。
3.2 select 的读集合、超时与返回值分支
select 的五个参数在 Windows 上是:忽略的 nfds、读集合、写集合、异常集合、超时。单线程监听多端口通常只关心读集合,写集合和异常集合传nullptr。超时用timeval,tv_sec是秒,tv_usec是微秒;传nullptr表示一直等,传{0, 0}表示立即返回。
fd_set master; FD_ZERO(&master); for (auto& kv : listenPorts) { FD_SET(kv.first, &master); } while (true) { fd_set readSet = master; // 每轮复制,select 会修改它 timeval tv{1, 0}; // 1 秒超时,方便处理退出标志 int n = select(0, &readSet, nullptr, nullptr, &tv); if (n == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAEINTR) { continue; // 被信号打断,重试 } std::cerr << "select failed: " << err << std::endl; break; } if (n == 0) { continue; // 超时,没有可读事件 } // 先处理监听 socket,再处理客户端 socket }返回值n是可读句柄总数,不是字节数。返回 0 表示超时,返回SOCKET_ERROR要取WSAGetLastError()。WSAEINTR一般来自控制台中断或调试器暂停,重试即可;WSAENOTSOCK(10038)通常说明集合里有已经关闭的 socket,后面避坑章会展开。超时不要设成 0 轮询,单线程空转会把 CPU 吃满;也不要设成nullptr后没有退出手段,调试时杀进程都杀不干净。
listenPorts建议用std::map<SOCKET, unsigned short>或std::unordered_map维护监听句柄到端口号的映射。select 只告诉你“这个句柄可读”,不告诉你“这是哪个端口”。accept 之后虽然可以用getsockname取本地端口,但在日志和统计里提前映射更直接,也方便排查“到底哪个端口没接住连接”。
3.3 多端口 accept 的循环写法:为什么一次只 accept 一个连接会翻车
select 返回可读,只代表监听 socket 的 accept 队列里至少有一个连接。可能同时到了五个,也可能一个。正确写法是对每个可读的监听 socket 循环 accept,直到返回WSAEWOULDBLOCK。如果只 accept 一次就回去 select,水平触发下一般还会再次返回,但吞吐会明显变差;如果监听 socket 是阻塞的,第二次 accept 在队列空时直接卡住,整个多端口循环就停在这一行。
for (auto& kv : listenPorts) { SOCKET listenSock = kv.first; unsigned short localPort = kv.second; if (!FD_ISSET(listenSock, &readSet)) { continue; } while (true) { sockaddr_in peer{}; int peerLen = sizeof(peer); SOCKET client = accept(listenSock, reinterpret_cast<sockaddr*>(&peer), &peerLen); if (client == INVALID_SOCKET) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { break; // 队列取干净了 } std::cerr << "accept error on " << localPort << ": " << err << std::endl; break; } // 新连接也必须非阻塞,否则后续 recv 会拖死循环 u_long nb = 1; ioctlsocket(client, FIONBIO, &nb); std::cout << "accepted on " << localPort << " from " << inet_ntoa(peer.sin_addr) << ":" << ntohs(peer.sin_port) << std::endl; // 真实业务:FD_SET(client, &master); 并维护客户端状态 closesocket(client); } }WSAEWOULDBLOCK(10035)在这里不是错误,是“暂时没有更多连接”的正常信号。其他错误要区分:WSAECONNRESET(10054)可能是客户端在握手后立刻断开;WSAENOBUFS(10055)通常和缓冲区或句柄耗尽有关;WSAEINVAL可能是监听 socket 已经关闭。accept 循环里不要做耗时业务,收到连接后要么塞进队列,要么把客户端 socket 加入 select,主循环继续转。
closesocket(client)是 demo 的简化写法,目的是把注意力放在 accept 上。真实服务不能接了就关,否则客户端发送数据会收到 RST。下一节把客户端 socket 接入 select 的边界说清楚。
3.4 新连接的归属映射与非阻塞 recv
客户端 socket 接入 select 后,master里既有监听 socket 也有已连接 socket。每轮 select 返回,先遍历监听 socket 做 accept,再遍历客户端集合做 recv。顺序不能反:如果先处理客户端,新到连接这一轮不会被接收,虽然下一轮还能接,但延迟会上去。
客户端 socket 必须非阻塞。recv返回大于 0 是实际字节数;返回 0 是对端正常关闭;返回SOCKET_ERROR且错误码为WSAEWOULDBLOCK表示这一轮没数据,继续等;其他错误才关闭连接。不要在recv返回 0 时还去FD_ISSET,先FD_CLR再closesocket,否则下一轮 select 会拿到无效句柄。
std::map<SOCKET, std::string> recvBuffers; // select 返回后,先处理监听 socket 的 accept, // 再遍历客户端集合处理可读 for (auto it = recvBuffers.begin(); it != recvBuffers.end(); ) { SOCKET client = it->first; if (!FD_ISSET(client, &readSet)) { ++it; continue; } char buf[4096]; int n = recv(client, buf, sizeof(buf), 0); if (n > 0) { it->second.append(buf, n); // 真实协议在这里按长度字段或分隔符切包 ++it; } else if (n == 0) { FD_CLR(client, &master); closesocket(client); it = recvBuffers.erase(it); } else { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { ++it; } else { FD_CLR(client, &master); closesocket(client); it = recvBuffers.erase(it); } } }recvBuffers只是示意,真实业务还要记录每个连接的本地端口、对端地址、最后活跃时间、发送缓冲区。单线程的好处是不用加锁,坏处是任何一个连接的处理都不能阻塞。文件读写、数据库查询、DNS 解析这些操作要么改成异步,要么丢到工作线程,主循环只做 socket 收发和状态机。
还有一个容易忽略的点:FD_SET(client, &master)之前要检查master.fd_count是否已经接近FD_SETSIZE。超过上限时FD_SET静默失败,客户端连接建立了但永远收不到数据。可以在 accept 后判断master.fd_count < FD_SETSIZE,超过就拒绝并记录日志,比悄悄丢连接好排查。
4. WSAEventSelect 备选:事件数组的 64 上限与 FD_ACCEPT 清理顺序
select 写起来直白,但每轮要重建集合、线性扫描,端口和连接一多就显出笨重。Windows 还提供 WSAEventSelect,把网络事件映射到事件对象,主循环用WSAWaitForMultipleEvents等事件,拿到事件后再用WSAEnumNetworkEvents查具体发生了什么。它同样可以单线程跑多个监听端口,而且不需要每轮复制 fd_set,代码结构更像事件驱动。
4.1 事件对象数组与 WSAWaitForMultipleEvents 的 64 上限
每个 socket 配一个WSAEVENT,用WSACreateEvent创建,用WSAEventSelect绑定关注的事件。监听 socket 通常关注FD_ACCEPT和FD_CLOSE,已连接 socket 关注FD_READ、FD_WRITE、FD_CLOSE。WSAEventSelect会自动把 socket 设为非阻塞,这一点比 select 省一步,但也意味着后面 accept 和 recv 必须按非阻塞语义写。
std::vector<SOCKET> listenSockets; std::vector<WSAEVENT> events; for (unsigned short port : {8001, 8002, 8003}) { SOCKET s = CreateListenSocket(port); if (s == INVALID_SOCKET) { continue; } WSAEVENT ev = WSACreateEvent(); if (ev == WSA_INVALID_EVENT) { closesocket(s); continue; } if (WSAEventSelect(s, ev, FD_ACCEPT | FD_CLOSE) == SOCKET_ERROR) { std::cerr << "WSAEventSelect failed: " << WSAGetLastError() << std::endl; WSACloseEvent(ev); closesocket(s); continue; } listenSockets.push_back(s); events.push_back(ev); }WSAWaitForMultipleEvents一次最多等WSA_MAXIMUM_WAIT_EVENTS个事件,这个常量在 Windows 上是 64。也就是说,监听 socket 加客户端 socket 的总数超过 64 时,不能把所有事件塞进一个数组一次等。常见做法是分组等待,或者监听 socket 用事件模型、客户端连接用 select/IOCP 混合。单线程多端口如果只监听几个端口,事件数量很少,这个上限不是问题;一旦把每个客户端也配一个事件,64 很快就到。
事件对象是手动重置的,WSAEnumNetworkEvents会重置事件和网络事件记录。不要自己调WSAResetEvent再枚举,顺序错了会丢事件。程序退出时用WSACloseEvent关闭每个事件对象,和closesocket一样要配对。
4.2 WSAEnumNetworkEvents 清事件与 accept 时机
WSAWaitForMultipleEvents返回WSA_WAIT_EVENT_0 + index,减掉基址就是事件数组下标。拿到下标后不要直接 accept,先调WSAEnumNetworkEvents把事件取走并重置。这个函数填充WSANETWORKEVENTS,lNetworkEvents是事件位掩码,iErrorCode是对应事件的错误码数组,索引用FD_ACCEPT_BIT、FD_READ_BIT这些常量。
while (true) { DWORD ret = WSAWaitForMultipleEvents( static_cast<DWORD>(events.size()), events.data(), FALSE, 1000, FALSE); if (ret == WSA_WAIT_TIMEOUT) { continue; } if (ret == WSA_WAIT_FAILED) { std::cerr << "WSAWaitForMultipleEvents failed: " << WSAGetLastError() << std::endl; break; } int index = static_cast<int>(ret - WSA_WAIT_EVENT_0); WSANETWORKEVENTS netEvents{}; if (WSAEnumNetworkEvents(listenSockets[index], events[index], &netEvents) == SOCKET_ERROR) { std::cerr << "WSAEnumNetworkEvents failed: " << WSAGetLastError() << std::endl; continue; } if (netEvents.lNetworkEvents & FD_ACCEPT) { if (netEvents.iErrorCode[FD_ACCEPT_BIT] != 0) { std::cerr << "FD_ACCEPT error: " << netEvents.iErrorCode[FD_ACCEPT_BIT] << std::endl; continue; } while (true) { SOCKET client = accept(listenSockets[index], nullptr, nullptr); if (client == INVALID_SOCKET) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { break; } std::cerr << "accept error: " << err << std::endl; break; } // 真实业务:给 client 创建事件并 WSAEventSelect closesocket(client); } } }fWaitAll传FALSE,表示任意一个事件有信号就返回;传TRUE要等所有事件都有信号,单线程多端口场景基本不用。超时 1000 毫秒是给退出标志留检查点,不要用WSA_INFINITE后没有关闭路径。iErrorCode[FD_ACCEPT_BIT]非 0 表示 accept 事件本身带错误,常见是连接在握手阶段被重置,记录后继续等下一个事件即可。
把客户端 socket 也接入事件模型时,每个客户端一个事件对象,数量受 64 限制。更常见的混合做法是:监听 socket 用 WSAEventSelect,客户端连接用 select 或 IOCP。不要为了“统一”把几百个客户端都塞进事件数组,最后还是要面对 64 上限。
4.3 select 与 WSAEventSelect 的选型对比
两种模型都能单线程跑多端口,差别在事件管理方式和上限。端口数少、连接数少、想快速把逻辑跑通,select 更直接;端口数少但希望事件语义清晰、不想每轮重建集合,WSAEventSelect 更合适;连接数上千、需要吞吐,二者都不该硬撑,应该看 IOCP。
| 维度 | select | WSAEventSelect | IOCP |
|---|---|---|---|
| 单线程多端口 | 适合,代码短 | 适合,事件驱动 | 适合,但复杂度高 |
| 句柄上限 | FD_SETSIZE,默认 64,可调 | WSA_MAXIMUM_WAIT_EVENTS,64 | 与等待事件数无关 |
| 是否需要非阻塞 socket | 需要手动设置 | WSAEventSelect自动设置 | 异步模型,另说 |
| 每轮开销 | 复制集合、线性扫描 | 等 |
本文还有配套的精品资源,点击获取