ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VC异步多线程Socket实战:IOCP服务端与客户端完整实现及避坑指南

VC异步多线程Socket实战:IOCP服务端与客户端完整实现及避坑指南 简介VC异步多线程Socket通信示例代码包内含服务端与客户端两个完整MFC工程面向需要掌握网络并发编程的C开发者解决传统同步Socket阻塞与多客户端处理效率低下的问题。资源共36个文件压缩包仅56KB包含8个.h头文件、6个.cpp源文件以及.dsp/.dsw工程文件、.rc资源脚本、.ico图标等辅助内容目录结构清晰便于按模块检索。代码重点展示了Winsock与CAsyncSocket的异步事件处理机制OnAccept、OnReceive等并利用CWinThread创建线程配合临界区、互斥量等同步对象实现多客户端并发通信服务端为每个连接分配独立线程客户端也可并发收发数据同时涵盖错误处理与资源释放逻辑。已有432人学习适合网络应用、分布式系统及游戏服务器开发的入门到进阶参考。1. VC异步多线程socket先从一次服务端翻车说起VC环境下的异步多线程socket网络编程光看名字就知道不是“跑通一个echo”那么简单。我最早接手一个tcp中转服务用的是老掉牙的阻塞线程池每连接一个thread连接数刚爬到300CPU直接烧到80%某一路客户端半断开一个recv就永远卡死整个服务跟着失去响应。后来把通信层整个重构成VC异步多线程socket服务端挂IOCP完成端口客户端走WSAAsyncSelect事件驱动同样的机器连接数跑到8000CPU占有率反而降了一大截。这篇笔记就是围绕这样一套资源展开的VC写的异步多线程socket实现同时包含服务端和客户端两端代码。它不是讲概念而是把线程模型怎么拆、缓冲区怎么管、收发完成回调怎么接、压测时怎么发现问题全部落到可复现的步骤上。适合正在写tcp服务端、网关、中转程序或者想把项目里阻塞式socket换掉的VC开发者。读完后你至少能对着代码说清楚哪个线程在accept、哪个线程在等完成事件、客户端断线以后那几毫秒里谁在收拾残局。2. 项目骨架与线程模型为什么服务端和客户端必须分开设计2.1 线程模型选型Accept线程、Worker线程与I/O完成端口这个项目里服务端和客户端没有共用同一套通信核心不是偷懒是两者的需求属性根本不同。服务端的核心目标是“大量连接同时在线”。每个客户端都要收发数据如果沿用阻塞模型一个连接占一个线程上了三百个连接线程切换开销就会吞掉大量CPU更麻烦的是recv的阻塞特性会让线程池里的线程大量积压。所以服务端选择了Windows下公认的高并发方案IOCP完成端口。它做的事情是让socket的异步收发完成后统一排进一个完成队列由少量工作线程从队列里取结果连接数再多也不会无限创建线程。客户端则完全反过来。客户端程序要的是界面不卡、单条连接稳定、逻辑清晰连接数往往是个位数。这个项目里客户端用的是WSAAsyncSelectsocket事件通过Windows消息循环投递主线程不阻塞收发操作不会把界面拖死。服务端和客户端分开这个决定从线程模型上就已经定下来了。服务端的线程安排是这样拆的线程类型数量职责Accept线程1只做accept接收新连接后立即投递第一个异步接收请求Worker线程系统默认并发数可手动调成CPU核数×2阻塞在GetQueuedCompletionStatus上取完成包做业务分发定时器线程1心跳检查、超时清理、连接状态统计Worker线程数量有讲究。CreateIoCompletionPort创建完成端口时可以指定并发数传0表示用系统默认值。如果你的Worker线程里做的都是纯内存操作和socket收发0就够用一旦业务里混了阻塞操作比如在工作线程中同步写日志文件或者读数据库就必须把并发数调大否则一个Worker卡在磁盘I/O上其他完成包没人处理。常见做法是取CPU核数×2这个项目里就是8个Worker线程。线程开多了反而增加上下文切换我见过有人把并发数设成20性能比8还差。2.2 缓冲区与半包处理异步socket的灵魂异步socket翻车的地方十有八九不在API调用上而在缓冲区管理上。接收缓冲区必须是“可累积”的。TCP是字节流不保证一次投递的WSABUF里就是个完整报文。我见过太多人第一次投递recv缓冲区只给64字节一个稍微超过64字节的业务包被拆成两段第二次读到后半段时判断“长度不对”直接关闭连接。这个项目里接收缓冲区MAX_RECV_SIZE给的是4096协议里的最大业务报文控制在2KB以内。接收缓冲区要按“最大包长×2”开并且要记录当前累积的已用长度每次收到数据先把长度加上再去做拆包判断。发送侧则是另一回事。很多初学者在调用send的地方把栈上的临时变量挂进WSABUF然后立刻return等到异步操作真正执行时那块栈内存已经被其他函数覆盖了。避免这个坑的做法只有一个数据先拷贝进自己持有生命周期的发送缓冲区再挂WSABUF。这个项目里每个ClientContext自带一块MAX_SEND_SIZE的发送缓冲区PostSend先memcpy再发起WSASend彻底切断发送数据和调用方内存的联系。2.3 核心数据结构Per-Handle与Per-I/O到底放什么IOCP编程绕不开两个概念Per-Handle是每个连接一份的上下文数据Per-I/O是每次重叠操作一份的操作记录。区分这两个概念直接决定你写出来的代码是稳定还是随机崩溃。Per-Handle数据放在ClientContext结构里struct ClientContext { SOCKET m_socket; // 客户端的连接socket sockaddr_in m_remoteAddr; // 客户端IP和端口 char m_recvBuf[MAX_RECV_SIZE]; // 接收缓冲4096字节 int m_recvLen; // 当前累积的接收数据长度 char m_sendBuf[MAX_SEND_SIZE]; // 发送缓冲8192字节 OVERLAPPED m_recvOv; // 接收操作的重叠结构 OVERLAPPED m_sendOv; // 发送操作的重叠结构 int m_refCount; // 引用计数控制释放时机 int m_status; // 1正常, 0关闭中, -1已关闭 };为什么接收缓冲和发送缓冲大小不一样接收侧因为有粘包累积缓冲区小了会频繁发生“剩余空间不足”得做搬移发送侧每次只投递一个完整的业务包8192足够容纳这个项目里最大的请求包。两个OVERLAPPED结构一个管收、一个管发串行使用不会同时有两个接收操作或两个发送操作挂在同一个socket上这是单连接内不会出现并发冲突的关键。m_refCount是释放安全的关键。一个连接在生命周期内可能同时挂着一个pending的接收操作和一个pending的发送操作。关闭连接时必须等这两个操作都从完成队列里取出来、处理完毕后才能释放内存。计数器初始值为2每次PostRecv或PostSend成功就加1每次操作完成就减1减到0才真正delete这个ClientContext。这是异步socket多线程模型里最常见的生存期管理手段少了这个引用计数程序跑几个小时后随机崩溃几乎就是必然事件。3. 服务端实现重叠I/O与Worker线程的完整路径3.1 初始化创建完成端口并绑定监听socket服务端初始化这步网上常见的写法简化了太多真正跑起来要补几个关键参数。先看代码// 1. 创建完成端口 HANDLE hIocp CreateIoCompletionPort( INVALID_HANDLE_VALUE, // 初始不关联任何句柄 NULL, 0, 0 // 并发线程数传0用系统默认值 ); if (hIocp NULL) { // 失败多半是内存或句柄资源不足 return FALSE; } // 2. 创建监听socket必须显式声明支持重叠 SOCKET sListen WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED); if (sListen INVALID_SOCKET) { return FALSE; } // 3. 端口复用开发期很重要不然重启服务经常绑定失败 BOOL bReuse TRUE; setsockopt(sListen, SOL_SOCKET, SO_REUSEADDR, (const char*)bReuse, sizeof(bReuse)); // 4. 绑定端口 SOCKADDR_IN addr {0}; addr.sin_family AF_INET; addr.sin_addr.S_un.S_addr htonl(INADDR_ANY); addr.sin_port htons(9800); if (bind(sListen, (SOCKADDR*)addr, sizeof(addr)) SOCKET_ERROR) { // 10048错误端口被占用就发生在这一行 closesocket(sListen); return FALSE; } // 5. 启动监听backlog用SOMAXCONN让系统决定 if (listen(sListen, SOMAXCONN) SOCKET_ERROR) { closesocket(sListen); return FALSE; } // 6. 把监听socket挂到完成端口上 HANDLE hResult CreateIoCompletionPort( (HANDLE)sListen, hIocp, (ULONG_PTR)0, 0); if (hResult NULL) { closesocket(sListen); return FALSE; }第一处要注意的是WSASocket调用最后一个参数WSA_FLAG_OVERLAPPED必须带上。如果不带这个标志后续WSARecv、WSASend指定重叠结构时会直接返回WSAEOPNOTSUPP。第二处是CreateIoCompletionPort的两种用途第一次调用时第一个参数传INVALID_HANDLE_VALUE意思是创建一个全新的完成端口第二次调用时传socket句柄意思是把这个socket关联到完成端口上。同一个API两个用法很容易理解错。把监听socket关联到完成端口时完成键我传了0。原因很简单监听socket本身不做具体的业务收发accept出来的客户端socket才是真正要处理的对象。3.2 Accept循环与客户端对象管理Accept线程的逻辑极简循环调用accept接到新连接就创建一个ClientContext把这个socket关联到完成端口投递第一个异步接收请求。// Accept线程的主循环 while (bAcceptLoopRunning) { SOCKET sClient accept(sListen, NULL, NULL); if (sClient INVALID_SOCKET) { int nErr WSAGetLastError(); if (nErr WSAECONNRESET) { // 客户端在accept返回之前就重置了连接正常现象 continue; } if (nErr WSAEINTR) { // 被异常中断通常是服务端关闭流程触发的 break; } break; } // 1. 新连接创建自己的上下文 ClientContext* pCtx new ClientContext(sClient); // 2. 把新socket关联到完成端口完成键指向它的上下文 CreateIoCompletionPort((HANDLE)sClient, hIocp, (ULONG_PTR)pCtx, 0); // 3. 立即投递第一个异步接收请求 if (!pCtx-PostRecv()) { // 投递失败就关闭连接 pCtx-OnError(); continue; // 注意OnError内部管理引用计数和内存 } }为什么第一步分配完ClientContext之后不检查分配失败因为new抛异常的话这里整个服务进程都会崩溃。真正需要注意的坑在第二步和第三步之间pCtx是在Accept线程里创建的创建完立即关联再到PostRecv但如果worker线程此刻就已经收到了socket的完成事件呢理论上不会因为关联完成端口和首次PostRecv之间没有一个已完成的重叠操作可供GetQueuedCompletionStatus取回。这个时序看起来没问题实际跑起来也稳定但为了安全起见我通常先把引用计数设为2PostRecv成功才减到1避免异常场景下内存被过早释放。3.3 Worker线程从GetQueuedCompletionStatus到业务分发Worker线程是服务的核心执行单元阻塞在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, // 本次I/O实际完成的字节数 ulKey, // 完成键取回ClientContext指针 pOv, // 重叠结构地址判断是哪次操作 INFINITE); if (pOv NULL) { // 收到人为投递的退出信号PostQueuedCompletionStatus带动 break; } ClientContext* pCtx (ClientContext*)ulKey; if (!bRet) { // 连接出错或对端重置 DWORD dwErr GetLastError(); if (dwErr ERROR_OPERATION_ABORTED) { // 主动取消通常发生在关闭socket时 } pCtx-OnError(); continue; } if (dwBytes 0) { // 对端正常关闭socket pCtx-OnClose(); continue; } // 判断本次完成的是接收还是发送 if (pOv pCtx-m_recvOv) { pCtx-OnRecvComplete(dwBytes); } else if (pOv pCtx-m_sendOv) { pCtx-OnSendComplete(dwBytes); } // 减少引用计数如果减到0就释放内存 pCtx-ReleaseRef(); } return 0; }这段代码有三个关键判断必须养成习惯。第一pOv NULL分支是退出机制。GetQueuedCompletionStatus正常情况下不会返回空指针的OVERLAPPED只有调用PostQueuedCompletionStatus(hIocp, 0, 0, NULL)主动唤醒线程时才会走到这里。服务端关停时先投N个退包再等待所有worker线程退出这样不会出现线程卡死的问题。第二dwBytes 0是对端正常关闭的标志。TCP机制里对端调用closesocket后当前挂着的WSARecv会以0字节完成返回。如果不做这个判断就会把0字节当成有效数据继续处理然后继续投PostRecv形成死循环。第三bRet为FALSE时不仅要看GetLastError还要看pOv是否为NULL。有一种特殊场景是bRet返回FALSE但pOv不是NULL这说明本次I/O本身失败了但确实是某个已投递操作的结果。遇到这种情况要先走错误处理再走释放逻辑不能直接continue否则引用计数永远不减内存就泄漏了。3.4 关闭与释放优雅停服不是一句closesocket服务端关停是整个项目里最容易出问题的环节。直接关闭监听socket、然后一个个closesocket客户端socket结果是大量worker线程卡在错误处理的泥潭里随机崩溃。正确流程是分层关闭。第一步把bAcceptLoopRunning置为FALSE调用closesocket(sListen)让accept循环退出。第二步遍历所有ClientContext列表对每个连接调用shutdown(socket, SD_BOTH)。这步会把还没有离开的pending收发操作都触发完成但操作本身会被标记失败不会继续投递新的。第三步调用PostQueuedCompletionStatus向完成队列投递N个退出信号N等于worker线程数让所有worker安全退出。第四步主线程等待所有worker线程句柄后再统一清理内存。前提是ClientContext里有正确的引用计数。shutdown触发完成之后每个挂起的I/O操作都会从队列里取出来走到OnError或OnClose分支通过ReleaseRef把计数减到0最终安全释放。如果引用计数管理有bug关闭时就会遇到某些客户端上下文始终减不到0内存泄漏只是小事更严重的是worker线程一直在处理一个已经“消失”的socket引发不可预知的内存访问错误。这个坑我已经踩过太多次每次都在关闭流程里翻车。4. 客户端实现异步连接与收发回调4.1 ConnectEx与异步连接状态机客户端这边连接数不多但连接动作本身也需要异步化。connect()函数有个隐藏问题在Windows上connect阻塞的时间不一定短尤其对方IP不可达时可能会卡几秒甚至十几秒主线程直接被拖住。WSAAsyncSelect模式下connect依然会阻塞等待结果只是方式不同。要彻底异步连接就得用ConnectEx。ConnectEx不是直接链接就能用的它是WSAIoctl取出来的扩展函数指针// 1. 创建socket同样要支持重叠 SOCKET sClient WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED); // 2. 取ConnectEx函数地址 GUID guidConnectEx WSAID_CONNECTEX; DWORD dwBytes 0; LPFN_CONNECTEX lpfnConnectEx NULL; WSAIoctl(sClient, SIO_GET_EXTENSION_FUNCTION_POINTER, guidConnectEx, sizeof(guidConnectEx), lpfnConnectEx, sizeof(lpfnConnectEx), dwBytes, NULL, NULL); if (lpfnConnectEx NULL) { // 无法取到说明VS版本值或者socket类型不对 closesocket(sClient); return FALSE; } // 3. ConnectEx必须先bind一次 SOCKADDR_IN localAddr {0}; localAddr.sin_family AF_INET; localAddr.sin_addr.S_un.S_addr htonl(INADDR_ANY); localAddr.sin_port 0; // 0表示让系统自动分配临时端口 bind(sClient, (SOCKADDR*)localAddr, sizeof(localAddr)); // 4. 发起异步连接 SOCKADDR_IN serverAddr {0}; serverAddr.sin_family AF_INET; serverAddr.sin_addr.S_un.S_addr inet_addr(192.168.1.100); serverAddr.sin_port htons(9800); OVERLAPPED ov {0}; BOOL bRet lpfnConnectEx(sClient, (SOCKADDR*)serverAddr, sizeof(serverAddr), NULL, 0, NULL, ov); if (!bRet WSAGetLastError() ! WSA_IO_PENDING) { // 真正连接失败 closesocket(sClient); return FALSE; } // 连接完成的通知通过WSAAsyncSelect的FD_CONNECT消息到达ConnectEx强制要求先bind是因为它内部需要显式的本地地址与端口。传0端口让系统自动分配没问题但必须在调用ConnectEx之前完成这步否则直接调用会失败。连接过程在客户端形成状态机的三跳从“未连接”到“连接中”等收到FD_CONNECT消息后再根据BOOL值判断成功还是失败。WSAAsyncSelect的消息投递不阻塞主线程继续跑UI这点是阻塞connect完全做不到的。4.2 发送与接收的Pend机制细节客户端的异步收发机制在WSAAsyncSelect模式下通常叫“Pend机制”先投递一个异步请求内核在处理过程中数据还没到达前请求处于“挂起”状态。坏习惯是在这个状态里继续投新的收发请求导致两个重叠操作同时挂在一个socket上。我在这个项目里把客户端的收发模型简化为同一时刻只能有一个接收操作在飞发送则只能有一个待发的包在队列里。接收侧的资料用WSARecv发送侧用WSASend都带OVERLAPPED结构。这里有个小细节WSAAsyncSelect和IOCP里的OVERLAPPED使用方式稍有不同客户端只触发消息通知但重叠结构照样要填。忽略这个结构的话完成通知里拿到的是垃圾指针处理时直接崩溃。发送的典型实现int SendPacket(SOCKET s, const char* pData, int nLen) { // 数据拷贝进发送缓冲避免异步期间调用方数据被释放 memcpy(g_sendBuf, pData, nLen); WSABUF wsaBuf; wsaBuf.buf g_sendBuf; wsaBuf.len nLen; DWORD dwFlags 0; DWORD dwSent 0; int nRet WSASend(s, wsaBuf, 1, dwSent, dwFlags, ov, NULL); if (nRet SOCKET_ERROR) { if (WSAGetLastError() ! WSA_IO_PENDING) { // 真正的错误连接可能已经断开 return -1; } } return 0; }发送的缓冲策略有个取舍如果每个发送都等消息循环处理完成再继续速度会慢但逻辑简单清晰。如果每个业务线程都直接往同一个socket上WSASend则必须用锁保护发送缓冲和重叠结构否则两个线程同时操作一个OVERLAPPED内部状态被踩坏。这个项目里客户端没有并发发送所有发送都从主线程或一个专门的发送队列往下走最大程度避免锁竞争带来的耦合。4.3 心跳与超时让客户端不变成僵尸连接客户端连上服务端之后要是断了服务端不一定能立刻感知。如果是程序正常退出、socket连接是优雅关闭双方都能检测到。但如果客户端程序直接被杀或者中途进了断网区域TCP层面要等很久才能发现连接失效。这种“僵尸连接”占着服务端的连接槽位不释放在长时间运行的系统里最后会把连接资源耗尽。客户端的职责就是主动维护心跳。定时器线程每隔5秒发一个自定义的Ping包服务端回Pong包。客户端连续三次没收到Pong就判定连接失效主动关闭socket并触发重连逻辑。这里要特别注意心跳也是数据包要和应用层协议协商好不能导致服务端拆包逻辑误判。常见做法是心跳包的包头里用一个特殊type字段区分业务包与心跳包。另外一个玄学场景是局域网内的网络不稳定心跳链路偶尔丢一台客户端判断超时重连然后服务端也检测到旧连接断开。避免误判的常见做法是客户端的超时阈值比心跳间隔大一个量级。5秒心跳、15秒判定超时稳妥。5. 异步多线程socket避坑服务端和客户端共6个典型问题5.1 报错“只有每个套接字地址只允许使用一次”现象服务端重启后bind失败报错“Windows socket error: 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。(10048)”。原因上一轮进程退出时监听端口还有客户端连接处于TIME_WAIT状态TIME_WAIT会保留一段时间的端口占用。另外如果你没有正确调用setsockopt(SO_REUSEADDR)重启时就算旧连接已经断开bind也会失败。解决socket创建后立即设置SO_REUSEADDR。这个选项在开发阶段几乎必开不具备影响生产环境的副作用。如果线上还报检查系统防火墙、网络进程占端口、或者service端口冲突。用netstat -ano | findstr 9800可以看到谁占着端口obligatory exe路径一并看。5.2 OVERLAPPED结构释放过早野指针在崩溃报告里翻车现象服务端跑一段时间后worker线程随机崩溃崩溃时GetQueuedCompletionStatus返回的一个OVERLAPPED地址已经被访问但指针指向的内容不是预期的ClientContext。原因ClientContext内存被提前释放了。最常见的操作是在某个错误处理分支里调用了delete pCtx但此时可能还有一个pending的I/O操作没有完成。过一会儿操作完成worker线程从完成队列取回结构发现pCtx对应的内存已被释放。解决引用计数是必须的。这个项目里的管理方式是每次PostRecv/PostSend成功后计数1每次从GetQueuedCompletionStatus取回完成包后计数-1计数归零才delete。核心思想是“谁投递谁负责谁完成谁释放”并且释放动作只发生在完成操作的线程里不发生在accept线程和错误处理分支里。5.3 跨线程关闭socket导致的崩溃现象业务线程判断某个客户端数据异常直接调用closesocket(socket)结果worker线程崩溃或断开后socket资源没有被真正回收。原因在IOCP模式下调用closesocket会立即触发所有挂起操作的失败完成但完成的机制是“非正常中止”。GetQueuedCompletionStatus会返回FALSE并且GetLastError为ERROR_OPERATION_ABORTED。如果整个释放流程没有做成引用计数同步关闭就可能出现socket被关闭但重叠操作还没从完成队列里取出的错位。解决跨线程不要直接closesocket先标记ClientContext状态为“关闭中”然后调用shutdown(socket, SD_BOTH)让对方端的收发操作被唤醒。被唤醒的I/O会走正常完成路径在worker线程里判断状态后统一关闭socket并释放内存。这个动作全部收敛到worker线程就没有跨线程socket竞争问题。5.4 服务端接口测试连接数上不去的瓶颈现象本地压测时连接数到几百就上不去新连接connect超时已有连接也是发一个包就要等很久。原因三个可能。第一listen的backlog设得太小系统默认值在Winsock里是SOMAXCONN但如果你写死了比如5半连接队列瞬间堆满。第二Accept线程处理速度跟不上比如accept循环里做了一次较重的日志写文件或者业务代码在accept后立刻做了DNS查询拖慢整个循环。第三客户端连接大量重置网络接口测试过程中WSAECONNRESET问题。解决listen(sListen, SOMAXCONN)accept循环里不做任何额外逻辑只做“接管连接 关联IOCP 投递第一个recv”针对WSAECONNRESET加continue跳过。压测瓶颈排查用netstat -ano | findstr 端口重点关注LISTEN和SYN_RECEIVED状态。5.5 粘包与半包读到的长度不等于报文长度现象从服务端收到的数据有时候一条消息没读完整有时候多读到下一条消息的头部业务解析直接报错。原因TCP是字节流协议不保证一次接收调用返回的数据必然等于应用层的一个完整报文。数据可能被拆成多段半包也可能多段拼在一起到达粘包。这是TCP底层机制导致的不是代码随机抽风。解决协议层必须做报文边界划分。这个项目采用最常见的“包头包体”方案包头固定4字节存储整个包体的长度业务解析时按长度字段切分报文。拆包逻辑如下int ProcessRecvData(ClientContext* pCtx) { while (pCtx-m_recvLen 4) { // 至少够读一个包头 int nPktLen *(int*)pCtx-m_recvBuf; if (nPktLen 0 || nPktLen MAX_PKT_SIZE) { // 长度字段非法说明数据腐蚀直接掐断连接 return -1; } if (pCtx-m_recvLen 4 nPktLen) { // 半包等后续数据到达再做处理 break; } // 取出一整个完整包交给业务handler HandlePacket(pCtx-m_recvBuf 4, nPktLen); // 剩余数据往前搬移m_recvLen更新 int nLeft pCtx-m_recvLen - 4 - nPktLen; memmove(pCtx-m_recvBuf, pCtx-m_recvBuf 4 nPktLen, nLeft); pCtx-m_recvLen nLeft; } return 0; }拆包的核心是“先判断长度是否完整再逐包取出”。这里有个容易忽略的细节包头长度字段做所谓“数据对齐”时要注意字节序。这个项目里服务端和客户端都在同一台机器的同一个字节序下运行直接按本机字节序读取。如果要跨平台或者跨语言通信统一转网络字节序接收端再转回来否则会出现长度字段读取错乱。5.6 AcceptEx的坑完成通知与内存布局现象追求更高性能时把accept改成AcceptEx结果出现了连接接收后OnIoComplete拿到的地址信息是错的部分连接无法正常通信。原因AcceptEx有三个隐藏特性。第一它必须单独取函数指针lpfnAcceptEx常规的WSARecv、WSASend不需要。第二AcceptEx会在I/O完成之后才返回本地地址与远程地址但这个地址是“比实际地址多一个16字节”的固定大小结构。如果你给的小了地址信息就被截断给大了内存又浪费。第三AcceptEx接收到的socket关联IOCP的时机和普通accept不同需要在完成处理里手动关联。解决当用AcceptEx时两个地址结构要用SOCKADDR_STORAGE这样一个兼容所有地址族的结构组大小是128字节。另外关联IOCP的动作必须在完成回调里做确保完全没问题。如果服务器只有几个客户端连接且Accept压力小建议直接用普通accept省去这一层复杂的处理和潜在出错点。6. 压测与验证给服务端和客户端一个可量化的收尾6.1 本地压测环境搭建与指标解读模块写完了不压等于没写。我的习惯是改完核心代码先跑一轮通断性测试再跑一轮压力测试。通断性测试单客户端连接服务端发几百个小的业务包确认收发数据和顺序完全一致。压力测试环境我会用现成工具配合自写小脚本。工具层面最简单的方案是telnet或者直接用客户端连接发数据最可靠的方式是写一个循环创建连接、每个连接瞬间发N个包的压测程序。资源包里没有附带压测工具但完全可以用已有的client代码改造。大致思路是配置压测参数并发连接数、每连接发送包数、包大小运行压测后记录三个指标总耗时、成功发包数、失败连接数观察CPU和内存曲线连接关闭时机是否正确压测时连接数从500起步然后1000、2000、5000逐步加。如果某个量级出现了大量连接失败或CPU达到90%记下来这个点就是服务的瓶颈区间。常见瓶颈包括Worker线程数配置不合适、内部业务处理太慢、接收缓冲太小导致频繁搬移。每次压测完看一眼服务端进程的内存。异步socket程序最容易在峰值连接下泄漏内存因为关闭流程出错或引用计数不对。跑两轮5000连接压测只要内存明显增长一定有泄漏。Windows任务管理器或者Process Explorer都可以看得很清楚。6.2 三个让服务端更稳的实战技巧第一个技巧是压测时顺便验证半包和粘包场景。不要只发规整的业务包故意把几个包拼在一起发送或者把一个包拆成两段发送观察service的处理是否稳定。我压测完总把自己开发机上客户端收发逻辑里加入“随机拼接”的逻辑验证拆包功能到位。第二个技巧是给Worker线程加上日志开关但默认关闭。平时跑生产不开日志压测时打开重点看有没有GetQueuedCompletionStatus返回错误、有没有dwBytes 0的连接被误判。一旦压测中日志量暴增说明有大量对端正常关连接被当作错误处理了。第三个技巧是结束压测后认真检查TIME_WAIT状态的连接数量。在命令行里输入netstat -ano | findstr TIME_WAIT如果瞬间出现几百个TIME_WAIT说明服务端或者客户端有大量非正常关闭的连接。TIME_WAIT数量多了会导致新连接绑定端口失败就是前面提到的10048错误。客户端侧可以用SO_LINGER设置0强制关闭但服务端不要轻易用这会影响正常关闭的逻辑。常见做法是调低TIME_WAIT时间或者在测试环境直接忽略它生产环境通过改进关闭逻辑减少非正常关闭的数量。从那以后我每接一个socket项目不管代码是谁写的都会先把“连接生命周期管理”这一关仔细过一遍。虽然麻烦但有效。你看完这个项目的完整链路也可以先跑起来然后拿自己项目里最丑陋的那部分过来对比着改。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进