Windows单线程多端口监听:select与WSAEventSelect选型 简介围绕 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 与创建监听 socketWindows 上跑 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接SOCKET64 位下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_castconst 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_castsockaddr*(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 失败常见错误码是WSAEADDRINUSE10048先看端口是不是被别的进程占了再看是不是上一个实例没退干净。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_castsockaddr*(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 塞进 selectfd_set 位图、超时分支与跨端口 acceptselect 在 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.hFD_SETSIZE改成 1024 只是把数组放大不代表 1024 个连接都能高效处理。select 每次返回后要线性扫描集合连接数上千时 CPU 会花在遍历上。单线程多端口在几十个监听 socket、几百个客户端以内是舒服的再往上应该考虑 WSAEventSelect 或 IOCP而不是继续加宏。另一个细节是fd_set的拷贝。select 会修改传入的集合把不可读的句柄清掉。所以不能把master集合直接传进去否则一轮之后监听 socket 可能被清没。常见做法是维护一个master每轮循环复制一份readSet传给 select。3.2 select 的读集合、超时与返回值分支select 的五个参数在 Windows 上是忽略的 nfds、读集合、写集合、异常集合、超时。单线程监听多端口通常只关心读集合写集合和异常集合传nullptr。超时用timevaltv_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一般来自控制台中断或调试器暂停重试即可WSAENOTSOCK10038通常说明集合里有已经关闭的 socket后面避坑章会展开。超时不要设成 0 轮询单线程空转会把 CPU 吃满也不要设成nullptr后没有退出手段调试时杀进程都杀不干净。listenPorts建议用std::mapSOCKET, 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_castsockaddr*(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); } }WSAEWOULDBLOCK10035在这里不是错误是“暂时没有更多连接”的正常信号。其他错误要区分WSAECONNRESET10054可能是客户端在握手后立刻断开WSAENOBUFS10055通常和缓冲区或句柄耗尽有关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::mapSOCKET, 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::vectorSOCKET listenSockets; std::vectorWSAEVENT 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把事件取走并重置。这个函数填充WSANETWORKEVENTSlNetworkEvents是事件位掩码iErrorCode是对应事件的错误码数组索引用FD_ACCEPT_BIT、FD_READ_BIT这些常量。while (true) { DWORD ret WSAWaitForMultipleEvents( static_castDWORD(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_castint(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。维度selectWSAEventSelectIOCP单线程多端口适合代码短适合事件驱动适合但复杂度高句柄上限FD_SETSIZE默认 64可调WSA_MAXIMUM_WAIT_EVENTS64与等待事件数无关是否需要非阻塞 socket需要手动设置WSAEventSelect自动设置异步模型另说每轮开销复制集合、线性扫描等本文还有配套的精品资源点击获取