简介:这是一份面向Windows平台C++网络编程学习者的实战代码资源,聚焦“单线程同时监听多个端口”这一典型场景,帮助开发者摆脱为每个端口单独开线程的传统做法,降低系统资源消耗与上下文切换开销。资源以IOCP(I/O完成端口)机制为核心,涉及套接字创建与绑定、CreateIoCompletionPort关联、异步accept请求投递、GetQueuedCompletionStatus事件循环以及OVERLAPPED结构体管理等关键环节,适合具备一定socket基础、希望深入理解高并发I/O模型的中级开发者。压缩包共2个文件,包含1个cpp源文件与1个h头文件,整体约3KB,代码结构紧凑,便于直接阅读与移植到实际项目。目前已有1647人学习下载,可作为理解IOCP多端口监听实现思路的参考范例,同时也能帮助读者梳理异步I/O中的错误处理与资源管理要点。
1. 单线程监听多端口:Windows 下 C++ 网络编程的一个反直觉起点
很多人第一次听到「单线程同时监听多个端口」,脑子里蹦出来的画面是开一堆线程、每个线程accept一个端口。但在 Windows 平台上,用 C++ 做这件事最经典、最稳、也最省心的做法,恰恰是一个线程 + 一个select循环。这不是偷懒,而是 Windows 的select天生就支持把多个监听套接字塞进同一个fd_set,一次调用就能同时感知哪个端口来了新连接。你不需要线程同步、不需要锁、不需要担心某个端口阻塞拖垮其他端口,代码量还不到两百行。
这篇文章面向的是已经会写最基础的socket/bind/listen、但被「多端口」卡住的 C++ 开发者。我会把「为什么单线程能行」「select到底怎么管多个监听套接字」「端口数量上去之后怎么调参」「哪些坑会让你的程序在 Windows 上莫名其妙卡死」这几件事讲透,最后给出一份可以直接编译运行的完整代码骨架。如果你正在用 VS Code 配 C/C++ 环境、或者刚装完 Visual C++ Redistributable 准备动手,这篇正好接得上。
2. 为什么单线程 + select 能同时监听多个端口
2.1 监听套接字本质就是「可读事件源」
在 TCP 服务端流程里,listen之后的套接字并不会用来收发数据,它的唯一职责是:当有客户端完成三次握手、进入 accept 队列时,这个套接字变成「可读」。这一点非常关键——它意味着监听套接字和普通连接套接字在select眼里是同一种东西:只要readfds集合里某个套接字可读,select就返回,你再用FD_ISSET逐个判断是谁可读。
所以「监听多个端口」在实现层面就退化成一件很朴素的事:创建 N 个监听套接字,分别bind到不同端口,全部塞进同一个fd_set,然后循环select。哪个套接字可读,就对哪个调用accept。整个过程没有任何并发原语,因为从头到尾只有一个线程在跑。
这里有个容易混淆的点:select的第一个参数nfds在 Windows 上是被忽略的,Windows 的select签名保留它只是为了兼容 Berkeley sockets。真正决定扫描范围的是fd_set里的内容。所以你在 Windows 上写select(0, &readfds, ...)完全合法,不要被某些跨平台教程里「必须传最大 fd+1」的说法带偏——那是 Linux 的规矩。
2.2 和「多线程每端口一线程」的取舍
多线程方案不是不能用,但它在「端口数量中等、连接不密集」的场景下性价比很低。假设你监听 8 个端口,每个端口开一个线程阻塞在accept上,你要处理的是:线程创建销毁开销、共享状态加锁、某个线程异常退出后如何优雅收尾、调试时断点落在哪个线程。而单线程select方案里,这些全都不存在,出问题只有一个调用栈可看,排查成本低一个数量级。
代价也很明确:select是轮询模型,端口数和连接数上去之后,每次循环都要遍历整个fd_set,CPU 会白白烧在「检查有没有事件」上。经验阈值是:监听端口在几十个以内、并发连接在几百以内,select完全够用;再往上就该考虑IOCP或WSAPoll了。但对绝大多数「一台机器上跑几个服务端口」的需求,select是那个「写完就不用再想」的选择。
2.3 Windows 上必须先做的两件事:WSAStartup 和套接字复用
Windows 的 socket 不是标准 C 库的一部分,用之前必须WSAStartup初始化 Winsock,退出时WSACleanup。这一步漏掉,后面所有 socket 调用都会返回INVALID_SOCKET,而且错误码是WSANOTINITIALISED,新手很容易在这里卡半天。
另一件事是SO_REUSEADDR。在 Windows 上,如果一个端口刚被关闭、还处于TIME_WAIT,你立刻重新bind会失败。设置SO_REUSEADDR能让bind成功。注意 Windows 的SO_REUSEADDR语义和 Linux 不同,它更接近 Linux 的SO_REUSEPORT,允许同一端口被多个套接字绑定——这在调试时是双刃剑,可能让你误以为程序正常,其实两个进程在抢同一个端口。生产环境建议只在确实需要快速重启时开启。
// 初始化 Winsock,任何 socket 调用之前必须执行 WSADATA wsaData; int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { std::cerr << "WSAStartup failed: " << ret << std::endl; return -1; } // ... 后续所有 socket 操作 ... WSACleanup(); // 程序退出前调用MAKEWORD(2, 2)请求的是 Winsock 2.2 版本,这是目前 Windows 上最通用的版本。返回值非 0 表示初始化失败,直接退出即可,不要继续往下走。WSACleanup要和WSAStartup配对,虽然进程退出时系统会兜底清理,但显式调用是好习惯,尤其在写库或 DLL 时。
3. 把多个监听套接字塞进一个 select 循环
3.1 创建并绑定多个监听端口的完整函数
先解决「怎么把 N 个端口变成 N 个监听套接字」。下面这个函数接收一个端口数组,返回对应的套接字数组,任何一步失败都会清理已创建的套接字,避免句柄泄漏。
#include <winsock2.h> #include <ws2tcpip.h> #include <iostream> #include <vector> #pragma comment(lib, "ws2_32.lib") // 为一组端口创建监听套接字,失败返回空 vector std::vector<SOCKET> createListeners(const std::vector<int>& ports) { std::vector<SOCKET> listeners; for (int port : ports) { SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s == INVALID_SOCKET) { std::cerr << "socket() failed for port " << port << ", err=" << WSAGetLastError() << std::endl; break; } // 允许地址复用,避免 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 = htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port = htons((u_short)port); if (bind(s, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { std::cerr << "bind() failed for port " << port << ", err=" << WSAGetLastError() << std::endl; closesocket(s); break; } if (listen(s, SOMAXCONN) == SOCKET_ERROR) { std::cerr << "listen() failed for port " << port << ", err=" << WSAGetLastError() << std::endl; closesocket(s); break; } listeners.push_back(s); std::cout << "listening on port " << port << std::endl; } // 任意一步失败,回收已创建的套接字 if (listeners.size() != ports.size()) { for (SOCKET s : listeners) closesocket(s); return {}; } return listeners; }几个参数值得单独说。INADDR_ANY表示绑定本机所有网卡地址,如果你只想监听127.0.0.1,把它换成inet_addr("127.0.0.1")。SOMAXCONN是系统允许的最大等待队列长度,Windows 上通常足够,不需要手动调小。SO_REUSEADDR的opt必须是BOOL类型而不是int,这是 Windows 特有的坑,传错类型setsockopt会返回错误但很多人不看返回值。
3.2 select 主循环:一次等待,逐个 accept
有了监听套接字数组,主循环就非常直白。每一轮把监听套接字全部加入readfds,调用select阻塞等待,返回后遍历判断哪个可读,对可读的执行accept。
int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) return -1; std::vector<int> ports = { 8080, 8081, 8082, 9000 }; std::vector<SOCKET> listeners = createListeners(ports); if (listeners.empty()) { WSACleanup(); return -1; } std::vector<SOCKET> clients; // 已接受的连接 while (true) { fd_set readfds; FD_ZERO(&readfds); SOCKET maxFd = 0; for (SOCKET s : listeners) { FD_SET(s, &readfds); if (s > maxFd) maxFd = s; } for (SOCKET s : clients) { FD_SET(s, &readfds); if (s > maxFd) maxFd = s; } // Windows 忽略第一个参数,传 0 即可 int n = select(0, &readfds, nullptr, nullptr, nullptr); if (n == SOCKET_ERROR) { std::cerr << "select failed, err=" << WSAGetLastError() << std::endl; break; } // 先处理监听套接字:有新连接 for (SOCKET ls : listeners) { if (FD_ISSET(ls, &readfds)) { sockaddr_in caddr{}; int clen = sizeof(caddr); SOCKET cs = accept(ls, (sockaddr*)&caddr, &clen); if (cs != INVALID_SOCKET) { char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &caddr.sin_addr, ip, sizeof(ip)); std::cout << "new conn from " << ip << ":" << ntohs(caddr.sin_port) << " on listener " << ls << std::endl; clients.push_back(cs); } } } // 再处理已连接套接字:有数据或对端关闭 for (size_t i = 0; i < clients.size(); ) { SOCKET cs = clients[i]; if (FD_ISSET(cs, &readfds)) { char buf[4096]; int r = recv(cs, buf, sizeof(buf), 0); if (r <= 0) { // r==0 对端正常关闭,r<0 出错,都关闭 closesocket(cs); clients.erase(clients.begin() + i); continue; } // 回显,实际业务替换这里 send(cs, buf, r, 0); } ++i; } } for (SOCKET s : clients) closesocket(s); for (SOCKET s : listeners) closesocket(s); WSACleanup(); return 0; }这段代码有几个必须理解的点。第一,fd_set每次循环都要重新FD_ZERO和FD_SET,因为select返回时会修改集合,只保留就绪的套接字,下一轮如果不重建,集合内容就是错的。第二,select的第一个参数在 Windows 上传 0 没问题,但如果你把这段代码移植到 Linux,必须传maxFd + 1,否则高编号的套接字会被忽略。第三,accept之后新连接要加入clients,下一轮select才会监控它。
3.3 端口数量与 fd_set 上限:别踩 FD_SETSIZE 这条线
Windows 的fd_set是一个固定大小结构,默认FD_SETSIZE是 64。这意味着你塞进单个fd_set的套接字总数(监听 + 已连接)不能超过 64,超出的部分FD_SET会静默丢弃,select根本不会监控它们,表现为「某些端口永远收不到连接」——这是最阴险的坑之一,因为没有任何报错。
如果你确实需要监控超过 64 个套接字,有两个办法。一是编译前定义更大的FD_SETSIZE,比如在包含winsock2.h之前#define FD_SETSIZE 1024,但这要求所有翻译单元一致,容易出错。二是改用WSAPoll,它用数组而不是位图,没有这个上限,接口也更清晰。我的建议是:端口数在 20 以内、连接数可控时用select;一旦接近 64 这个量级,直接上WSAPoll,别硬撑。
| 方案 | 套接字上限 | 适用场景 | 主要代价 |
|---|---|---|---|
| select | 默认 64 | 端口少、连接少 | 每次遍历整个集合 |
| WSAPoll | 无硬上限 | 中等规模 | 需要维护 pollfd 数组 |
| IOCP | 无硬上限 | 高并发 | 编程模型复杂 |
4. 避坑与排查:单线程多端口最容易翻车的地方
4.1 现象:程序启动就 bind 失败,错误码 10048
原因通常是端口被占用,或者上一次运行残留的TIME_WAIT状态没释放。Windows 上TIME_WAIT默认持续 4 分钟左右,这段时间内重新bind同一端口会返回WSAEADDRINUSE(10048)。
解决办法是设置SO_REUSEADDR,也就是前面代码里那段setsockopt。但要注意,如果端口是被另一个正在运行的进程占用,SO_REUSEADDR也救不了你,这时要用netstat -ano | findstr :8080找到占用进程的 PID,再决定是杀掉还是换端口。别一上来就怀疑代码,先确认端口状态。
4.2 现象:某个端口能连上,但 select 永远不返回
这几乎一定是fd_set溢出。当你塞进去的套接字超过FD_SETSIZE(默认 64),FD_SET宏会直接忽略超出的部分,不报错、不警告。表现就是「前 64 个端口正常,第 65 个开始石沉大海」。
排查方法很简单:在FD_SET之后打印一下实际监控的套接字数量,和你的预期对比。如果对不上,就是溢出了。解决要么调大FD_SETSIZE,要么换WSAPoll。我一般会在初始化阶段就断言listeners.size() + clients.size() < FD_SETSIZE,把问题挡在启动时而不是运行中。
4.3 现象:客户端断开后,程序 CPU 占用飙到 100%
这是select循环的经典陷阱。当对端关闭连接,套接字变成可读,recv返回 0。如果你没有正确处理返回值 0 的情况、没有closesocket并把它从clients里移除,那么下一轮select会立刻再次报告这个套接字可读,recv又立刻返回 0,循环空转,CPU 直接拉满。
解决就是前面代码里那段:recv返回值<= 0时,关闭套接字并从clients中删除。注意删除时用erase后不要++i,因为后面的元素前移了,continue跳过自增才是对的。这个细节写错会导致漏处理一个连接,属于血泪经验级别的坑。
4.4 现象:accept 返回的客户端地址全是 0.0.0.0
原因通常是accept的第三个参数clen没有正确初始化。Windows 的accept要求clen是「传入缓冲区大小、传出实际地址长度」,如果你传了一个未初始化的值或者传了nullptr,行为未定义。正确做法是int clen = sizeof(caddr);再传&clen。
另一个可能是指针类型转换写错,sockaddr_in转sockaddr*时长度参数必须匹配sockaddr_in的大小,不要传成sockaddr的大小。这类问题不会崩溃,但会让你拿到的对端信息全是垃圾,调试时非常迷惑。
4.5 现象:调试器里单步正常,直接运行就卡死
这通常和select的阻塞语义有关。select默认是阻塞的,如果没有任何事件,它会一直等。调试时你单步走,事件可能刚好到达,看起来正常;直接运行时如果事件没来,程序就停在那里,看起来像卡死。
如果你需要非阻塞或带超时的行为,给select的最后一个参数传一个timeval结构,比如{0, 100000}表示 100 毫秒超时。超时返回 0,你可以借此做心跳、日志刷新等周期性任务。传nullptr就是无限等待,适合纯事件驱动的场景。选哪种取决于你的业务是否需要定时逻辑。
5. 进阶技巧:用 WSAPoll 替换 select 并做连接数压测
当你把端口数推到几十个、连接数上百之后,select的 64 上限和每次重建fd_set的开销就开始咬人了。这时候最平滑的升级路径是WSAPoll,它把「套接字 + 关心的事件」放进一个WSAPOLLFD数组,返回后直接检查revents,没有位图、没有上限、语义更清晰。
// 用 WSAPoll 替换 select 的核心片段 std::vector<WSAPOLLFD> pfds; for (SOCKET s : listeners) { WSAPOLLFD p{}; p.fd = s; p.events = POLLRDNORM; // 关心可读 pfds.push_back(p); } // 已连接套接字同样 push 进来,events 设为 POLLRDNORM int n = WSAPoll(pfds.data(), (ULONG)pfds.size(), 1000); // 1 秒超时 if (n > 0) { for (auto& p : pfds) { if (p.revents & POLLRDNORM) { // 判断 p.fd 是监听套接字还是连接套接字,分别处理 } } }WSAPoll的第三个参数是超时毫秒数,-1表示无限等待。revents里除了POLLRDNORM,还可能出现POLLERR、POLLHUP,这些也要处理,否则连接异常断开时你会漏掉。注意WSAPoll在 Windows Vista 之后才完整支持,目标机器如果是更老的系统,还是得用select。
升级完之后,建议做一次连接数压测,确认你的单线程模型到底能扛多少。我一般用一段简单的 Python 脚本开几百个连接,每个连接发一条消息就断开,观察服务端 CPU 和内存。
# 压测脚本:并发建立 N 个连接,各发一条消息 import socket, threading def worker(port, count): for _ in range(count): try: s = socket.create_connection(("127.0.0.1", port), timeout=2) s.sendall(b"ping") s.recv(64) s.close() except Exception as e: print("err", e) threads = [] for port in (8080, 8081, 8082, 9000): t = threading.Thread(target=worker, args=(port, 200)) threads.append(t) t.start() for t in threads: t.join() print("done")跑这个脚本时重点看两件事:服务端 CPU 是否随连接数线性上升(select的正常表现),以及有没有连接被拒绝(说明 accept 队列满了或者fd_set溢出了)。如果 CPU 在连接断开后没有回落,回去检查 4.3 那条,八成是没清理已关闭的连接。
最后说个我自己的习惯:每次写这类网络程序,我都会在select/WSAPoll返回后加一行日志,打印本轮就绪的套接字数量和当前clients.size()。这行日志在排查「为什么没反应」时价值极高,能一眼看出是没事件、还是事件来了没处理、还是处理完没清理。别嫌它吵,上线前再关掉就行。希望帮到你。
本文还有配套的精品资源,点击获取