ARTICLE DETAIL

资讯详情

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

VC异步多线程Socket实战:从WSAAsyncSelect到IOCP的避坑指南

VC异步多线程Socket实战:从WSAAsyncSelect到IOCP的避坑指南 简介面向VC开发者的异步多线程Socket通信示例工程同时包含服务端与客户端两套完整项目适合正在学习网络编程、并发处理及事件驱动模型的初中级开发者参考。工程重点演示Winsock、CAsyncSocket等关键组件的配合以及OnAccept、OnReceive等异步事件经消息机制触发的处理流程并引入临界区、互斥量等同步手段帮助读者理解如何避免多线程竞态冲突、保证通信资源安全释放从而提升效率与响应速度。压缩包共36个文件以8个h头文件和6个cpp源码为主体另有dsp/dsw工程配置、rc资源文件及txt说明文档整体大小仅56KB服务端与客户端分为两个独立目录便于直接打开对照阅读。已有432人学习浏览对想快速上手异步Socket通信并搭建可运行示例的读者具有直接的参考与复用价值。1. VC 异步多线程 Socket为什么你写的客户端一接大包就卡死做 Windows 下的网络通信用 VCVisual CMFC 或 Win32 API 都算写 Socket 程序只要数据量一上来、连接数一多很多人马上会遇到几个典型症状界面无响应、收包收不全、两个客户端同时连上来就互相踢掉。这些问题十有八九不是 Socket 本身的问题而是你把网络读写直接丢在了主线程里或者用了阻塞模式却没处理好线程调度。VC 下的异步多线程 Socket本质上就两件事把耗时的网络操作从界面线程里摘出去以及让 Socket 的收发不阻塞线程。搞清楚这两点服务端和客户端就都只是套一个骨架的事。这篇文章会把服务端和客户端完整拆开讲从 WSAAsyncSelect 这类异步模型到 Overlapped I/O 完成端口再到真正写代码时的线程池分配、缓冲区管理和闭包时的半包处理。最后一部分是常见故障的排查清单都是实际项目里踩过的坑。2. 先分清三件事阻塞、非阻塞、异步到底差在哪2.1 阻塞模式的硬伤你以为是网络慢其实是线程被挂起了默认情况下Windows Socket 是阻塞模式。recv()调用发出去之后如果内核缓冲区里没有数据这个线程就挂在那里等不返回。单客户端的时候问题不大但你要是用 MFC 直接在 UI 线程里调recv()等于把整个窗口的消息循环停掉了窗口自然就拖不动、点不了看起来就是“死机”。阻塞模式下线程资源分配也很别扭。你要支持 10 个客户端就得开 10 个线程每个线程都卡在各自的recv()上等数据。连接数一多线程切换的开销直接吃掉 CPU而且每个线程默认要 1MB 栈空间内存也很浪费。所以阻塞模式只适合连接数少、数据量小的场景。2.2 WSAAsyncSelect让 Socket 消息进窗口队列VC 里最“古老”也最常用的异步方案是WSAAsyncSelect。它的核心思路是把 Socket 事件转换成 Windows 消息投递到指定窗口的消息队列里。你只需要在窗口过程里处理WM_SOCKET自定义消息就能感知到可读、可写、关闭等事件。// 把 Socket 设为非阻塞并注册网络事件到窗口消息 int nRet WSAAsyncSelect(sock, hWnd, WM_SOCKET, FD_READ | FD_WRITE | FD_CLOSE); if (nRet SOCKET_ERROR) { int nErr WSAGetLastError(); // 常见错误WSAEINVAL 表示 socket 已经注册过事件需先取消 }逻辑说明WSAAsyncSelect会同时把 Socket 自动设为非阻塞模式注册成功后recv()就不会傻等了事件来的时候系统会往hWnd对应的窗口消息队列塞一条WM_SOCKET消息lParam里带事件类型。参数里FD_READ表示可读事件FD_WRITE表示可写FD_CLOSE表示对端关闭。要注意的是这个函数只对“那个特定窗口”有效如果窗口被销毁而 Socket 还活着消息就没人处理Socket 等于废了。这个方案的优点是代码简单适合连接数不多几十个以内的 MFC 程序而且天然跟界面线程亲和。缺点也很明显它本质上还是靠窗口消息驱动窗口一旦忙碌、消息积压网络响应就变慢而且它不支持真正的“多线程并发收发”只有一个线程在处理所有 Socket大流量时会成为瓶颈。2.3 Overlapped I/O 和完成端口服务端高并发的正路如果你要写一个能扛几千连接的服务端WSAAsyncSelect就不够看了。Windows 上真正的高性能方案是 Overlapped I/O配合IOCP完成端口。它的原理是把WSARecv之类的操作丢给内核操作完成后通过完成端口回调你的工作线程收发过程不阻塞任何线程线程只处理已完成的事件。// 投递一个异步接收请求 WSABUF wsaBuf; wsaBuf.buf pCtx-buffer; wsaBuf.len sizeof(pCtx-buffer); DWORD dwFlags 0; DWORD dwBytes 0; int nRet WSARecv(pCtx-socket, wsaBuf, 1, dwBytes, dwFlags, pCtx-overlapped, NULL); if (nRet SOCKET_ERROR WSAGetLastError() ! WSA_IO_PENDING) { // 投递失败需要清理这个上下文 closesocket(pCtx-socket); delete pCtx; }逻辑说明WSARecv投递后立即返回不管有没有数据到达。真正收完数据后系统会把完成通知放到 IOCP 队列里工作线程用GetQueuedCompletionStatus取出来处理。pCtx是每个连接独立分配的上下文结构里面必须包含OVERLAPPED结构、Socket 句柄、缓冲区指针否则完成回调时你根本不知道这次完成的是哪个连接。参数里dwFlags需要置 0dwBytes在投递时无意义完成时才填充实际字节数。WSA_IO_PENDING不是错误恰恰说明请求已经进了内核正在等待完成。IOCP 的典型特征是处理线程数量固定通常是 CPU 核心数的两倍左右而不是一个连接一个线程。这也是服务端和客户端在架构上最大的分水岭。3. 服务端实现线程池 Overlapped 完成端口的完整骨架3.1 服务端的角色划分监听线程和工作线程为什么不能混用服务端至少有两类线程一个是监听线程负责socket()、bind()、listen()以及循环accept()新连接剩下的是工作线程全部通过完成端口来收数据、处理业务逻辑、发数据。这两类线程不能混用。如果让工作线程去 accept那么 accept 会阻塞在工作线程上一旦没有新连接这个线程就被挂住白白浪费一个处理能力。// 监听线程入口 DWORD WINAPI ListenThread(LPVOID lpParam) { SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); BOOL bReuse TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)bReuse, sizeof(BOOL)); SOCKADDR_IN addr { 0 }; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(9527); bind(listenSock, (SOCKADDR*)addr, sizeof(addr)); listen(listenSock, SOMAXCONN); while (true) { SOCKET clientSock accept(listenSock, NULL, NULL); if (clientSock INVALID_SOCKET) break; // 创建上下文并绑定到完成端口 CreatePerConnectionContext(clientSock); } return 0; }逻辑说明监听 socket 单独放在一个线程里循环调用accept()。每一个新连接进来就创建一个上下文并绑定到完成端口。SOMAXCONN让系统自动设置 backlog一般不用改。SO_REUSEADDR在服务端是必须的否则bind()之后如果程序崩溃端口会处于 TIME_WAIT 状态重启时直接报“地址被占用”。参数里INADDR_ANY表示监听所有本机网卡地址如果要绑定指定 IP换成inet_addr(192.168.1.10)即可。3.2 完成端口的创建与关联每个连接都要重新投递完成端口本身要提前创建然后把监听 socket 之外的所有客户端 socket 关联进去。每个 socket 关联一次之后持续通过WSARecv投递接收请求。// 创建完成端口 HANDLE hIocp CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0); // 工作线程数量 CPU核心数 * 2这里假设是 8 核 int nThreads 16; for (int i 0; i nThreads; i) { HANDLE hThread CreateThread(NULL, 0, WorkerThread, hIocp, 0, NULL); CloseHandle(hThread); } // 关联客户端 socket 到完成端口 HANDLE hRet CreateIoCompletionPort((HANDLE)clientSock, hIocp, (ULONG_PTR)pCtx, 0); if (hRet NULL) { // 关联失败关闭连接释放上下文 closesocket(clientSock); delete pCtx; }逻辑说明CreateIoCompletionPort第一次调用传INVALID_HANDLE_VALUE是“创建”第二次传 socket 句柄是“关联”同一个函数两种用法容易被忽略。关联时第三个参数即pCtx会被作为“完成键”原样传回工作线程拿到它就知道是哪个连接、哪个上下文。第四个参数是线程数如果传 0系统会按 CPU 核心数自动分配但显式设置更可控。3.3 工作线程循环GetQueuedCompletionStatus 才是核心调度器工作线程做的事非常单一死循环调GetQueuedCompletionStatus拿到完成事件后根据完成键里的状态机决定下一步是收、是发、还是关连接。DWORD WINAPI WorkerThread(LPVOID lpParam) { HANDLE hIocp (HANDLE)lpParam; DWORD dwBytes 0; ULONG_PTR ulKey 0; OVERLAPPED* pOv NULL; while (true) { BOOL bRet GetQueuedCompletionStatus(hIocp, dwBytes, ulKey, pOv, INFINITE); PerIOContext* pCtx (PerIOContext*)ulKey; if (!bRet) { // 强制失败socket 被关闭或内存不足直接清理 if (pOv ! NULL pCtx ! NULL) { closesocket(pCtx-socket); delete pCtx; } continue; } if (dwBytes 0) { // 对端正常关闭优雅释放 closesocket(pCtx-socket); delete pCtx; continue; } // 处理收到的数据然后再次投递接收 ProcessData(pCtx-buffer, dwBytes); PostRecv(pCtx); } return 0; }逻辑说明dwBytes为 0 时代表对端关闭这里是判断连接正常断开的关键。ulKey就是关联时传的pCtx直接通过它访问连接上下文不需要从OVERLAPPED结构反向找。拿到数据后必须先处理、再重新投递WSARecv否则这个连接就再也不会收到新数据了。工作线程数量我一般设为 CPU 核心数乘 2纯 IO 逻辑可以多些如果有 CPU 密集业务就按核心数来避免上下文切换炸掉。3.4 上下文结构体一个连接一个对象别把所有状态混在一起每个连接必须有一个独立的上下文里面至少要有套接字句柄、接收缓冲区、OVERLAPPED结构、以及一个收发状态标志。很多人写多线程 Socket 翻车就是因为所有连接共用一个静态缓冲区A 连接的数据还没处理完B 连接的数据已经覆盖了同一块内存。struct PerIOContext { SOCKET socket; OVERLAPPED overlapped; char buffer[4096]; int nRecvOffset; // 当前缓冲区已有数据长度 int nSendOffset; // 发送时的数据偏移 };逻辑说明OVERLAPPED必须作为上下文的一部分不能是栈上临时变量。因为WSARecv返回后内核还在异步使用这个结构如果它是局部变量、函数退出就销毁内核写入时直接内存错误。nRecvOffset是为了处理“一次 recv 可能只收到半个包”的情况收进来的数据先累积等到一个完整的应用层包解析出来再交给业务逻辑。这个结构体是服务端代码的核心资产。后面所有坑比如半包、粘包、缓冲区溢出最后都要回到这个结构体上找解决方案。4. 客户端实现异步连接和异步收发的三个关键点4.1 客户端不需要 IOCP但必须用异步 Socket客户端通常只有一个或少数几个连接没必要上完成端口。但也不建议回到阻塞模式因为收数据、发数据如果阻塞在工作线程里退出时很难干净地关线程。客户端最稳妥的做法是用WSAAsyncSelect配合窗口消息或者直接用WSASocket创建的异步 Socket在独立线程里做事件循环。// 创建客户端 SocketWin32 API 方式 SOCKET cliSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); u_long argp 1; ioctlsocket(cliSock, FIONBIO, argp); // 1 表示非阻塞 // 发起异步连接 SOCKADDR_IN svrAddr { 0 }; svrAddr.sin_family AF_INET; svrAddr.sin_port htons(9527); inet_pton(AF_INET, 192.168.1.10, svrAddr.sin_addr); int nRet connect(cliSock, (SOCKADDR*)svrAddr, sizeof(svrAddr)); if (nRet SOCKET_ERROR) { int nErr WSAGetLastError(); if (nErr ! WSAEWOULDBLOCK nErr ! WSAEINPROGRESS) { // 真正的连接失败需要关闭 closesocket(cliSock); return -1; } }逻辑说明FIONBIO把 Socket 设为非阻塞。connect()这时立即返回大概率是WSAEWOULDBLOCK这不代表失败而是连接正在后台进行。之后通过select()或WSAEventSelect等待FD_CONNECT事件确认连接是否成功。参数里inet_pton是 Windows Vista 以后才比较稳的转换函数老项目里还在用inet_addr那东西对255.255.255.255这种广播地址返回INADDR_NONE容易埋雷。4.2 客户端收数据用一个线程死循环 recv 非阻塞窗口客户端可以用一个独立的收包线程循环调用select()判断是否有数据可读可读再调recv()。这里的recv()虽然是阻塞函数但因为select()已经告知有数据它会立即返回不会卡住线程。这样既避免了窗口消息的复杂度又不需要 IOCP。// 客户端收包线程 DWORD WINAPI ClientRecvThread(LPVOID lpParam) { SOCKET s (SOCKET)(UINT_PTR)lpParam; char buf[4096]; while (true) { fd_set rds; FD_ZERO(rds); FD_SET(s, rds); timeval tv { 1, 0 }; // 1 秒超时用于退出轮询 int nRet select(0, rds, NULL, NULL, tv); if (nRet SOCKET_ERROR) { break; // socket 错误退出线程 } if (nRet 0) continue; // 超时无数据继续循环 if (FD_ISSET(s, rds)) { int nRecv recv(s, buf, sizeof(buf), 0); if (nRecv 0) { // 处理数据此处按帧解析 } else if (nRecv 0) { break; // 服务端关闭 } else { if (WSAGetLastError() WSAEWOULDBLOCK) continue; break; } } } return 0; }逻辑说明select()的第一个参数在 Windows 上无意义直接传 0。timeval超时设为 1 秒作用是让线程每隔一秒醒来一次检查退出标志否则select()永远阻塞线程没法干净退出。recv()返回 0 表示对方正常关闭返回负值时如果错误码是WSAEWOULDBLOCK说明数据被别的线程抢走了跳过继续循环其他错误一律退出。这里FD_ISSET判断可读之后recv()的数据长度可能小于一个应用层包的长度需要自行做分包拼包逻辑。4.3 客户端发送用临界区保护发送缓冲区避免多线程乱序客户端的发送看似简单但一旦你既在业务线程发心跳、又在 UI 线程发指令两个线程同时调send()数据就有可能在系统层面交错了。解决办法是给发送操作加锁或者在应用层做一个发送队列所有线程把要发的数据丢进队列由一个发送线程统一调send()。// 临界区方式保护 socket 发送 CRITICAL_SECTION g_csSend; // 初始化: InitializeCriticalSection(g_csSend); bool SendData(SOCKET s, const char* pData, int nLen) { EnterCriticalSection(g_csSend); int nSent 0; while (nSent nLen) { int ret send(s, pData nSent, nLen - nSent, 0); if (ret SOCKET_ERROR) { int err WSAGetLastError(); if (err WSAEWOULDBLOCK) { Sleep(1); // 客户端场景下直接等 1ms 再试 continue; } LeaveCriticalSection(g_csSend); return false; } nSent ret; } LeaveCriticalSection(g_csSend); return true; }逻辑说明这个函数用CriticalSection把send()包起来保证同一时刻只有一个线程在写 Socket。while循环处理“半发送”情况即一次send()只发了部分数据必须发完剩下的。WSAEWOULDBLOCK表示内核发送缓冲区已满客户端场景下简单Sleep(1)再重发就行服务端场景则必须做挂起队列不能死等。临界区粒度必须尽可能小发送大包时不要在锁里面做业务处理否则另外的线程全堵在锁上。5. 异步多线程 Socket 避坑从 C1004 到内存泄漏的五个翻车现场5.1 “通常每个套接字地址只允许使用一次”TIME_WAIT 和端口重用现象服务端程序重启时bind()直接失败报WSAEADDRINUSE10048错误信息就是热词里那句“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”。原因之前程序关闭时大量连接处于 TIME_WAIT 状态系统默认要等 2 个 MSL最长报文段寿命通常 1 到 2 分钟才能释放端口。如果代码里没设SO_REUSEADDR重启就撞上这些 TIME_WAIT 的残留。解决监听 socket 创建后立刻设置SO_REUSEADDR代码见 3.1 节。另外程序退出时尽量先shutdown(SD_SEND)再调closesocket让对端收到 FIN减少自己这边的 TIME_WAIT 堆积。5.2 高并发下收包丢失WSARecv 只投递了一次现象客户端发来连续的多个包服务端只处理了第一个后面的全丢了或者中间隔了很久才收到。原因服务端在初始化连接时只投递了一次WSARecv收到第一包数据后没有再投递新的接收请求导致后续数据在内核缓冲区里永远等不到“有线程来取”。解决每次从完成端口拿到数据并处理完成后必须立刻再次调用WSARecv投递接收。这是 IOCP 模式最容易犯的错检查代码时先数一下PostRecv和WSARecv出现的次数确保每个连接完成一次收包后都有新的投递动作。5.3 缓冲区被并发写坏共享缓冲区加锁不够现象两个客户端连接各自独立收发时正常但几十个连接同时来数据崩溃或数据错乱。原因很多人图省事所有连接共用一个全局缓冲区。IOCP 工作线程并行处理多个连接的完成事件一个线程刚往缓冲区写数据另一个线程也写互相覆盖。解决缓冲区必须放进每个连接的上下文结构体里见 3.4 节每连接一份严禁全局共享。同理连接上下文的销毁时机也要小心不能在工作线程里 delete 之后还有别的线程用同一个完成键访问。5.4 线程资源泄漏服务端越跑越慢现象程序运行几天后内存占用翻了几倍句柄数暴涨新连接响应越来越慢。原因每个连接创建的上下文要么没释放要么线程里CreateThread不CloseHandle要么closesocket之后忘了删上下文。IOCP 工作线程如果用了CreateThread并且丢弃了句柄线程退出后内核对象不会立刻清理累计到一定数量就把资源耗光。解决CreateThread返回的线程句柄必须CloseHandle线程对象不销毁不代表线程被杀只是减少一个引用计数。每次关闭连接时按固定顺序处理先closesocket再delete上下文最后在完成事件里设置退出标志保证没有线程再触碰这个上下文。可以在程序中定期枚举句柄数如果持续增长就一定有泄漏。5.5 半包数据recv 到 4 个字节就处理结果逻辑全崩现象客户端发了一个 100 字节的数据包服务端收到 4 字节就开始解析解析失败、状态错乱、协议握手失败。原因TCP 是流式协议没有“消息边界”。一次recv()返回的数据可以是半包、整包、甚至多个包的合并体。拿缓冲区里现有的字节数直接当一整个应用层包处理必然崩溃。解决自定义一个应用层协议最简单的是“包头 包体”包头固定 4 字节前 2 字节存包体长度后 2 字节存类型或校验。收包时先累积数据到上下文缓冲区每次检查缓冲区长度是否大于等于 4如果够就解析出包体长度再判断缓冲区的数据是否足够一个完整包够才取出来处理。// 简单的拆包函数从接收缓冲区解析一个完整包 // 返回 true 表示成功解析一个包包内容由 pOut 返回 bool TryParsePacket(PerIOContext* pCtx, char* pOut, int* pOutLen) { // 假设包头 4 字节 2 字节长度 2 字节类型 if (pCtx-nRecvOffset 4) return false; short nPktLen *(short*)pCtx-buffer; // 长度字段 if (pCtx-nRecvOffset 4 nPktLen) return false; // 数据还不够整包 memcpy(pOut, pCtx-buffer 4, nPktLen); *pOutLen nPktLen; // 剩余的粘包数据前移 memmove(pCtx-buffer, pCtx-buffer 4 nPktLen, pCtx-nRecvOffset - 4 - nPktLen); pCtx-nRecvOffset - 4 nPktLen; return true; }逻辑说明这里nPktLen直接从缓冲区强转成short要注意字节序。如果服务端和客户端在不同平台比如一个 x86、一个 ARM必须统一用网络字节序用ntohs转回来。memmove处理粘包场景把处理掉的部分移除剩下的拼在缓冲区头部继续等下一包。nRecvOffset必须在每次收包时递增解析成功后递减不能有遗漏。6. 异步 Socket 的进阶验证用 Wireshark 抓包确认你的收发真的干净开发完一套异步多线程 Socket 服务端和客户端不要只看“两边能通”就收工。协议对不对、半包处理有没有生效、关闭时有没有四次握手这些都要靠抓包和日志来验证。先在自己电脑上跑一个最小抓包验证服务端监听 9527客户端连接后每秒发一个 20 字节的心跳包服务端原样回写。用 Wireshark 抓 loopback 接口过滤tcp.port 9527观察序列号Seq是否连续增长。如果 Seq 出现跳变说明你的客户端发送逻辑有丢数据或者重复发送。再验证收包的完整性客户端连发 1000 个包每个包带一个递增序号服务端解析后检查序号是否连续。这个测试必须在服务端开启大量连接的情况下做比如同时开 200 个客户端进程每个发 1000 个包总共 20 万包看服务端处理的包序号有没有缺口。如果出现缺口检查是不是WSARecv投递次数不够或者上下文缓冲区的nRecvOffset没有被正确累加。最后验证关闭逻辑客户端主动断连服务端应收到FD_CLOSE或dwBytes 0并且清理上下文。用任务管理器观察服务端的内存和句柄数连续开 500 个连接再全断开内存和句柄应该回到初始值。如果句柄只增不减回到 5.4 节检查资源释放。这四步验证做完你的异步多线程 Socket 才能真正说“能上生产”。我自己在项目上被半包坑过、被端口复用坑过、被全局缓冲区坑过每次解决后都会把测试用例保留下来做成回归脚本。这套验证方法现在还在用也希望帮到你少走这些弯路。本文还有配套的精品资源点击获取
返回列表