
简介这份源码面向具备一定 Windows 网络编程基础的开发者聚焦 IOCPIO 完成端口这一高性能服务器模型给出可直接编译运行的 socket 服务端实例。内容完整覆盖从创建非阻塞 socket、bind 绑定 IP 与端口、listen 监听到创建 IO 完成端口并绑定 socket、按 CPU 核心数建立工作者线程池、使用 AcceptEx 预投递客户连接的整套流程线程池通过 GetQueuedCompletionStatus 统一处理客户连接、收发与关闭事件并动态补充 AcceptEx便于理解高并发下的连接管理与事件分发机制。资源包共 22 个文件以 8 个 h 头文件与 5 个 cpp 源文件为核心另含 sln、vcxproj 等 VS 工程文件及 rc、ico 等资源文件整体约 134KB使用 VS2017 MFC 编写结构清晰、便于二次开发与调试。目前已有 1374 人学习适合作为 IOCP 入门与实战参考。1. 从一次连接风暴说起这套 IOCP 服务器源码到底能扛什么去年帮朋友处理一个 MFC 写的采集服务客户端规模刚过两千就开始出现连接排队日志里AcceptEx的返回时快时慢CPU 却只跑到三成。问题不在机器在于他用的是「每连接一线程」的老模型线程切换把时间片全吃掉了。后来换成完成端口IOCP重写同样的硬件稳定连接数直接翻了四倍。这套源码就是那次重构沉淀下来的东西——一个基于 Windows IOCP 的服务器骨架用 MFC 做宿主外壳核心网络层走AcceptExGetQueuedCompletionStatus的经典组合。它解决的不是「能不能连上」而是「高并发下怎么让线程数不随连接数膨胀」。适合两类人一是手里有 MFC 项目、想给现有服务加高并发能力的 Windows 客户端/服务端开发者二是想搞懂 IOCP 到底怎么落地、不想只看理论文档的工程师。源码里把监听、投递、完成处理、连接回收这几段拆得比较清楚拿来当模板改业务逻辑比从零搭省事得多。2. 拆开看骨架IOCP 的三层结构与 AcceptEx 投递时机2.1 为什么是完成端口而不是 select 或线程池Windows 上做高并发网络绕不开三个选项select、线程池 阻塞 socket、IOCP。select的 FD_SETSIZE 默认 64改大也只是把轮询成本往后推连接上千后每次调用都要遍历整个集合CPU 全耗在空转上。线程池 阻塞 socket 看似简单但每个阻塞调用占一个线程线程一多内核调度和栈内存就成了瓶颈前面说的连接风暴就是这么来的。IOCP 的思路完全不同它把「等待数据」这件事交给内核应用层只负责从完成队列里取结果。线程不再绑定到具体连接而是绑定到完成端口谁有空谁处理。这样线程数可以固定在一个很小的值一般是 CPU 核数 × 2连接数涨到几千线程数纹丝不动。代价是编程模型变复杂投递和完成必须成对出现漏一个就卡死。这套源码选 IOCP 不是跟风是因为它的目标场景就是「长连接、高并发、MFC 宿主」。MFC 本身消息循环和 IOCP 的工作线程可以共存只要别把网络处理塞进 UI 线程。2.2 源码里的三层结构拿到源码先别急着编译花十分钟把目录理一遍。整体分三层监听层负责创建监听 socket、绑定完成端口、投递第一批AcceptEx。核心是一个CIOCPServer类初始化时调用CreateIoCompletionPort把监听 socket 和完成端口关联。连接层每个客户端连接对应一个CClientContext里面存 socket、收发缓冲区、重叠结构OVERLAPPED。连接建立后立刻投递第一个WSARecv。完成层一组工作线程循环调用GetQueuedCompletionStatus根据返回的OVERLAPPED指针反查是哪个连接、哪种操作accept / recv / send再分发处理。三层之间靠OVERLAPPED结构里的自定义字段串联。常见做法是把OVERLAPPED作为结构体第一个成员这样拿到完成结果时可以直接强转回上下文指针。源码里就是这么干的这个技巧后面避坑章节还会提到。2.3 AcceptEx 的投递时机与数量控制AcceptEx和普通accept最大的区别是它需要你先提供一个已连接的 socket 和一块接收缓冲区然后异步等待。也就是说连接还没来你就得把「坑」挖好。源码里在监听层初始化时一次性投递了若干个AcceptEx数量由m_nMaxAccept控制。// 监听层初始化投递首批 AcceptEx BOOL CIOCPServer::PostAccept() { // 每个待处理连接需要一个独立的上下文和缓冲区 CClientContext* pContext new CClientContext(); pContext-pServer this; // 预创建 socketAcceptEx 成功后直接使用 pContext-socket WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED); // 缓冲区要能放下本地地址 远程地址 数据 DWORD dwBytes 0; BOOL bRet AcceptEx(m_listenSocket, pContext-socket, pContext-buf, // 接收缓冲区 0, // 不预收数据 sizeof(SOCKADDR_IN) 16, sizeof(SOCKADDR_IN) 16, dwBytes, pContext-ov); // 重叠结构 if (!bRet WSAGetLastError() ! ERROR_IO_PENDING) { // 真失败不是异步挂起 delete pContext; return FALSE; } return TRUE; }这段代码有几个参数值得说清楚。dwReceiveDataLength传 0表示连接建立时不顺带收数据数据留给后续WSARecv处理这样职责更清晰。两个地址长度参数必须给足sizeof(SOCKADDR_IN) 16这是AcceptEx的硬性要求给少了会直接失败。WSA_FLAG_OVERLAPPED标志不能省否则 socket 不支持异步操作。投递数量m_nMaxAccept怎么定源码默认给的是 10。这个值不是越大越好每个待处理AcceptEx都占一个 socket 和一块缓冲区投太多浪费句柄投太少连接突发时来不及补位新连接会排队。经验值是按「每秒新建连接数 × 平均处理耗时」估算一般 10 到 50 之间。源码里在每次AcceptEx完成后会立刻补投一个保持池子里始终有m_nMaxAccept个在等。2.4 完成线程的循环与分发逻辑工作线程是 IOCP 的发动机。源码里WorkerThread是个静态函数循环体很短// 工作线程从完成队列取结果并分发 DWORD WINAPI CIOCPServer::WorkerThread(LPVOID lpParam) { CIOCPServer* pServer (CIOCPServer*)lpParam; DWORD dwBytes 0; ULONG_PTR dwKey 0; OVERLAPPED* pOv NULL; while (TRUE) { // 阻塞等待完成事件超时设 INFINITE BOOL bRet GetQueuedCompletionStatus( pServer-m_hIOCP, dwBytes, dwKey, pOv, INFINITE); if (pOv NULL) { // 完成端口被关闭退出线程 break; } // 通过 OVERLAPPED 反查上下文 CClientContext* pContext CONTAINING_RECORD(pOv, CClientContext, ov); if (!bRet) { // 操作失败通常是连接断开 pServer-OnClientClose(pContext); continue; } // 根据操作类型分发 switch (pContext-opType) { case OP_ACCEPT: pServer-OnAcceptComplete(pContext, dwBytes); break; case OP_RECV: pServer-OnRecvComplete(pContext, dwBytes); break; case OP_SEND: pServer-OnSendComplete(pContext, dwBytes); break; } } return 0; }GetQueuedCompletionStatus的第三个参数dwKey是完成键源码里传的是this指针方便在多个完成端口共存时区分。第四个参数pOv是关键它指向当初投递时用的OVERLAPPED结构。CONTAINING_RECORD宏根据成员偏移反推出整个上下文结构这是 Windows 内核编程里很常见的技巧。分发逻辑用opType字段区分操作类型。这个字段在投递前设置AcceptEx投递时设OP_ACCEPTWSARecv设OP_RECVWSASend设OP_SEND。注意opType必须在每次投递前重置否则上一轮的值会串到下一轮。线程数量在Start里创建源码用的是GetSystemInfo拿到的dwNumberOfProcessors * 2。这个倍数不是拍脑袋IOCP 的完成线程大部分时间在等内核通知偶尔有短时计算2 倍能保证一个线程处理时另一个能顶上。如果业务逻辑里有阻塞调用比如查数据库这个倍数要往上调或者干脆把阻塞操作挪到单独线程池。3. 编译与跑通从 MFC 工程配置到第一次收发验证3.1 工程配置里容易漏的三个链接项源码是 MFC 工程用 VS 打开后先别点编译检查三个地方。第一项目属性 → 链接器 → 输入 → 附加依赖项必须有ws2_32.lib和mswsock.lib。AcceptEx、GetAcceptExSockaddrs这些函数在mswsock.h里声明库在mswsock.lib漏了会报 LNK2019。第二字符集。源码里字符串处理用的是TCHAR和_tcslen这类宏项目字符集设成「使用 Unicode 字符集」或「使用多字节字符集」都能编但设成 Unicode 时sprintf要换成_stprintf。源码里已经做了宏适配保持默认即可。第三预处理器定义。_WIN32_WINNT至少设成0x0501XP 及以上否则AcceptEx的原型可能取不到。在 stdafx.h 里加一行// stdafx.h 里确保版本宏足够高 #ifndef _WIN32_WINNT #define _WIN32_WINNT 0x0501 #endif #include mswsock.h #include winsock2.h注意winsock2.h必须在windows.h之前包含否则会撞到 winsock1 的老定义。MFC 工程里afxwin.h会间接拉windows.h所以 stdafx.h 的顺序要调好先winsock2.h再afxwin.h。3.2 初始化顺序WSAStartup 与完成端口创建服务启动的调用链是CIOCPServer::Start→InitSocket→CreateIOCP→PostAccept→CreateThreads。顺序不能乱尤其是WSAStartup必须在任何 socket 调用之前。// 服务启动按顺序初始化 BOOL CIOCPServer::Start(int nPort, int nMaxAccept) { // 1. 初始化 Winsock WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) return FALSE; m_nMaxAccept nMaxAccept; // 2. 创建监听 socket m_listenSocket WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED); if (m_listenSocket INVALID_SOCKET) return FALSE; // 3. 绑定端口 SOCKADDR_IN addr; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(nPort); if (bind(m_listenSocket, (SOCKADDR*)addr, sizeof(addr)) SOCKET_ERROR) return FALSE; // 4. 监听backlog 给 SOMAXCONN if (listen(m_listenSocket, SOMAXCONN) SOCKET_ERROR) return FALSE; // 5. 创建完成端口并关联监听 socket m_hIOCP CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0); if (m_hIOCP NULL) return FALSE; CreateIoCompletionPort((HANDLE)m_listenSocket, m_hIOCP, (ULONG_PTR)this, 0); // 6. 投递首批 AcceptEx for (int i 0; i m_nMaxAccept; i) PostAccept(); // 7. 创建工作线程 CreateThreads(); return TRUE; }CreateIoCompletionPort第一次调用时第一个参数传INVALID_HANDLE_VALUE表示创建新的完成端口并发线程数传 0 让系统按 CPU 核数自动定。第二次调用才是把 socket 关联上去完成键传this。listen的 backlog 传SOMAXCONN让系统用最大值。这个值影响的是「已完成三次握手但还没被 AcceptEx 取走」的队列长度和m_nMaxAccept是两回事别搞混。3.3 用 telnet 做第一次连通性验证编译通过后先别写客户端用系统自带的 telnet 最快。启动服务命令行执行# Windows 上启用 telnet 客户端后 telnet 127.0.0.1 8888连上后服务端日志应该打印「accept complete」说明AcceptEx投递和完成处理这条链路通了。然后在 telnet 窗口敲几个字符回车服务端应该打印收到的字节数。这一步验证的是WSARecv投递和OnRecvComplete分发。如果 telnet 连不上按这个顺序查服务是否真的在监听netstat -ano | findstr 8888防火墙是否拦了bind的地址是不是INADDR_ANY。如果连上了但收不到数据检查WSARecv有没有在OnAcceptComplete里投递以及opType有没有设成OP_RECV。3.4 收发缓冲区的管理与零拷贝边界源码里每个连接有一块固定大小的接收缓冲区默认 4096 字节。WSARecv投递时把这块缓冲区挂上去完成后在OnRecvComplete里处理数据处理完再投递下一次WSARecv。这是典型的「一次投递一次完成」模型简单可靠。但有个边界要注意如果客户端发来的数据超过 4096一次WSARecv收不完会触发多次完成。源码里在OnRecvComplete里判断dwBytes 0表示对端关闭否则把数据追加到应用层缓冲区等业务层判断「一帧完整」再处理。这个「粘包/拆包」逻辑不在网络层做网络层只负责搬运字节流。零拷贝在 IOCP 里不是默认的。想减少一次内存拷贝可以用WSARecv直接投递到应用层缓冲区但前提是应用层能接受「数据可能不完整」。源码为了通用性保留了中间缓冲区业务层从中间缓冲区取数据。如果追求极致性能可以把中间缓冲区去掉让业务层自己管代价是业务代码复杂度上升。4. 避坑与排查IOCP 新手最容易翻车的五个点4.1 现象连接建立后立刻断开日志显示 recv 返回 0原因AcceptEx完成后没有正确投递第一个WSARecv或者投递时opType没设成OP_RECV导致完成线程收到一个未知类型的完成事件走了关闭分支。解决在OnAcceptComplete里设置opType OP_RECV然后调用WSARecv投递。注意WSARecv的lpNumberOfBytesRecvd参数传NULL因为异步模式下这个值在完成时才有效投递时传了也没用。4.2 现象服务跑一段时间后 CPU 飙到 100%但连接数没涨原因AcceptEx完成后没有补投池子里的待处理AcceptEx被耗尽新连接全部堆在 backlog 里。工作线程反复被唤醒又无事可做空转吃 CPU。解决在OnAcceptComplete的最后无论成功失败都调用一次PostAccept补位。源码里是在OnAcceptComplete开头就补投这样即使后续处理失败池子数量也不会掉。4.3 现象客户端正常关闭服务端却报内存泄漏原因CClientContext在连接关闭时没有释放或者释放了但OVERLAPPED结构还被内核引用。IOCP 的完成事件是异步的如果连接关闭时还有未完成的WSARecv直接delete上下文会导致内核访问已释放内存。解决关闭连接时先closesocket然后等所有未完成操作返回可以用引用计数计数归零再delete。源码里用了一个简单的引用计数m_nRef投递时加一完成时减一减到零才释放。4.4 现象AcceptEx返回 FALSEWSAGetLastError是 10022原因AcceptEx的地址长度参数给错了。两个长度参数必须都大于等于sizeof(SOCKADDR_IN) 16给sizeof(SOCKADDR_IN)会报 10022WSAEINVAL。解决统一用sizeof(SOCKADDR_IN) 16。这个 16 是AcceptEx要求的额外空间用来存放地址信息不是可选项。4.5 现象多线程下偶发数据错乱A 连接收到 B 连接的数据原因OVERLAPPED结构被复用或共享。每个待处理操作必须有独立的OVERLAPPED如果多个WSARecv共用一个完成时无法区分是哪个连接的。解决OVERLAPPED作为CClientContext的成员每个连接一个每次投递前清零memset(ov, 0, sizeof(ov))。注意清零要在投递前做不能在完成后做否则可能清掉内核正在写的数据。5. 进阶把 IOCP 服务器塞进 MFC 消息循环与压力验证5.1 工作线程与 UI 线程的安全通信MFC 的 UI 线程不是线程安全的工作线程直接操作控件会崩。源码里的做法是工作线程收到数据后不直接更新界面而是PostMessage一个自定义消息给主窗口把数据指针通过lParam传过去主窗口收到消息后再更新。// 工作线程里投递消息给 UI 线程 // WM_CLIENT_DATA 是自定义消息在头文件里定义 #define WM_CLIENT_DATA (WM_USER 100) void CIOCPServer::NotifyUI(CClientContext* pContext, const char* pData, int nLen) { // 数据拷贝一份避免工作线程释放后 UI 线程访问野指针 char* pCopy new char[nLen]; memcpy(pCopy, pData, nLen); // 把指针打包进 lParamUI 线程负责 delete ::PostMessage(m_hWnd, WM_CLIENT_DATA, (WPARAM)pContext, (LPARAM)pCopy); }这里的关键是「数据所有权转移」。工作线程new出来的内存在 UI 线程delete谁new谁不管谁收到消息谁管。如果嫌麻烦可以用std::shared_ptr配合自定义消息但 MFC 的消息机制对智能指针支持不友好源码选了裸指针加约定。5.2 用脚本做并发连接压测验证服务器能扛多少连接不用写客户端用 Python 脚本最快# 并发连接压测逐步增加连接数观察服务端表现 import socket import time import threading HOST 127.0.0.1 PORT 8888 CONN_COUNT 500 sockets [] lock threading.Lock() def connect_one(idx): try: s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((HOST, PORT)) with lock: sockets.append(s) # 保持连接定期发心跳 while True: s.send(bping) time.sleep(5) except Exception as e: print(fconn {idx} error: {e}) threads [] for i in range(CONN_COUNT): t threading.Thread(targetconnect_one, args(i,)) t.daemon True t.start() threads.append(t) # 控制建连速度避免瞬间打满 time.sleep(0.01) print(f{CONN_COUNT} connections started) time.sleep(60)跑起来后观察服务端的 CPU 和内存。正常情况下500 连接下 CPU 应该低于 10%内存增长和连接数成正比。如果 CPU 随连接数线性上涨说明完成线程数量不对或者有轮询逻辑。如果内存涨得比连接数快很多检查每个连接的缓冲区是不是开太大。5.3 一个容易被忽略的细节完成键的复用CreateIoCompletionPort的完成键参数源码里监听 socket 传的是this客户端 socket 传的是CClientContext*。这样在完成线程里dwKey可以直接区分是监听事件还是客户端事件。但有个坑如果CClientContext被释放后完成键还留在完成端口里后续GetQueuedCompletionStatus可能返回一个野指针。避免方法是在closesocket之后、delete之前确保该 socket 的所有完成事件都已经处理完。源码里用引用计数解决更简单的做法是延迟释放——把待释放的上下文放进一个队列由一个单独的清理线程定期回收。从那以后我每次拿到 IOCP 相关代码都先看三件事AcceptEx有没有补投、OVERLAPPED是不是每个操作独立、关闭连接时有没有等未完成操作归零。这三条过了基本不会出大问题。希望帮到你。本文还有配套的精品资源点击获取