ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

VC++ IOCP实战:异步多线程Socket高并发编程从入门到避坑

VC++ IOCP实战:异步多线程Socket高并发编程从入门到避坑 简介一份VC环境下的异步多线程Socket通信项目完整覆盖服务端与客户端两侧面向需要学习网络编程、并发处理及事件驱动模型的开发者旨在解决同步Socket阻塞和单线程连接效率低的问题。项目通过CAsyncSocket异步回调与多线程调度合理使用临界区、互斥量等同步对象能够有效提升多客户端同时接入时的响应速度帮助读者理解Windows消息机制、Winsock库在MFC中的实际运用。压缩包仅56KB包含36个文件以8个头文件和6个C源文件为主体辅以MFC工程配置、图标资源与说明文档其中服务端和客户端各自拥有独立项目工程目录结构清晰便于对照编译和调试。服务端负责监听连接并为每个客户端分配处理线程客户端则通过异步事件实现并发发送与接收两个部分都演示了OnAccept、OnReceive等异步回调的使用方法。目前已有432人学习下载适合中初级开发者作为理解VC异步Socket编程与线程协作的参考样例也可为游戏服务器、分布式系统等场景提供设计思路。1. 为什么“vc 异步多线程 socket”不是开一百个线程而是让 I/O 自己找上门先抛一个反直觉的结论如果你在 VC 里写一个“每个 socket 开一个线程”的服务端并发从 500 涨到 2000 的时候吃掉 CPU 的不是业务逻辑而是线程调度和上下文切换。真正意义上的“异步多线程 socket”是这样一套组合socket 本身以异步模式收发数据系统把 I/O 完成事件投递到一个完成端口你用一个固定线程池去消费这些事件。线程不主动“等”任何 socket是事件找上门不是线程去敲门。这套方案在 Visual CWindows 平台里的标准落地工具是 IOCP 加 overlapped I/O服务端和客户端可以共用同一套完成端口和 worker 线程。文章适合三类人要给 Windows 服务端做高并发接入的 C 工程师维护老项目、想把阻塞 socket 改造成异步的“救火队员”以及准备“多线程面试题”时想把同步和异步的区别讲清楚的技术人。接下来我们从选型一路走到实现、参数和填坑最后给你一个能验证效果的办法。2. 选型先行在 VC 里做异步 socket为什么要锁定 IOCP2.1 同步和异步的区别一个迟到 200ms 的包能看出你的线程在哪阻塞模型下你的线程调用recv后就是一块木头包没到它就一直停在NtWaitForSingleObject上。假设一个业务响应慢下游 200ms 才回包这 200ms 里线程什么都没干但它的栈、用户态内存、内核线程对象全被占着。两千个连接就需要两千个线程光是 Windows 给这些线程切换上下文就能让若干个 CPU 核心空转在调度上。异步模型不是这样。WSARecv调用完立即返回socket 的收发由内核继续盯着你手里的线程可以立刻去处理别的已完成的 I/O。数据到达后系统把一个“完成通知”丢给完成端口worker 线程在GetQueuedCompletionStatus处接住再做真正的处理。这才是“同步和异步的区别”同步是你亲自站着等异步是你留个纸条货到了有人喊你。多线程在这里的作用不是“多开几个等待者”而是“多几个消费者来取完成通知”。你在做“多线程思考”的时候要想的是任务队列怎么被均匀消费而不是每个连接对应一个线程。后面我们会看到worker 线程数量通常只和 CPU 核心数挂钩和连接数完全解耦。2.2 socket 网络编程的三种异步模型消息、事件、完成端口在 Windows SDK 里不做 IOCP 也有两条路但都有限制模型通知方式容量上限适用场景WSAAsyncSelect向指定窗口的 HWND 发消息受窗口消息队列吞吐限制高并发丢消息带界面的小型工具WSAEventSelect每 socket 关联一个事件对象用 WaitForMultipleObjects 等待一次最多等 64 个事件连接多了要拆组几十个连接的原型验证IOCP完成端口内核将完成通知直接投递到端口队列理论支持数万活跃 socketWindows 服务端/重客户端首选WSAAsyncSelect 不适合纯后台服务因为你得先有一个窗口和消息循环没有 UI 反而要被消息派发拖后腿。WSAEventSelect 的 64 个对象上限在几千连接的场景下要把 socket 拆成好几组去 Wait处理起来像是在写分布式玩具。IOCP 是 Windows 上把“异步”和“多线程”合并成一个体系的地方它维护一个完成队列系统每完成一次 overlapped I/O 就入队你的 worker 线程竞争出队。socket 和线程之间没有绑定关系这是它和“每连接一线程”最本质的区别。2.3 场景选型与最小步骤什么项目必须用 IOCP先别急着抄我后面的代码对照自己的需求选型并发连接数预期超过 200且峰值波动大用 IOCP。每秒新建连接几百甚至上千比如网关、代理、行情推送用 IOCP。只是内网几十个设备的轻量通信直接用传统的std::thread加阻塞 socket开发速度快得多。对你的 VC 工程做一个最小改造也很简单源码里包含winsock2.h项目属性里把“附加依赖项”加上ws2_32.lib。如果你看到error: command ... cl.exe failed with exit status 2多半是编译工具链环境没对而不是代码问题这个坑我在第 5 章会细讲。实现方面我一般按这几步走WSAStartup初始化 Winsock 2.2。CreateIoCompletionPort创建完成端口。所有目标 socket 都带WSA_FLAG_OVERLAPPED创建并绑定到完成端口。worker 线程池跑GetQueuedCompletionStatus。服务端用 AcceptEx客户端用 ConnectEx让“建立连接”本身也是异步的。后面两章就是这套流程的完整落地。先说服务端。3. 服务端落地用 IOCP 写一个可异步运行的多线程 echo 服务3.1 先定义两块核心数据PER_SOCKET 与 PER_IO_CONTEXTIOCP 写代码的时候最怕你思路里还在“socket 里存数据”。整个模型里没有哪块内存是天生跟着 socket 走的所以每个 socket 和每次 I/O 的生命周期都得自己管。我会定义两个结构体struct PER_SOCKET { SOCKET s; LONG refCount; // socket 被多少个在途 I/O 引用 CRITICAL_SECTION sendLock; // 保护发送队列 // 这里可以扩展业务字段比如连接ID、对端地址 }; struct PER_IO_CONTEXT { OVERLAPPED ov; // 这个成员必须能通过 CONTAINING_RECORD 取回自己 SOCKET s; WSABUF wsaBuf; char buffer[4096]; DWORD flags; int ioType; // 0本地 accept1recv2send3ConnectEx };OVERLAPPED不是随便放哪个位置都行的。GetQueuedCompletionStatus的第四个参数是LPOVERLAPPED我们要从它找回自己的PER_IO_CONTEXT最可靠的办法就是让ov是第一个成员然后做指针偏移转换。这块内存必须一直活着直到对应的完成通知被处理掉否则会出现“访问已释放内存”的野指针问题。引用计数是必须的。一个 socket 可能同时挂着一个 recv、一个 send、甚至还有一个 AcceptEx 补位请求。只要有一个还没完成的 I/O 引用它你就不能释放PER_SOCKET否则完成通知回来后会踩空。后面避坑章还会细说。3.2 初始化完成端口与监听 Socket这一步建立“事件发生的物流中心”。所有 socket不管是监听的、接受的还是客户端主动连接的都要绑到这个端口上。WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); HANDLE hIocp CreateIoCompletionPort( INVALID_HANDLE_VALUE, NULL, 0, 0); if (hIocp NULL) { // 错误处理 } SOCKET listenSock WSASocketW( AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED); SOCKADDR_IN addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(9000); bind(listenSock, (SOCKADDR*)addr, sizeof(addr)); listen(listenSock, SOMAXCONN); // 把监听 socket 也绑到完成端口 CreateIoCompletionPort( (HANDLE)listenSock, hIocp, (ULONG_PTR)listenKey, 0);这里有一个我踩过的细节监听 socket 必须用WSASocketW创建并显式指定WSA_FLAG_OVERLAPPED。如果你图省事用socket()后续AcceptEx和WSARecv都可能行为异常因为它默认是 non-overlapped 的。CreateIoCompletionPort的后三个参数completion key 可以是任意指针一般用来区分是监听 socket 还是普通 socket第四个参数 0 是线程数下限实际线程数由你自己创建的 worker 决定。3.3 用 AcceptEx 实现“来连接也是异步通知”传统accept()是阻塞的你得专门开一个线程等它。要彻底异步就得用 AcceptEx但它是扩展函数不能直接#include就能调要用 WSAIoctl 拿函数指针。GUID guidAcceptEx WSAID_ACCEPTEX; LPFN_ACCEPTEX lpfnAcceptEx NULL; DWORD bytes 0; WSAIoctl(listenSock, SIO_GET_EXTENSION_FUNCTION_POINTER, guidAcceptEx, sizeof(guidAcceptEx), lpfnAcceptEx, sizeof(lpfnAcceptEx), bytes, NULL, NULL); // 投递两个重叠的 accept避免串行 for (int i 0; i 2; i) { PER_IO_CONTEXT* io allocate_io(); io-ioType IO_ACCEPT; io-s listenSock; io-acceptSock WSASocketW(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED); DWORD unused 0; BOOL ok lpfnAcceptEx(listenSock, io-acceptSock, io-buffer, 0, sizeof(SOCKADDR_IN) 16, sizeof(SOCKADDR_IN) 16, unused, io-ov); if (!ok WSAGetLastError() ! WSA_IO_PENDING) { // 按错误码处理 } }为什么要连续投两个因为 AcceptEx 一旦收到一个连接这个请求就算完成了如果只有一个 pending那么在新请求补位之前后续连接就没人接。两个请求交替补位可以保证没有“空窗期”。这里io-buffer会同时用来存局部地址和远程地址所以它的长度要能放下两个SOCKADDR_IN再加 16 字节对齐空间我只给 64 字节实际项目建议给 128。当 worker 收到IO_ACCEPT完成通知时要把io-acceptSock也绑到完成端口然后对这个新 socket 投递一个 recv最后再创建一个新的PER_IO_CONTEXT重新补一个 AcceptEx 到监听 socket。3.4 worker 线程GetQueuedCompletionStatus 的唯一正确写法worker 线程是消费完成通知的地方。它不做 accept、不做等待只做出队和分发。DWORD WINAPI WorkerThread(LPVOID param) { WORKER_PARAM* p (WORKER_PARAM*)param; while (TRUE) { DWORD bytes 0; ULONG_PTR key 0; OVERLAPPED* ov NULL; BOOL ok GetQueuedCompletionStatus( p-hIocp, bytes, key, ov, INFINITE); if (!ok ov NULL) { // 这表示完成端口被关闭做退出处理 break; } PER_IO_CONTEXT* io CONTAINING_RECORD(ov, PER_IO_CONTEXT, ov); // 根据 io-ioType 分发 switch (io-ioType) { case IO_ACCEPT: HandleAcceptCompletion(p, io, bytes); break; case IO_RECV: HandleRecvCompletion(p, io, bytes); break; case IO_SEND: DecrementRef(io-sock); ReleaseIO(io); break; } } return 0; }GetQueuedCompletionStatus返回时即使ok为 FALSE只要ov非空这个PER_IO_CONTEXT仍然有效。最常见的错误是看到 FALSE 就认为“I/O 已失败”然后把ov直接释放。正确做法是先取回io再看错误码如果错误是ERROR_OPERATION_ABORTED说明 socket 被主动关了如果是CONNECTION_RESET说明对端断连。无论哪种这个io之后都不应再被投递但内存释放要延后等所有对该io的引用都结束。CONTAINING_RECORD是 Windows SDK 里的宏它和offsetof配合能从OVERLAPPED*算出容器起始地址。这里要求ov确实是PER_IO_CONTEXT的第一个成员不能换位。3.5 参数说明与内存释放姿势下面是我在多个项目里用下来比较稳的参数组合参数建议值说明接收缓冲buffer4096 起步可调 8K/16K太小会频繁触发多次投递太大浪费内存per-socket pending recv1 个收到完整包后再投递下一个逻辑简单监听 socket pending accept2 个避免连接到达时无请求可完成worker 线程数2 * CPU核心数纯 I/O 密集可以再加倍别超过 64listen()backlogSOMAXCONNWindows 上可接受的最大值内存释放的关键动作是“最后一次引用”。每个PER_IO_CONTEXT在投递时引用计数 1完成通知处理后 -1归零才能delete。PER_SOCKET同样accept 完成 1投递 recv 1关闭时只有在引用计数为 0 的情况下才真正释放否则先关句柄标记为“等待归零”等最后一个完成通知回来再清理。这种两阶段释放就是异步网络编程的“后悔药”你永远不知道内核哪个角落里还拿着一份指向你内存的指针。宁可晚释放不能早释放。4. 客户端落地把连接、发送、接收都装进同一个完成端口4.1 客户端为什么也必须异步从“连接建立”开始一说到异步 socket大家默认是服务端的事。但客户端有时更急需要做批量获取、并发抓取或者给自己服务端做压力测试你可能开着几百个连接每个连接还不停发请求。假如每个连接都connect()等握手线程会被 TCP 三次握手的时间卡死。一个 RTT 是 50ms 的机房你不希望 1000 个连接花 50 秒来“建好”。异步客户端的核心是 ConnectEx它在调用后立即返回连接握手完成后完成通知照常进完成端口。这样你可以在一个线程里快速发起几百个连接请求然后统一到 worker 里去处理完成结果。常见做法是让客户端和服务端共用同一个hIocp占用同一套 worker 线程池省资源也省代码。4.2 拿 ConnectEx 并投递一个异步连接ConnectEx 也需要先拿函数指针过程和 AcceptEx 一样GUID guidConnectEx WSAID_CONNECTEX; LPFN_CONNECTEX lpfnConnectEx NULL; DWORD bytes 0; WSAIoctl(sock, SIO_GET_EXTENSION_FUNCTION_POINTER, guidConnectEx, sizeof(guidConnectEx), lpfnConnectEx, sizeof(lpfnConnectEx), bytes, NULL, NULL);然后用它代替connect()PER_IO_CONTEXT* io allocate_io(); io-ioType IO_CONNECT; io-s sock; sockaddr_in local{}; local.sin_family AF_INET; local.sin_addr.s_addr htonl(INADDR_ANY); local.sin_port 0; bind(sock, (SOCKADDR*)local, sizeof(local)); BOOL ok lpfnConnectEx(sock, (SOCKADDR*)remote, sizeof(remote), NULL, 0, NULL, io-ov); if (!ok WSAGetLastError() ! WSA_IO_PENDING) { // 处理错误 }ConnectEx 有个爸爸级的规矩调用前 socket 必须是未连接的且必须已经显式 bind。如果没 bind 就调用它会返回WSAEINVAL。另一个坑是 ConnectEx 不会在连接失败时把 socket 变成可重用状态——你只能关闭这个 socket重新创建一个新的来发起下一次连接。所以客户端想重连时不要只在同一个 socket 上重试否则你会被隐藏的WSAECONNRESET折磨很久。4.3 异步发送与接收BUF/WSABUF 必须活到完成之后客户端最经典的翻车场景是把局部变量直接丢给WSASend// 错误写法 void SendData(SOCKET s, const std::string msg) { char tmp[1024]; strcpy(tmp, msg.c_str()); WSABUF buf{ (ULONG)msg.size(), tmp }; // 函数返回后 tmp 销毁 WSASend(s, buf, 1, NULL, 0, pendIo-ov, NULL); }WSASend返回不等于数据已经发完它只是把“发送任务”交给内核。只要这个任务还在排队你的WSABUF和底层缓冲区每一字节都必须活着。正确做法是把业务数据复制进PER_IO_CONTEXT自己的 buffer 里void AsyncSendData(SOCKET s, PER_IO_CONTEXT* io, const char* data, int len) { memcpy(io-buffer, data, len); io-wsaBuf.buf io-buffer; io-wsaBuf.len len; io-ioType IO_SEND; // 调用 WSASend沿用 io-ov WSASend(s, io-wsaBuf, 1, NULL, 0, io-ov, NULL); }同理WSARecv投递之后在完成通知回来之前PER_IO_CONTEXT里的 buffer 是内核写数据的地方任何并发的读写都是数据竞争。4.4 把服务端和客户端装进同一个进程同一套循环有一个思路值得养成习惯把服务端和客户端当成“同一家公司”的两个部门都注册到同一个完成端口上。你只需要用 completion key 区分来源struct COMPLETION_KEY { int type; // KEY_LISTEN / KEY_CLIENT_SOCK / KEY_SERVER_SOCK void* ptr; // 指向各自的 PER_SOCKET };worker 循环里拿到 key根据key-type进入不同分支。这样你在自己的压测程序里可以一口气跑起来左边开线程 accept右边开线程发起 ConnectEx数据通路都走同一个 IOCP。生产环境往往不用这样但当你要做连接回显、延迟统计、故障注入时这种布局会让你少写很多死代码。我一般会对客户端单独做一个小队列所有已连接但还没发送的请求放进一个普通过程容器worker 完成一个 recv 后从这个队列拿下一个请求。队列的锁是热点后面进阶章会讲到减少锁争用的思路。5. 避坑手册5 个让异步 socket 项目翻车的真实细节5.1 现象接到的数据错位、带有上一包残留服务端 echo 数据总能对上但一旦业务包变长或者连续收发发现buffer里总有上一批的尾巴或者第一段数据变成乱码。我在排查过几次虚拟内存之后确认了原因同一个PER_IO_CONTEXT的 buffer 被重复投递了。比如你在IO_RECV完成里顺手又调用了一次WSARecv但没注意到上次的 recv 其实还没完全“闭账”两个重叠的WSARecv都指向同一块内存完成顺序一乱后完成的把前一个覆盖了。解决每个PER_IO_CONTEXT同一时间只允许有一个在途的 recv。完成通知收到后先把io-ioType改成IO_IDLE再决定要不要重新投递。宁可在代码里加一个断言也不要在生产环境去找哪个包被吃了。我常在分发入口加一句assert(io-ioType ! IO_RECV_PENDING);能拦掉大半这类 bug。5.2 现象accept 出来的 socket 不工作recv 永远不回调你按监听 socket 的流程写了AcceptExworker 也收到IO_ACCEPT完成但把acceptSock交给业务去发数据没有任何反应。原因是新 socket 从 AcceptEx 里出来时是没有绑定到完成端口上的。你的代码给监听 socket 绑定了 IOCP却没有对 acceptSock 做同样的事所以之后投递的WSARecv一直挂着完成通知没有地方去。解决在HandleAcceptCompletion里第一件事就是CreateIoCompletionPort((HANDLE)acceptSock, hIocp, (ULONG_PTR)newKey, 0);。这一步不能省也不要用HANDLE瞎转。绑定成功后再给这个新 socket 设置SO_UPDATE_ACCEPT_CONTEXT否则某些属性比如getpeername拿不到远端地址。5.3 现象GetQueuedCompletionStatus 永远等不到通知或返回 87 错误OVERLAPPED没有清零。这个坑的隐蔽性在于它第一次跑可能没事第二次就跑一次错一次。Windows 内核假设OVERLAPPED结构被ZeroMemory过尤其是内部保留字段。如果你用malloc分配PER_IO_CONTEXT而没有清零87 号错误ERROR_INVALID_PARAMETER就会频繁出现。解决分配后立刻ZeroMemory(io, sizeof(PER_IO_CONTEXT));。另外千万别把同一个OVERLAPPED同时交给WSARecv和WSASend二者会互踩内部保留字段。我的自定义规则同一时间一个 context 只有一个职责哪怕它叫做双向上下文。5.4 现象编译时报error: command ... cl.exe failed with exit status 2这是给 VC 环境特有的坑很多人在写代码时没把 Winsock 链接库加进去直接报LNK2019还有的是在命令行或者第三方工具链比如 Python 的某个扩展安装里用 cl 编译结果头文件目录、库目录没配置报出这串cl.exe failed with exit status 2。它本质是环境变量和工具链没被初始化和你的 socket 代码无关。解决不要硬记那些set INCLUDE...。从开始菜单打开“x64 Native Tools Command Prompt for VS”项目在这个环境下编译如果是给外部构建系统调用先执行vcvarsall.bat x64。用 CMake 的话指定-A x64 -T v143按你安装的 VS 版本调整。当然还有一类真问题你机器上只装了 Python 的 VC 编译碎片没有完整 C 生成工具那就需要安装“使用 C 的桌面开发”工作负载。5.5 现象高并发时吞吐量上不去CPU 用不满几千连接跑起来线程池里 8 个线程明明在干活但吞吐量只有预期的一半。观察发现每个连接每时每刻就一个 recv 在飞行包和包之间有“处理完再投递”的等待这个串行路径成了吞吐瓶颈。另一个常见原因是发送时大家都抢一把锁worker 互相等锁CPU 也没干正事。解决常见做法是每个连接投递 2-3 个 outstanding recv让内核同时为这个连接缓冲多个数据包减少来回“排队-投递”的间隙。发送的话把CRITICAL_SECTION粒度从整个 socket 缩小到一段“发送队列”的 push/pop或者干脆用无锁队列。worker 线程数量也不要乱设先用2 * 核数压测后再加减。6. 把异步多线程 socket 压到极限验证方法与两个调优习惯6.1 怎么验证“异步”不是玄学压测看三张图写完这套服务端和客户端不要直接上生产。我习惯先做一轮最小压测客户端开 2000 个连接每连接连续发 100 次小包然后看任务管理器里的线程数和 CPU 占用线程总数8 个 worker 2 个 accept 补位 1 个主线程 连接数2000 观察点线程数不随连接增长CPU 在核数范围内波动 如果线程数涨到几百说明某处还在阻塞式 accept/recv只要线程数稳定就能证明异步流程走通了。压测时别忘了把GetQueuedCompletionStatus的INFINITE改成一个超时值并输出统计否则代码一卡住你完全无感。6.2 两个调优习惯把小包合并把线程数钉在核数上第一个习惯批量发送时别循环调用WSASend。用WSABUF数组一次带多个小包让WSASend内部做散列发送能显著减少进入内核的次数。小包越碎收益越明显但对大包别硬拼。第二个习惯worker 线程数先锁定为2 * CPU 核心数跑完压测再上下调。多线程不是越多越好超了反而互相抢时间片。我一般会在一个压测脚本里循环切换线程数画出吞吐量-线程数曲线取拐点。最后说句私话我做异步 socket 这么多年教训最深的就是“异步编程不是写了 OVERLAPPED 就万事大吉”。你发出去一个请求心里要时刻记着它还没完成、内存还活着、完成处理也是异步流程的一部分。每写一个WSARecv都要用这个问题自问一遍。希望这篇的内容能帮到你少走我当年翻车走过的路。本文还有配套的精品资源点击获取
返回列表