简介:RAR 压缩包内是一套面向网络编程开发者的 Socket 重叠 I/O 完成例程,以 TCP 通信为背景,提供客户端与服务器端两个配套工程,演示 Windows 下利用重叠 I/O 与完成端口处理高并发网络请求。整体共 28 个文件、约 6.66MB,核心内容为 C++ 源码、Visual Studio 工程和解决方案文件,同时包含已编译的 exe、pdb 调试符号及 obj 中间文件,目录按客户端与服务端分离,便于直接打开、编译或运行。目前已有 284 人学习,适合想掌握异步 Socket 编程、完成端口模型或高并发服务器设计的开发者。通过该例程,读者既能查看客户端初始化连接、收发数据的实现,也能分析服务端如何借助完成端口管理多个客户端连接,从而理解重叠 I/O 的异步机制、线程模型与数据收发过程,为构建高性能网络服务提供参考。 前阵子一个朋友发来一个socket工程文件,里面是一个用重叠IO加上完成例程写的数据收发服务端。他调了一整天,说客户端连上没多大一会儿就报"connection was closed unexpectedly",看服务端日志里全是"重叠IO操作在进行中"。我一看代码就明白问题出在哪了:他把WSA_IO_PENDING当成错误处理了,一看到这个错误码就释放掉重叠结构和缓冲区,内核的完成例程之后再来访问,自然崩得莫名其妙。
这篇文章就把这个工程例子彻底讲透:重叠IO完成例程(Overlapped I/O with Completion Routine)在Windows socket编程里到底是怎么工作的,怎么用它做一个能连续接收文件的服务端,以及我在实测中踩过的那些坑。如果你正准备写一个Windows下的socket通信程序,或者在网上找例程却看不懂,这篇应该能帮上忙。先说明一下,相关搜索词里混着一堆和Windows socket无关的内容,比如数据库的socket连接失败、Vivado工程文件管理,那些不在本文范围内,这里只讲重叠IO和完成例程。
1. 为什么要用重叠IO完成例程:先从阻塞式socket的事故说起
1.1 从阻塞式socket到重叠IO:一次对比
大多数人写socket服务端,第一版一定是从阻塞式开始的。单客户端还好,一个recv堵在那里,数据到了就返回;但第二个客户端来了怎么办?开线程。于是每个连接一个线程,看起来能跑,连接数一个多,几百个线程挤在一起,上下文切换、锁竞争、内存占用全上来了。Windows上的线程不是免费的,一个线程默认栈就是1MB,光线程栈就能吃掉几百MB内存,而且线程切换开销在长连接和短连接频繁切换的场景下非常明显。
接着是select模型。select解决了单线程等待多个socket的问题,但一个FD_SETSIZE默认只有64,要处理更多连接还得改宏重新编译;而且每轮都要把fd_set从用户态拷到内核态又拷回来,数据到了你不知道是哪一条,还得逐个FD_ISSET遍历。小型工具够用,工程级服务端有点勉强。
重叠IO的出发点完全不同。它不是让你"等待"一个socket,而是把一个读写操作"投递"给内核,然后你该干嘛干嘛,操作完成之后内核来通知你。打个比方:阻塞模式是你站在快递柜前等包裹;select模式是你每隔三秒刷一次快递柜;重叠IO是你把取件码给了驿站老板,包裹到了他打电话叫你来拿。你不用反复看,但包裹到的时候你需要去一趟。完成例程就是那个"电话通知"。
1.2 完成例程、事件通知、IOCP:三种通知方式怎么选
重叠IO有几种完成通知方式,很多人一开始就搞混:
- 事件通知:每个重叠操作绑定一个WSAEVENT事件,然后用WSAWaitForMultipleEvents等待。问题在于一个线程最多等64个事件(WSA_MAXIMUM_WAIT_EVENTS),连接一多,你得自己搞线程组和事件路由,复杂度直接起飞。
- 完成例程(Completion Routine):把回调函数直接传给WSARecv/WSASend,I/O完成时系统把函数指针排到发起线程的APC队列里。发起线程只要处于可告警等待状态,回调就会被执行。代码上就是一个"完事之后调我一下"的感觉,线性思维,好理解。
- IOCP(完成端口):完成事件进入一个完成队列,线程池里的线程用GetQueuedCompletionStatus去取。这才是一般所说服务端高并发的正统方案,但要自己管理线程池、内存块、连接上下文,工程量大得多。
我用完成例程,是因为它是重叠IO里最简单直接的完成通知形态,适合做例程、做小规模工具。下面的表格可以直观看出几类模型的差异:
| 模型 | 并发能力 | 编程复杂度 | 通知方式 | 适用场景 |
|---|---|---|---|---|
| 阻塞+多线程 | 低(受线程数限制) | 低 | 无(线程阻塞) | 单连接、调试用 |
| select | 中(FD_SETSIZE限制) | 中 | 轮询 | 少量连接 |
| 重叠IO+事件通知 | 中(64事件/线程) | 高 | 事件 | 中等连接、教学 |
| 重叠IO+完成例程 | 中高 | 中 | APC回调 | 小规模服务端、文件传输 |
| IOCP | 高 | 很高 | 完成队列 | 大规模网关、IM、IoT接入 |
2. 完成例程的底层运作机制:WSAOVERLAPPED、APC队列与可告警等待
2.1 重叠结构和缓冲区生命周期:不搞清楚这里必崩
每次投递一个异步收发,你都要有一个WSAOVERLAPPED结构,以及一个真正用来放数据的缓冲区。我习惯把它们包在一个per-IO结构体里:
typedef struct _PER_IO_OPERATION { WSAOVERLAPPED overlapped; // 必须是第一个成员,方便CONTAINING_RECORD WSABUF wsaBuf; // WSARecv/WSASend需要的缓冲区描述 char buffer[64 * 1024]; // 实际数据缓冲区 SOCKET clientSocket; // 所属连接 HANDLE hFile; // 接收文件的目标文件句柄 int recvState; // 0=收包头 1=收文件数据 LARGE_INTEGER fileSize; ULONGLONG received; // 该文件已接收字节数 } PER_IO_OPERATION, *LPPER_IO_OPERATION;完成例程回调里,系统只给你LPWSAOVERLAPPED指针,用CONTAINING_RECORD把它还原成PER_IO_OPERATION:
LPPER_IO_OPERATION op = CONTAINING_RECORD(lpOverlapped, PER_IO_OPERATION, overlapped);缓冲区生命周期是这个模型里最大的坑。WSARecv投递之后,内核可能在几十毫秒甚至几秒之后才往缓冲区里填数据。如果你在栈上定义一个局部数组就调WSARecv,函数一返回,栈空间就失效了,等完成例程触发时,内核往已经失效的栈地址写数据,后面跑不跑崩全看运气。正确做法:per-IO结构用new/malloc从堆分配,在完成例程里处理完再delete/free,投递后绝不提前释放。
2.2 完成例程在哪个线程执行:一个能让你怀疑人生的认知
这里必须说清楚:完成例程不是在"某个新线程"上执行,而是在发起重叠IO操作的那个线程的APC队列里排队。线程必须处于可告警等待状态(SleepEx、WaitForSingleObjectEx、WSAWaitForMultipleEvents),APC才会被拿出来执行。
三个反面场景,我都见过:
- 投递WSARecv后,线程用Sleep而不是SleepEx,或者用不带Ex的WaitForSingleObject,回调永远不执行,表现就是"连接正常,但啥都收不到"。
- 投递WSARecv的线程直接退出了,回调连排队的地方都没有,资源泄露。
- 一个工作线程在SleepEx,但它等的是另一个线程发起的I/O,APC还是派到发起线程的队列里,工作线程干等,回调依然不执行。
所以工程模型必须保证"投递"和"等待"在同一个线程。我做接收服务端时用的循环是这样的:
u_long mode = 1; ioctlsocket(listenSocket, FIONBIO, &mode); while (running) { SOCKET client = accept(listenSocket, NULL, NULL); while (client != INVALID_SOCKET) { LPPER_IO_CONNECT ctx = CreateClientContext(client); if (ctx) ctx->PostRecv(); client = accept(listenSocket, NULL, NULL); } SleepEx(50, TRUE); // 关键:可告警等待,让APC里的完成例程被处理 }这个写法的核心是:accept是非阻塞的,有连接就投递WSARecv,没连接就SleepEx处理APC回调。完成例程里继续投递下一次接收,下一次接收也排队到这个线程,整个服务端就是一条循环,好理解也好调试。如果你用Qt写过网络程序,看过QSocketNotifier不能从别的线程enable/disable的报错,其实和这是同类问题:异步模型的线程归属约束,从一开始就要遵守。
2.3 WSA_IO_PENDING不是错误:这是异步的起点
调用WSARecv后会有三种结果:
- 返回0:数据已经就绪,本次调用直接填好了缓冲区。注意,即使立即完成,完成例程仍然会排队被调用一次,这个设计有点反直觉。
- 返回SOCKET_ERROR,WSAGetLastError()返回WSA_IO_PENDING(错误码997):数据未就绪,操作已经在内核挂起,完成例程将来会触发。
- 返回SOCKET_ERROR,错误码不是WSA_IO_PENDING:真正的错误,需要清理资源。
所以投递函数的写法是:
int ret = WSARecv(socket, &op->wsaBuf, 1, &bytes, &flags, &op->overlapped, OnRecvComplete); if (ret == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING) { // 只有这里才是真错误 Cleanup(op); }我那个朋友的代码就是条件写反了,在WSA_IO_PENDING时进了清理分支,把PER_IO_OPERATION直接delete掉,结果内核完成例程一触发就访问已经被释放的内存。这种问题在Debug版本下会在堆检查处直接崩,在Release版本下表现为随机崩溃、数据错乱,特别难查。
3. 工程例程实录:一个文件传输服务端的重叠IO接收实现
3.1 数据包设计:文件名、大小和数据块怎么组织
要传文件,光有socket收发还不够,得约定协议。我用的简单二进制包头是32字节,紧接着是文件名,再往后是文件数据:
#define FILE_MAGIC 0x534F434B // "SOCK" #pragma pack(push, 1) typedef struct _FILE_HEADER { DWORD magic; // FILE_MAGIC DWORD version; // 1 ULONGLONG fileSize; // 文件总字节数,小端 DWORD nameLen; // 文件名长度 BYTE reserved[12]; } FILE_HEADER; #pragma pack(pop)之所以必须设计包头,是因为服务端需要知道落盘路径、文件名和总大小,否则没法创建文件也不知道该收多少字节。TCP是字节流,没有包边界,所以服务端必须自己处理"一个包头被分成两次到达"和"一次到达包含了下一个文件的开头"这两种情况。我在per-IO结构里用recvState字段区分当前是收包头还是收文件数据:
- 收包头阶段:期望字节数逐步累计,直到凑满sizeof(FILE_HEADER),然后解析包头,创建文件,切到收数据阶段。
- 收文件数据阶段:每收到一段就写入文件,received累计到fileSize后,说明当前文件收完,把文件句柄关掉,状态机回到收包头阶段,准备接收下一个文件。
状态机不复杂,但只要漏了"累计"这一步,接收就会错位。
3.2 服务端核心代码:完成例程里的关键动作
下面是完成例程回调的骨架,我简化了文件操作细节,重点看流程:
void CALLBACK OnRecvComplete(DWORD dwError, DWORD cbTransferred, LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags) { LPPER_IO_OPERATION op = CONTAINING_RECORD(lpOverlapped, PER_IO_OPERATION, overlapped); if (dwError != 0) { if (dwError != WSA_OPERATION_ABORTED) { // 真正的传输错误 } Cleanup(op); return; } if (cbTransferred == 0) { // 对端调用 shutdown(SD_SEND) 或正常关闭:文件流结束 CloseFile(op); return; } // 把本次收到的数据写入文件 ProcessReceivedData(op, cbTransferred); // 关键:继续投递下一次接收 PostRecv(op); }PostRecv里面做的事情就是把op->wsaBuf.buf指向op->buffer、len设为缓冲区大小,清零错误标志,再次调用WSARecv并传入同一个完成例程。为什么这里可以安全复用同一个PER_IO_OPERATION?因为你能走到这一步,说明前一个重叠操作已经完成,内核不再引用这个结构了,复用是安全的。
要特别记住:每次回调最后必须重新投递WSARecv。很多从网上抄来的例程漏了这一步,表现就是"第一个包能收到,后面的数据全部没有",客户端那侧看到的就是连接被莫名其妙关闭。还有一种错误是收到一个完整文件后又想收下一个,却忘了把recvState切回0,结果第二个文件的包头被当成文件数据写盘,文件全坏了。
3.3 客户端发送端:先用阻塞式发送跑通
客户端第一版没必要一上来就重叠IO,文件上行场景做阻塞循环send就够了。实验目标是验证服务端重叠IO接收,客户端越简单越不容易引入新变量。流程:
- 组装FILE_HEADER,注意fileSize用小端,nameLen同理;
- send包头,检查返回值是否等于sizeof(FILE_HEADER);
- 循环读文件块,调用send发送,send的返回值可能小于你要发的长度,必须循环处理剩余部分;
- 全部发完后,调用shutdown(SD_SEND),表示"我的数据发完了,写方向关闭"。
shutdown半关闭是个好习惯。如果客户端直接close,TCP会发FIN,服务端最终也会收到0,但shutdown让语义更明确,服务端可以区分"数据发完了"和"异常断开"。还有一个经验:客户端发完立刻退出进程,是有可能发出RST的,因为进程退出后系统会强制关闭所有句柄,如果发送缓冲里还有残余数据没发完,对端收到的可能是RST而不是FIN。所以不要发完文件马上return,用shutdown之后等一小会儿,或者干脆等对端关闭连接。
4. 实测踩坑记录:操作进行中、连接被关闭、bind失败三个高频问题
4.1 "重叠IO操作在进行中"到底要不要处理
这个报错在C++里不是异常,而是WSA_IO_PENDING错误码;在C#里如果你用SocketAsyncEventArgs,它会以异常形式抛出来,说"重叠IO操作在进行中"。C#那个往往是同一个Socket在异步接收还没完成时又启动了另一个接收,属于API误用,和C++里的997本质上对应的是同一套底层机制。
遇到这个错误码,我的建议是:在C++服务端里把它当"正常挂起"处理,不要打错误日志,不要做清理,不要panic。日志里如果一堆"重叠IO操作在进行中",通常不是你程序错了,而是连接比较空闲、数据没到,属于健康状态。真正要警惕的是那种"随机崩溃,偶尔报错"的情况,那才是你提前释放了缓冲区的信号。
4.2 "socket connection was closed unexpectedly"的完整排查链路
很多调用AI API的客户端会报这句话,看起来像是远端服务问题,但如果是在调试自己的socket服务端时出现,就要按顺序排查:
- 服务端是否保持在SleepEx循环里?如果主循环因为某个回调崩溃、阻塞调用、死锁退出,服务端就不会再收数据,对端发送窗口很快被填满,然后触发超时断开。
- 是否漏了PostRecv?漏投递后继续发下一批数据的客户端会一直等不到ACK,最后连接被系统判定超时。
- 包头字段是否做了字节序转换?如果收到长度字段没有ntohl转换,一个4字节的小数字会被读成几GB,代码可能malloc失败或状态机永远等不满,表现就是连接异常。
- 客户端是否传一半就关了?大文件传一半,客户端send超时或者收到网络错误,直接close,服务端拿到FIN,文件写一半就结束了。要区分是正常结束还是异常结束,看服务端收到的错误码是0还是WSAECONNRESET。
我用Wireshark抓包来看FIN还是RST:FIN代表正常关闭,RST代表异常,比如往一个对端已经关闭的socket里写数据。如果看到RST,基本可以确定是代码时序问题,而不是网络问题。
4.3 bind失败:端口占用和SO_REUSEADDR的正确用法
"only one usage of each
本文还有配套的精品资源,点击获取