overlapped i/o
概述
Overlapped I/O是Windows平台特有的异步I/O模型,是Windows高性能网络编程(尤其是IOCP)的基础
为什么需要它
- 同步
I/O(如Linux下常见的recv/send)调用会阻塞线程,直到操作完成 - 要支持高并发,传统做法是"一个连接一个线程",但线程数量一多,上下文切换开销就会把系统拖垮
Overlapped I/O 的思路是
- 发起
I/O请求后立即返回,不阻塞线程,操作在后台(内核)异步完成,完成后通过某种机制通知应用程序- 这个"立即返回、后台完成"的语义,就是 "
overlapped"(重叠)这个名字的来源 I/O操作与你的程序执行是重叠(并发)进行的,而不是顺序阻塞
- 这个"立即返回、后台完成"的语义,就是 "
核心数据结构:OVERLAPPED
- 每发起一个异步
I/O操作,都要传入一个OVERLAPPED结构体实例,它相当于这次操作的"上下文/句柄"- 操作完成后系统通过它告诉你结果
|
1 2 3 4 5 6 7 8 9 10 11 12 |
typedef struct _OVERLAPPED { ULONG_PTR Internal; // 内核使用,存放错误码 ULONG_PTR InternalHigh; // 内核使用,存放传输字节数 union { struct { DWORD Offset; // 文件操作的偏移量(低32位) DWORD OffsetHigh; // 文件操作的偏移量(高32位) }; PVOID Pointer; }; HANDLE hEvent; // 关联的事件对象,用于通知完成 } OVERLAPPED; |
- 实践中通常会把它嵌入到自定义结构体的第一个字段,这样收到完成通知时可以通过指针强转拿到自己的上下文数据
|
1 2 3 4 5 6 7 |
typedef struct { OVERLAPPED ov; // 必须放在第一个字段 WSABUF wsabuf; char buffer[4096]; int operation_type; // 自定义:区分是读操作还是写操作 SOCKET client_socket; } PER_IO_CONTEXT; |
使用 Overlapped I/O 的三种通知方式
事件通知(Event-based)
- 每个
OVERLAPPED关联一个事件对象,I/O完成后事件被置位,通过WaitForSingleObject/WaitForMultipleObjects等待
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
OVERLAPPED ov = {0}; ov.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL); char buf[1024]; WSABUF wsabuf = { sizeof(buf), buf }; DWORD flags = 0, bytes; int ret = WSARecv(sock, &wsabuf, 1, &bytes, &flags, &ov, NULL); if (ret == SOCKET_ERROR) { if (WSAGetLastError() == WSA_IO_PENDING) { // 操作已提交,正在后台进行 WaitForSingleObject(ov.hEvent, INFINITE); GetOverlappedResult((HANDLE)sock, &ov, &bytes, FALSE); // 现在 buf 里有数据,bytes 是实际收到的字节数 } } |
WSA_IO_PENDINGWSA_IO_PENDING是关键返回值,表示"操作已受理但尚未完成,稍后会通知你",这不是错误,是正常的异步流程
- 局限:
WaitForMultipleObjects最多只能等待64个句柄,这种方式无法支撑大规模并发连接,通常只用于少量句柄的简单场景
完成例程(Completion Routine / APC)
I/O完成后,系统在发起调用的线程里排队一个APC (Asynchronous Procedure Call),线程进入可警醒等待状态时- 如调用
SleepEx、WaitForSingleObjectEx且alertable=TRUE就会执行回调函数:
- 如调用
|
1 2 3 4 5 6 7 8 9 10 11 12 |
void CALLBACK CompletionRoutine( DWORD dwError, DWORD dwBytesTransferred, LPOVERLAPPED lpOverlapped, DWORD dwFlags) { // 处理完成的I/O printf("收到 %lu 字节\n", dwBytesTransferred); } WSARecv(sock, &wsabuf, 1, NULL, &flags, &ov, CompletionRoutine); // 必须让线程进入alertable状态才会触发回调 SleepEx(INFINITE, TRUE); |
- 局限:
- 回调只在发起
I/O的那个线程里执行(APC是线程关联的),且线程必须主动进入alertable状态 - 编程模型较别扭,实践中较少用于高并发服务器
- 回调只在发起
IOCP(I/O Completion Port)—— 生产级方案
- 这是
Windows下做高并发服务器的标准方案,本质是一个内核维护的队列- 多个线程可以从同一个
IOCP句柄上等待"任意一个"已完成的I/O操作,无需为每个socket关联专属线程
- 多个线程可以从同一个
IOCP
IOCP 是什么
IOCP本质是Windows内核提供的一个多生产者-多消费者的任务队列:- 生产者:各种异步
I/O操作完成后,内核把"完成包"塞进这个队列 - 消费者:应用程序开若干个工作线程,反复从队列里取"已完成的包"来处理
- 生产者:各种异步
解决的核心问题
- 如何用少量线程,高效地服务成千上万个并发连接,同时充分利用多核
CPU,且避免线程频繁切换的开销
各种异步 I/O 操作
核心 API
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
HANDLE CreateIoCompletionPort( HANDLE FileHandle, // 要关联的句柄(socket/文件),首次创建传INVALID_HANDLE_VALUE HANDLE ExistingCompletionPort, // 首次创建传NULL,后续关联传已创建的IOCP句柄 ULONG_PTR CompletionKey, // 与这个句柄关联的用户自定义数据 DWORD NumberOfConcurrentThreads // 允许同时运行的线程数,0表示=CPU核心数 ); BOOL GetQueuedCompletionStatus( HANDLE CompletionPort, LPDWORD lpNumberOfBytesTransferred, // 输出:实际传输字节数 PULONG_PTR lpCompletionKey, // 输出:关联时传入的key LPOVERLAPPED *lpOverlapped, // 输出:对应的OVERLAPPED指针 DWORD dwMilliseconds // 超时时间 ); BOOL PostQueuedCompletionStatus( HANDLE CompletionPort, DWORD dwNumberOfBytesTransferred, ULONG_PTR dwCompletionKey, LPOVERLAPPED lpOverlapped ); // 手动向队列投递一个"假的"完成包,常用于通知工作线程退出 |
核心概念
- 完成端口(
Completion Port):- 一个内核对象,
socket的I/O操作完成后,结果会投递到这个端口
- 一个内核对象,
- 工作线程池:
- 固定数量的线程从完成端口取"完成包"来处理,线程数一般设为
CPU核数 × 2
- 固定数量的线程从完成端口取"完成包"来处理,线程数一般设为
- 重叠结构(
OVERLAPPED):- 每次异步
I/O操作都要传一个OVERLAPPED结构,用来追踪这次操作
- 每次异步
工作流程图
完整工作流程
- 第一步:创建
IOCP对象- 第四个参数
NumberOfConcurrentThreads是关键调优点
- 第四个参数
|
1 2 |
// 第一次调用用于"创建",不关联任何句柄 HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0); |
- 第二步:创建工作线程池
|
1 2 3 4 5 6 7 |
SYSTEM_INFO si; GetSystemInfo(&si); int num_threads = si.dwNumberOfProcessors * 2; // 经验值,通常CPU核数的1~2倍 for (int i = 0; i < num_threads; i++) { CreateThread(NULL, 0, WorkerThreadProc, iocp, 0, NULL); } |
- 第三步:把
socket关联到IOCPCreateIoCompletionPort这个函数身兼两职- 第一次调用(
ExistingCompletionPort=NULL)是"创建" - 之后每次调用(传入已有的
iocp句柄)都是"关联"一个新句柄
|
1 2 3 4 5 6 7 |
SOCKET client_sock = accept(listen_sock, NULL, NULL); // 关联时可以传入自定义 CompletionKey,通常传每个连接的上下文指针 PER_SOCKET_CONTEXT* sock_ctx = malloc(sizeof(PER_SOCKET_CONTEXT)); sock_ctx->socket = client_sock; CreateIoCompletionPort((HANDLE)client_sock, iocp, (ULONG_PTR)sock_ctx, 0); |
- 第四步:投递异步操作
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
PER_IO_CONTEXT* io_ctx = malloc(sizeof(PER_IO_CONTEXT)); memset(&io_ctx->ov, 0, sizeof(OVERLAPPED)); io_ctx->wsabuf.buf = io_ctx->buffer; io_ctx->wsabuf.len = sizeof(io_ctx->buffer); io_ctx->op_type = OP_RECV; DWORD flags = 0; int ret = WSARecv(client_sock, &io_ctx->wsabuf, 1, NULL, &flags, &io_ctx->ov, NULL); if (ret == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING) { // 真正的错误,不是"正在处理中" handle_error(); } // 无论是立即完成还是WSA_IO_PENDING,都会在IOCP队列里产生一个完成通知 // 这是IOCP设计的重要特性:即使操作"立即"完成,也统一走完成端口通知,不特殊处理 |
- 第五步:工作线程循环取任务
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
DWORD WINAPI WorkerThreadProc(LPVOID param) { HANDLE iocp = (HANDLE)param; DWORD bytes_transferred; ULONG_PTR completion_key; LPOVERLAPPED pov; while (1) { BOOL ok = GetQueuedCompletionStatus(iocp, &bytes_transferred, &completion_key, &pov, INFINITE); PER_SOCKET_CONTEXT* sock_ctx = (PER_SOCKET_CONTEXT*)completion_key; PER_IO_CONTEXT* io_ctx = (PER_IO_CONTEXT*)pov; if (!ok) { // ok=FALSE 且 pov非NULL: 说明I/O操作本身失败了(如客户端断开) // ok=FALSE 且 pov为NULL: 说明GetQueuedCompletionStatus本身调用失败(如超时/句柄关闭) if (pov != NULL) { close_connection(sock_ctx); } free(io_ctx); continue; } if (bytes_transferred == 0 && io_ctx->op_type == OP_RECV) { // 收到0字节的recv完成,意味着对端正常关闭连接(类似Linux下read返回0) close_connection(sock_ctx); free(io_ctx); continue; } switch (io_ctx->op_type) { case OP_RECV: process_received_data(sock_ctx, io_ctx->buffer, bytes_transferred); // 处理完可以再投递下一次WSARecv,形成循环 post_next_recv(sock_ctx); break; case OP_SEND: // 发送完成,可以释放发送缓冲区或投递下一个发送 break; case OP_ACCEPT: handle_new_accept(io_ctx); break; } free(io_ctx); } return 0; } |
关键设计点深入
NumberOfConcurrentThreads:并发线程数控制- 这是
IOCP最容易被忽视但很重要的调优参数 - 它不是"最多能有多少个线程等在这个
IOCP上"(等待的线程数不受限制),而是"同一时刻允许同时处于运行状态、处理完成包的线程数" - 这个机制的目的是避免过度并行导致的上下文切换开销
线程数超过CPU核心数并不会带来性能提升,反而会增加调度开销
这是IOCP内建的"活跃线程数节流阀",是epoll模型里没有的、需要应用层自己控制的能力 - 设为
0时系统默认按CPU核心数处理
- 这是
|
1 2 3 4 5 |
假如工作线程池有8个线程,NumberOfConcurrentThreads设为4 --> 8个线程都可以调用GetQueuedCompletionStatus等待 --> 但内核会保证同一时刻最多只有4个线程在"运行"(不算被阻塞挂起的) --> 如果某个正在运行的线程因为系统调用阻塞了(比如做了个耗时的磁盘IO或加锁等待) 内核会自动唤醒队列里排队的第5个线程来补上运行名额 |
- 完成包的处理顺序:
LIFO而非FIFOIOCP唤醒等待线程的顺序是后进先出(LIFO,类似栈),而不是按等待的先后顺序- 这是刻意设计的:
- 最近被唤醒过的线程,它的线程栈、
TLB、缓存数据更可能还"热"在CPU缓存里,唤醒它比唤醒一个"冷"了很久的线程效率更高(缓存局部性优化)
|
1 2 |
线程A、B、C 依次调用 GetQueuedCompletionStatus 进入等待(A最先等) 新任务到达 --> 优先唤醒最后进入等待的线程(很可能是C) |
AcceptEx:预投递接受连接- 标准
accept()是阻塞调用,IOCP生态提供AcceptEx让接受新连接也纳入异步框架 AcceptEx还支持"接受连接的同时一并接收第一批数据",减少一次额外的系统调用往返,这是标准accept()+recv()组合做不到的优化- 实践中通常会预先投递多个
AcceptEx(比如同时挂10个),这样多个新连接同时涌入时不会因为"处理完一个才投递下一个accept"而错过
- 标准
|
1 2 3 4 5 6 7 8 9 10 11 12 |
LPFN_ACCEPTEX AcceptExFn; // 需要先通过 WSAIoctl 获取函数指针 (Windows扩展函数的常规做法) SOCKET accept_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 预先创建好的socket DWORD bytes; char addr_buf[(sizeof(sockaddr_in) + 16) * 2]; // 存放本地/远端地址信息 OVERLAPPED ov = {0}; AcceptExFn(listen_sock, accept_sock, addr_buf, 0, sizeof(sockaddr_in) + 16, sizeof(sockaddr_in) + 16, &bytes, &ov); // 立即返回,新连接到达时通过IOCP通知,而不是阻塞在accept上 |
- 内存管理:
Context池化- 生产级 IOCP 服务器几乎都会用内存池来管理
PER_IO_CONTEXT和PER_SOCKET_CONTEXT,而不是每次malloc/free - 原因:高并发场景下频繁
malloc/free本身就是性能瓶颈(涉及堆锁竞争),且内存碎片化会拖慢长期运行的服务
- 生产级 IOCP 服务器几乎都会用内存池来管理
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 典型做法:预分配一大块,用无锁链表或加锁的空闲列表管理 typedef struct { PER_IO_CONTEXT* free_list; CRITICAL_SECTION lock; } IO_CONTEXT_POOL; PER_IO_CONTEXT* acquire_context(IO_CONTEXT_POOL* pool) { EnterCriticalSection(&pool->lock); PER_IO_CONTEXT* ctx = pool->free_list; if (ctx) pool->free_list = ctx->next; LeaveCriticalSection(&pool->lock); return ctx ? ctx : malloc(sizeof(PER_IO_CONTEXT)); } |
- 缓冲区生命周期陷阱
- 这是
IOCP编程最容易踩坑的地方 - 从投递
WSARecv/WSASend到收到对应的完成通知之间,这段时间的buffer内存绝对不能动(不能释放、不能复用给另一个操作) - 因为内核(甚至网卡
DMA)可能正在往这块内存写数据,你如果提前释放或复用,会导致:
内存破坏(另一个操作的数据被写乱)
Use-after-free崩溃 - 这也是为什么每个"未完成的操作"都要有独立的
context+buffer,不能共享
- 这是
- 连接关闭与优雅退出
IOCP服务器关闭连接时有一个常见陷阱:closesocket()调用后,之前投递的未完成的overlapped操作不会立即消失,它们仍然会在稍后产生完成通知(通常带错误码),必须在收到这些"迟到的"完成包后才能真正释放对应的context内存,否则会内存泄漏或崩溃- 生产代码通常给每个连接维护一个"未完成操作计数器",归零后才真正释放该连接的所有资源
服务端code
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 |
// iocp_server.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。 // #include <WS2tcpip.h> #include <WinSock2.h> #include <mswsock.h> #include <windows.h> #include <cstring> #include <iostream> #include <thread> #include <vector> #pragma comment(lib, "Ws2_32.lib") #pragma comment(lib, "Mswsock.lib") constexpr int PORT = 9000; constexpr int BUFFER_SIZE = 4096; constexpr int MAX_ACCEPT_POST = 10; // 预投递的AcceptEx数量 enum class IoType { ACCEPT, RECV, SEND }; struct IoContext { OVERLAPPED overlapped; WSABUF wsaBuf; char buffer[BUFFER_SIZE]; IoType ioType; SOCKET socket; // AcceptEx用到的新连接socket IoContext(IoType type) : ioType(type), socket(INVALID_SOCKET) { ZeroMemory(&overlapped, sizeof(overlapped)); ZeroMemory(buffer, BUFFER_SIZE); wsaBuf.buf = buffer; wsaBuf.len = BUFFER_SIZE; } }; // ------------------------------------------------------------ // 每个连接的上下文(Per-Handle Data) // 一个连接可能同时有一个收操作和一个发操作在进行 // ------------------------------------------------------------ struct ConnectionContext { SOCKET clientSocket; explicit ConnectionContext(SOCKET s) : clientSocket(s) {} ~ConnectionContext() { if (clientSocket != INVALID_SOCKET) { closesocket(clientSocket); } } }; // 全局变量 HANDLE g_hIOCP = nullptr; SOCKET g_listenSocket = INVALID_SOCKET; LPFN_ACCEPTEX g_lpfnAcceptEx = nullptr; LPFN_GETACCEPTEXSOCKADDRS g_lpfnGetAcceptExSockAddrs = nullptr; // 地址长度需要预留16字节余量(AcceptEx要求) constexpr int ADDR_LEN = sizeof(SOCKADDR_IN) + 16; bool PostAccept() { SOCKET acceptSock = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, nullptr, 0, WSA_FLAG_OVERLAPPED); if (acceptSock == INVALID_SOCKET) { std::cout << "create accept sock failed, err is : " << WSAGetLastError() << std::endl; return false; } IoContext* ctx = new IoContext(IoType::ACCEPT); ctx->socket = acceptSock; DWORD bytesReceived = 0; // AcceptEx会同时完成"接受连接"和"接收第一批数据"两件事 // 这里只用来接受连接,所以数据缓冲区大小设为0起始位置之外的部分 BOOL result = g_lpfnAcceptEx(g_listenSocket, acceptSock, ctx->buffer, 0, // 不在accept阶段接收数据 ADDR_LEN, ADDR_LEN, &bytesReceived, &ctx->overlapped); if (!result && WSAGetLastError() != ERROR_IO_PENDING) { std::cout << "accept failed, err is :" << WSAGetLastError() << std::endl; closesocket(acceptSock); delete ctx; return false; } return true; } // 投递一次异步接收 bool PostRecv(ConnectionContext* connCtx) { IoContext* ctx = new IoContext(IoType::RECV); DWORD flags = 0; DWORD bytesRecv = 0; int result = WSARecv(connCtx->clientSocket, &ctx->wsaBuf, 1, &bytesRecv, &flags, &ctx->overlapped, nullptr); if (result == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING) { std::cout << "wsarecv failed, err is : " << WSAGetLastError() << std::endl; delete ctx; return false; } return true; } // 投递一次异步发送(echo回显收到的数据) bool PostSend(ConnectionContext* connCtx, const char* data, DWORD len) { IoContext* ctx = new IoContext(IoType::SEND); memcpy(ctx->buffer, data, len); ctx->wsaBuf.len = len; DWORD bytesSent = 0; int result = WSASend(connCtx->clientSocket, &ctx->wsaBuf, 1, &bytesSent, 0, &ctx->overlapped, nullptr); if (result == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING) { std::cout << "wsasend failed, err is : " << WSAGetLastError() << std::endl; delete ctx; return false; } return true; } void WorkerThread() { while (1) { DWORD bytesTransferred = 0; ULONG_PTR completionKey = 0; LPOVERLAPPED pOverlapped = nullptr; BOOL success = GetQueuedCompletionStatus( g_hIOCP, &bytesTransferred, &completionKey, &pOverlapped, INFINITE); if (pOverlapped == nullptr) { // IOCP本身出错或收到退出信号 break; } IoContext* ioCtx = reinterpret_cast<IoContext*>(pOverlapped); // 客户端断开连接 或 操作失败 if (!success || (bytesTransferred == 0 && ioCtx->ioType != IoType::ACCEPT)) { if (completionKey != 0) { delete reinterpret_cast<ConnectionContext*>(completionKey); } delete ioCtx; continue; } switch (ioCtx->ioType) { case IoType::ACCEPT: { // 新连接建立,创建连接上下文并关联到IOCP SOCKET clientSockt = ioCtx->socket; setsockopt(clientSockt, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT, (char*)&g_listenSocket, sizeof(g_listenSocket)); ConnectionContext* connCtx = new ConnectionContext(clientSockt); CreateIoCompletionPort((HANDLE)clientSockt, g_hIOCP, (ULONG_PTR)connCtx, 0); std::cout << "new conn created, socket is : " << clientSockt << std::endl; // 立刻为新连接投递第一次recv PostRecv(connCtx); delete ioCtx; PostAccept(); break; } case IoType::RECV: { ConnectionContext* connCtx = reinterpret_cast<ConnectionContext*>(completionKey); // echo:把收到的数据原样发回去 PostSend(connCtx, ioCtx->buffer, bytesTransferred); PostRecv(connCtx); break; } case IoType::SEND: { // 发送完成,这里不需要额外动作 delete ioCtx; break; } default: break; } } } int main() { // 1. 初始化Winsock WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { std::cout << "wsa startup err" << std::endl; return 1; } // 2. 创建完成端口 g_hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0); if (g_hIOCP == nullptr) { std::cout << "create iocp failed" << std::endl; return 1; } // 3. 创建监听socket g_listenSocket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, nullptr, 0, WSA_FLAG_OVERLAPPED); if (g_listenSocket == INVALID_SOCKET) { std::cout << "create listen sock failed" << std::endl; return 1; } sockaddr_in serverAddr{}; serverAddr.sin_family = AF_INET; serverAddr.sin_addr.s_addr = INADDR_ANY; serverAddr.sin_port = htons(PORT); if (bind(g_listenSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { std::cout << "bind socket failed, err is : " << WSAGetLastError() << std::endl; return 1; } if (listen(g_listenSocket, SOMAXCONN) == SOCKET_ERROR) { std::cout << "listen failed, err is : " << WSAGetLastError() << std::endl; return 1; } // 4. 把监听socket关联到IOCP CreateIoCompletionPort((HANDLE)g_listenSocket, g_hIOCP, 0, 0); // 5. 加载AcceptEx扩展函数指针(Windows下需要通过WSAIoctl动态获取) GUID guidAcceptEx = WSAID_ACCEPTEX; DWORD bytes = 0; WSAIoctl(g_listenSocket, SIO_GET_EXTENSION_FUNCTION_POINTER, &guidAcceptEx, sizeof(guidAcceptEx), &g_lpfnAcceptEx, sizeof(g_lpfnAcceptEx), &bytes, nullptr, nullptr); // 6. 创建工作线程池,数量建议为CPU核数 x 2 SYSTEM_INFO sysInfo; GetSystemInfo(&sysInfo); int threadCount = sysInfo.dwNumberOfProcessors * 2; std::vector<std::thread> workers; for (int i = 0; i < threadCount; i++) { workers.emplace_back(WorkerThread); } std::cout << "IOCP echo server启动,监听端口 " << PORT << ",工作线程数 " << threadCount << std::endl; // 7. 预先投递若干个AcceptEx,保证随时能接受新连接 for (int i = 0; i < MAX_ACCEPT_POST; i++) { PostAccept(); } // 主线程等待(实际项目中这里可以做信号处理、优雅退出等) for (auto& th : workers) { th.join(); } closesocket(g_listenSocket); WSACleanup(); return 0; } |
客户端code
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
// client.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。 // #include <WinSock2.h> #include <WS2tcpip.h> #include <iostream> #include <string> #pragma comment(lib, "Ws2_32.lib") constexpr int SERVER_PORT = 9000; constexpr const char* SERVER_IP = "192.168.1.7"; constexpr int BUFFER_SIZE = 4096; int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { std::cout << "wsastartup err" << std::endl; return 1; } SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock == INVALID_SOCKET) { int wsa_err = WSAGetLastError(); std::cout << "create sock failed, err is "<< wsa_err << std::endl; WSACleanup(); return 1; } sockaddr_in serverAddr{}; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(SERVER_PORT); inet_pton(AF_INET, SERVER_IP, &serverAddr.sin_addr); if (connect(sock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { std::cout << "connect sock failed, err is " << WSAGetLastError() << std::endl; closesocket(sock); WSACleanup(); return 1; } std::cout << "connect sock succ, ip is " << SERVER_IP << std::endl; std::cout << "input: " << std::endl; std::string line; char recvBuf[BUFFER_SIZE]; while (1) { std::getline(std::cin, line); if (line == "exit") { break; } if (line.empty()) { continue; } int send_result = send(sock, line.c_str(), (int)line.size(), 0); if (send_result == SOCKET_ERROR) { std::cout << "send failed, err is " << WSAGetLastError() << std::endl; break; } int bytesReceived = recv(sock, recvBuf, BUFFER_SIZE - 1, 0); if (bytesReceived > 0) { recvBuf[bytesReceived] = '\0'; std::cout << "recv res is : " << recvBuf << std::endl; } else if (bytesReceived == 0) { std::cout << "server closed connect.." << std::endl; } else { std::cout << "recev failed, exit" << std::endl; break; } } closesocket(sock); WSACleanup(); return 0; } |
AcceptEx相比accept做了什么不同的事
- 普通
accept()的顺序是- "先有一个握手完成的连接在队列里 → 调用
accept→ 内核帮你创建新socket并返回",也就是socket的创建是在accept这一刻由内核完成的
- "先有一个握手完成的连接在队列里 → 调用
AcceptEx的顺序反过来了- 你需要提前用
WSASocket()创建好一个空的socket,把这个空socket的句柄传给AcceptEx,让内核在真正有新连接握手完成时,把这个连接"绑定"到你预先准备好的这个socket上
- 你需要提前用
- 这么设计的好处是:
- 可以提前把
socket创建的开销和"等待新连接"这个动作解耦,配合IOCP的异步通知机制,能做到"连接一到,几乎立刻可用",减少了同步accept()那种"来一个连接现造一个socket"的实时开销,这也是AcceptEx比普通accept在高并发场景下性能更好的原因之一
- 可以提前把
- 另,
AcceptEx完成后,必须调用- 因为
AcceptEx创建连接的方式比较特殊,这个新socket需要显式"继承"监听socket的一些属性(比如SO_REUSEADDR这类选项),不然一些依赖监听socket属性的功能可能不会正常生效
- 因为
|
1 |
setsockopt(clientSocket, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT, (char*)&listenSocket, sizeof(listenSocket)); |
为什么iocp性能强
-
线程池模型
- 少量线程(通常等于
CPU核心数)就能服务海量并发连接,避免"一连接一线程"的开销
- 少量线程(通常等于
-
内核调度
GetQueuedCompletionStatus会尽量让"最近运行过的线程"优先取到任务(利用CPU缓存局部性),并且会限制"同时活跃"的线程数不超过CPU核心数(可通过CreateIoCompletionPort第四个参数配置并发值),避免过度调度
-
无锁队列
- 完成包在内核里排队,多线程竞争走内核同步机制,比用户态自己维护队列+锁更高效
区别于epoll
epoll是"就绪通知"(告诉你fd可读/可写了,你自己再去读写,可能读不满预期字节数)IOCP是"完成通知"(告诉你I/O已经做完了,数据已经在缓冲区里,字节数是确定的)
Overlapped I/O 使用中的注意事项
- 缓冲区生命周期
- 异步操作发起后,在完成通知到达之前,你传给
WSARecv/WSASend的buffer绝对不能释放或复用 - 否则内核可能正在往一块已经被回收的内存写数据,导致内存破坏
- 这也是为什么通常要为每个未完成的操作单独
malloc一块context+buffer
- 异步操作发起后,在完成通知到达之前,你传给
- 一个
socket可以有多个未完成的overlapped操作- 比如同时发起多个
WSARecv(预投递多个接收缓冲区)来提升吞吐量,这是常见的性能优化手段 - 预先
post多个recv请求,减少"处理完一个再投递下一个"之间的空档期
- 比如同时发起多个
- 取消操作
CancelIoEx可以取消尚未完成的overlapped操作(比如连接关闭时清理未完成的I/O)- 但取消是异步的,取消请求本身也会通过完成端口通知你"这个操作被取消了"(错误码
ERROR_OPERATION_ABORTED),而不是立即生效
AcceptEx / ConnectEx的预投递Windows提供了AcceptEx(相比标准accept效率更高,支持提前投递多个"待接受连接"的overlapped请求)- 也支持在
accept的同时一并接收第一批数据,减少一次系统调用往返
难点
- 需要自己管理每个连接的上下文(
Per-IO Data / Per-Handle Data)
|
1 2 3 4 |
PER_HANDLE_DATA (每个连接一份,生命周期=连接生命周期) │ ├── PER_IO_DATA (recv操作用,每次投递new一份) └── PER_IO_DATA (send操作用,每次投递new一份) |
- 处理"粘包/半包"问题(
TCP流式协议的通病)- 粘包/半包是
TCP流式协议本身的特性,跟用不用IOCP没有直接关系 - 但放在
IOCP语境下确实有个特有的坑: - 因为完成通知里给你的是"这次
WSARecv完成收到了N字节",你必须在应用层自己维护一个按连接的累积缓冲区,把这次收到的数据append进去,再尝试从头解析出完整消息,剩余的不完整数据留到下次完成通知再拼接 - 而且因为
IOCP是多线程并发处理完成包的,同一个连接的多次recv完成通知,理论上可能被不同的工作线程"乱序"处理
这会让粘包处理的复杂度进一步增加
通常需要给每个连接的数据处理加锁或者用序号机制保证处理顺序
- 粘包/半包是
|
1 2 3 4 5 |
// 每个连接的上下文里需要维护一个累积缓冲区,而不是每次recv完就直接当成完整消息处理 typedef struct { char accumulate_buf[MAX_MSG_SIZE * 4]; int accumulate_len; } PER_HANDLE_DATA; |
-
连接的生命周期管理容易出现野指针/内存问题(比如
AcceptEx时的引用计数)- 一个连接可能同时有多个未完成的操作(比如同时挂着一个
recv和一个send),如果客户端在这时候断线,你不能立即释放这个连接的context,必须等所有挂起的操作都返回完成通知(哪怕是带错误码的)之后才能真正释放 - 常见做法是维护一个"未决操作计数器",每次投递
+1,每次完成通知(无论成功失败)-1,归零后才free掉Per-Handle Data,通常还需要原子操作 AcceptEx还有个特有的坑:预先创建的socket如果AcceptEx迟迟没有新连接到达,而你的服务器要关闭了,这个挂起的AcceptEx需要用CancelIoEx显式取消,否则会一直挂着阻碍资源回收
- 一个连接可能同时有多个未完成的操作(比如同时挂着一个
-
Overlapped结构的内存管理要小心,容易内存泄漏- 从投递到完成通知之间
buffer不能动 - 容易内存泄漏的具体场景包括:
- 忘记处理
GetQueuedCompletionStatus返回FALSE但lpOverlapped非NULL的情况
表示这次具体的I/O操作失败了,但完成包本身是有效的,仍然需要释放对应context - 服务器关闭时,还有大量"已投递但未完成"的操作,如果不逐一
CancelIoEx并等待其完成通知,直接强行退出进程虽然OS会兜底回收,但如果是长时间运行、需要优雅重启的服务,这就是实打实的资源泄漏和潜在崩溃点
- 从投递到完成通知之间
-
错误码处理的复杂性
WSARecv/WSASend返回SOCKET_ERROR时,必须准确区分WSA_IO_PENDING(正常,异步处理中)和真正的错误(如WSAECONNRESET),处理不当会把正常的异步流程误判为错误断开连接
-
NumberOfConcurrentThreads和实际线程池大小的调优- 这个参数设置不合理(比如设置过大或过小)会导致
CPU利用率不理想,需要结合压测调参,没有放之四海而皆准的固定值
- 这个参数设置不合理(比如设置过大或过小)会导致
-
优雅关闭(
Graceful Shutdown)的时序问题- 服务器要退出时,"停止接受新连接 -> 等待现有连接处理完 -> 取消所有挂起
IO-> 关闭IOCP句柄"这个顺序如果搞错,容易出现工作线程卡死在GetQueuedCompletionStatus上退不出去,或者提前释放了还在使用的资源
- 服务器要退出时,"停止接受新连接 -> 等待现有连接处理完 -> 取消所有挂起
Reactor 模式
概述
- 告诉你 "
fd已就绪,你可以读/写了",实际的I/O操作(数据从内核缓冲区拷到用户缓冲区)由应用程序自己发起 - 是"就绪通知"
工作流程
|
1 2 3 4 5 6 |
1. 应用程序注册感兴趣的事件(可读/可写)到多路复用器(epoll) 2. 事件循环(Event Loop)阻塞在 epoll_wait 上等待事件发生 3. 某个fd就绪 -> epoll_wait 返回该fd 4. 应用程序调用对应的Handler处理 5. Handler内部才真正调用 read()/write() 执行实际I/O 6. 处理完数据后,回到步骤2继续等待 |
code
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// Reactor 模式的经典实现骨架 while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { if (events[i].events & EPOLLIN) { // fd可读了,但数据还在内核缓冲区里,需要自己去读 int fd = events[i].data.fd; char buf[4096]; int n = read(fd, buf, sizeof(buf)); // 真正的I/O操作在这里发生 handle_data(buf, n); } if (events[i].events & EPOLLOUT) { // fd可写了,自己去写 write(fd, send_buf, send_len); } } } |
关键特征
- 应用程序主导
I/O时机:- 内核只负责"通知你能干活了",真正搬运数据的
syscall(read/write)由应用程序自己调用 - 且这次调用是同步阻塞在用户态视角下很快返回的
- 内核只负责"通知你能干活了",真正搬运数据的
- 本质上是同步非阻塞
I/O + I/O多路复用的组合 Linux的epoll、select、poll,以及macOS/BSD的kqueue都是Reactor模式的具体实现
单 Reactor 单线程
- 所有
accept、读、写、业务处理都在一个线程里,简单但无法利用多核Redis早期就是这个模型,用单线程规避锁竞争换取简单性
单 Reactor 多线程
I/O事件的监听和分发仍在主线程,但实际业务处理丢给线程池处理,避免慢业务逻辑阻塞事件循环
主从 Reactor 多线程
Nginx、Netty的典型模型- 这样
accept和I/O处理都能利用多核,是目前高性能服务器最常见的架构
|
1 2 3 4 5 6 |
主 Reactor(1个): 只负责 accept 新连接 │ 分发给从 Reactor │ 从 Reactor(多个,通常=CPU核数): 各自维护一个epoll, 各自处理一部分连接的读写事件+业务逻辑 |
Proactor 模式
概述
- 告诉你 "
I/O操作已经完成了",数据已经在你的缓冲区里,实际的I/O操作由操作系统内核完成 - 是"完成通知"
工作流程
|
1 2 3 4 5 |
1. 应用程序发起一个异步I/O操作请求(如WSARecv),并提供缓冲区 2. 这次调用立即返回(不阻塞),操作系统接管,开始在后台执行实际I/O 3. 操作系统把数据从网卡拷贝、组装,直接写入你提供的缓冲区里 4. I/O完全做完后,操作系统通过完成端口(IOCP)通知应用程序 5. 应用程序从完成队列里取出结果,这时候数据已经现成的在buffer里了,直接处理 |
code
|
1 2 3 4 5 6 7 8 9 10 11 |
// Proactor 模式(IOCP)的经典实现骨架 // 提前投递读操作,缓冲区已经准备好 WSARecv(sock, &wsabuf, 1, NULL, &flags, &ov, NULL); // 立即返回,不阻塞 // 工作线程池循环等待"已完成"的I/O while (1) { GetQueuedCompletionStatus(iocp, &bytes_transferred, &key, &pov, INFINITE); // 走到这里,说明数据已经在 wsabuf.buf 里了,可以直接用 PER_IO_CONTEXT* ctx = (PER_IO_CONTEXT*)pov; process_data(ctx->buffer, bytes_transferred); // 无需再调用recv } |
关键特征
- 内核主导实际的数据搬运
- 应用程序只负责"发起请求+提供缓冲区"和"处理已完成的结果",中间的
I/O执行过程完全交给内核
- 应用程序只负责"发起请求+提供缓冲区"和"处理已完成的结果",中间的
- 是真正意义上的异步
I/O(AIO)- 系统调用发起后立即返回,不需要等到数据就绪再手动去读
Windows IOCP是最经典的Proactor实现Linux的io_uring和早期的POSIX AIO也属于Proactor范畴
为什么 Linux 长期没有真正的 Proactor 模型
Linux的POSIX AIO(aio_read/aio_write)名义上是异步I/O,但实现上有很多限制:- 早期
POSIX AIO只能配合O_DIRECT(绕过page cache)才能做到真正意义的异步,否则很多实现内部其实是用线程池模拟的(内核偷偷起个线程帮你同步执行,再回调通知你,本质是"伪异步")
- 早期
- 网络
socket长期不支持POSIX AIO的完整异步语义
io_uring 改变了这个局面
Linux 5.1(2019年)引入的io_uring才算真正意义上补齐了这块短板,其设计明显借鉴了IOCP的思路:
|
1 2 3 4 5 |
用户态 内核态 │ │ ├── 提交队列 SQ (Submission Queue) ──> 内核处理 │ │ └── 完成队列 CQ (Completion Queue) <──── 完成后写入结果 |
io_uring用两个环形缓冲区(SQ/CQ)实现用户态和内核态之间无需系统调用的双向通信- 借助
io_uring_enter批量提交/收割,甚至配合SQPOLL模式可以做到完全无系统调用开销
- 借助
- 这比
IOCP更进一步IOCP每次GetQueuedCompletionStatus仍然是一次系统调用,而io_uring可以做到用户态直接从共享内存里读完成结果- 可以说:
epoll代表了Linux过去二十年Reactor模式的巅峰,io_uring则是Linux补上Proactor模式这一课,而且反超了Windows IOCP的效率
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Chromium:鼠标事件的生成与处理07/19
- ♥ Linux 高性能服务器编程:网络基础编程一11/27
- ♥ Windows线程同步相关03/10
- ♥ Linux 高性能服务器编程:I/O复用一12/11
- ♥ Windows网络编程四07/18
- ♥ Windows网络编程一07/13
