Windows异步IO之IOCP
Windows异步IO之IOCP
简洁:因为epoll的出现Linux操作系统占据了服务器的大量份额,Linux有着完备的高并发技术框架。而在windows场景下的高并发需求常常使用IOCP。
引言
网络作为所有程序通信的基础,其处理数据的效率决定了这款产品的性能,不同的性能带来不同的用户体验。无论是在哪个平台,网络通信的起点都从socket套接字开始,初始化socket之后服务器进入监听状态随时等待并处理客户端的请求。在这个过程中,数据的读写即send、recv等IO操作不可避免地会导致线程阻塞(例如服务器阻塞当前线程等待客户端连接请求、服务器与客户端建立连接等待客户端发送数据)。为了提高性能,从最早的一线程一请求方式,迭代至今的IO多路复用、异步IO阶段,作为服务器的网路编程框架已经大致固定。现目前在高并发的业务场景下有两类模式:Reactor基于事件的非阻塞多路复用IO、Proactor异步IO请求。关于reactor在select、poll、epoll的IO多路复用与reactor事件驱动方案 - +_+0526 - 博客园中做了详细的介绍,本文介绍在Windows平台基于Proactor模式的异步IO框架IOCP。简单介绍IOCP的原理及常用接口,最后通过一个简单的范例展示IOCP操作流程。
原理及架构
- reactor与proactor
reactor模式仅接管了事件描述符,只在在事件就绪(可读、可写)时通知回调函数执行对应的操作。
而proactor模式不仅将事件进行统一管理,并且将IO操作交给内核处理,只返回给用户数据。
可以看到,用户通过iocp管理io时,不需要将io检测与操作分离开来。换句话说,iocp将io操作一同接管,它通过与内核之间的交互得到得到数据后通知用户程序。
- iocp原理
既然iocp这么简便那么它的原理是什么呢?要理解iocp的原理我们要对它的核心接口有明确的认识:
CreateIoCompletionPort():在整个iocp的工作流程中,这个函数会被调用两次。第一次调用创建一个IO完成端口对象:
typedef struct _IO_COMPLETION_PORT {
HANDLE Handle; // 完成端口句柄
LIST_ENTRY CompletionQueue; // 完成队列(链表结构)
KQUEUE PendingIoQueue; // 待处理 I/O 请求队列(设备驱动关联)
DWORD MaxConcurrentThreads; // 最大并发线程数
} IO_COMPLETION_PORT;
HANDLE CreateIoCompletionPort(
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
ULONG_PTR CompletionKey,
DWORD NumberOfConcurrentThreads
);
完成端口关联请求队列以及完成队列。请求队列用于存储用户提交的IO请求,根据用户传入的异步IO函数并传入及其对应的OVERLAPPED结构体组织优先级队列;完成队列存储已经完成的IO操作的结果,等待用户主动获取,一般情况下是一个全局队列。
第二次调用CreateIoCompletionPort会将套接字文件等句柄关联到已初始化完成端口内部的句柄映射表,同时记录其唯一的Completionkey。
Completionkey其本质是一个指针大小的无符号整数(ULONG_PTR),在iocp中作为上下文传递枢纽而存在。其最重要的功能就是唯一标识不同文件描述符。第二次调用CreateIoCompletionPort,内核会组织一个(FileHandle, CompletionKey)二元组插入句柄映射表。
异步IO投递:这一步包含有AcceptEx、WASRecv、WSASend、ConnectEx等接口,每当用户调用异步请求接口,都会入队到完成端口对象中的请求队列。
值得注意的是,这一步操作可以同时向iocp投递大量IO,例如启动服务器时往往一次性投递多个AcceptEx实现并发接收多个连接。
那么问题来了,我们一次性投递了这么多IO,当系统完成IO事件后如何按照投递时的事件一一对应返回给用户结果呢?
我们先来看看其他方案是如何解决这个问题的:
- epoll
epoll是我们的老熟人了(select、poll、epoll的IO多路复用与reactor事件驱动方案 - +_+0526 - 博客园),在epoll_event实例中有data字段,在使用时将该字段设置为需要接管的fd。当事件就绪时,依次对比就绪事件的data字段数据得到注册时的fd。也就是说epoll使用epoll_event中的data字段标识用户投递事件。
struct epoll_event {
uint32_t events; // EPOLLIN/EPPOLLOUT 等事件类型
union {
int fd; // 文件描述符
void* ptr;
} data;
};
- io_uring
io_uring通过SQ与CQ实现事务管理(异步IO之IO_Uring - +_+0526 - 博客园),在提交队列项(SQE)和完成队列项(CQE)中都有用于关联的user_data字段:
struct io_uring_sqe {
__u8 opcode; // 操作码 (READ, WRITE, ACCEPT, etc.)
__u64 addr; // 数据地址 (缓冲区指针)
__u32 len; // 数据长度
__u64 user_data; // 用户标识符 (用于匹配请求)
// ... flags, fd, 其他参数
};
struct io_uring_cqe {
__u64 user_data; // 对应 SQE 的 user_data
__s32 res; // 操作结果 (类似系统调用返回值)
__u32 flags;
};
在提交SQE时设置该字段的值,当完成事件就绪同样通过该字段找回上下文。
那么我们也能推导出iocp也大概率采用了相同的架构来实现事件关联。相较于前两者iocp的关联机制稍复杂。通过(FileHandle, CompletionKey)二元组显示绑定,同时通过OVERLAPPED结构对单次IO操作进行绑定:
typedef struct _OVERLAPPED {
ULONG_PTR Internal; // 内核状态码(如STATUS_PENDING)
ULONG_PTR InternalHigh; // 实际传输的字节数
union {
struct {
DWORD Offset; // 文件操作偏移量(低32位)
DWORD OffsetHigh; // 文件操作偏移量(高32位)
};
PVOID Pointer; // 保留字段(某些驱动使用)
};
HANDLE hEvent; // 事件对象句柄(IOCP模式下应为NULL)
} OVERLAPPED, *LPOVERLAPPED;
在事件投递时,传入该结构体就能做到IO关联。
- GetQueueCompletionStatus():用于获取已完成的IO操作。
BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort,
LPDWORD lpNumberOfBytesTransferred,
PULONG_PTR lpCompletionKey,
LPOVERLAPPED *lpOverlapped,
DWORD dwMilliseconds
);
当有事件完成,iocp会将事件通知放在完成端口队列中,调用GetQueueCompletionStatus则会从完成端口队列中取出通知。
至此iocp的编程关键接口就介绍完成了,proactore与reactor架构虽然在机制上有着本质上的不同,但在代码框架上没有大的区别。需要显示地实例化管理对象,通过固定接口向管理对象提交事件,最后后续事件通知进行下一步操作。
服务器echo程序
和epoll操作方法类似,所有的方法都围绕着一个main loop执行。在main loop中程序不断地获取完成端口信息,如果有IO就绪则立马进行对应的操作;同时main loop中还扮演着管理事件生命周期的功能。
- 关键结构体与类设计
struct IOContext {
WSAOVERLAPPED overlapped;
SOCKET socket;
WSABUF wsaBuf;
IO_OP_TYPE type;
char buffer[IO_BUFFER_SIZE];
IOContext () {
Reset();
socket = INVALID_SOCKET;
}
~IOContext () {
}
void Reset() {
ZeroMemory(&overlapped, sizeof(overlapped));
memset(buffer, 0, IO_BUFFER_SIZE);
wsaBuf.buf = buffer;
wsaBuf.len = IO_BUFFER_SIZE;
type = IO_OP_TYPE::IO_NULL;
}
};
封装OVERLAPPED,同时将套接字、数据缓冲区、事件操作类型等信息打包,形成了一个完整的IO操作上下文。需要注意的是,调用GetQueuedCompletionStatus接口获取完成的IO事件时,只能拿到OVERLAPPED指针,如果需要通过指针获取到所在结构体的起始地址使用宏CONTAINING_RECORD,反向解析出IOContexti地址(关于CONTAINING_RECORD可以查阅C语言基于Linux平台实现通讯录 - +_+0526 - 博客园中问题一的container_of,其原理完全相同)。
class SocketMgr {
public:
SocketMgr();
~SocketMgr();
...
//Windows相关句柄
protected:
HANDLE completionPort;//IOCP完成端口
HANDLE *workerThreads;//工作线程
Connection *acceptor;//监听连接对象
...
private:
static DWORD WINAPI WorkerThreadProc(LPVOID lpParam);// 工作线程入口函数 main loop 入口
bool init();
bool initServer();
...
bool associateWithIOCP(Connection *conn);//将socket关联到完成端口
bool postAccept(Connection *conn, IOContext *ioContext);
bool postRecv(Connection *conn, IOContext *ioContext);
bool postSend(Connection *conn, IOContext *ioContext);
// IO处理函数
bool doAccpet(Connection *conn, IOContext *ioContext);
bool doRecv(Connection *conn, IOContext *ioContext);
bool doSend(Connection *conn, IOContext *ioContext);
bool doClose(Connection *conn);
};
设计网络通信管理类,用于管理iocp异步IO模型。使用protected权限管理iocp相关参数,将关联操作设置为私有权限。
- SocketMgr类主要接口
分别在init()和initServer()中初始化iocp和服务器套接字。
bool SocketMgr::init()
{
//创建完成端口
completionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
if (NULL == completionPort)
{
return false;
}
//创建工作线程 通常为cpu核心的2倍
...
return true;
}
init()首次调用CreateIoCompletionPort获得一个完成端口对象completionPort,此时的completionPort仅进行了相关参数的初始化,还未接管任何设备句柄。
bool SocketMgr::initServer()
{
// 生成用于监听的socket的Context
acceptor = new Connection;
acceptor->socket = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
if (INVALID_SOCKET == acceptor->socket)
return false;
// 将 socket 绑定到完成端口中
if (NULL == CreateIoCompletionPort((HANDLE)acceptor->socket, completionPort, (ULONG_PTR)acceptor, 0))
{
RELEASE(acceptor);
return false;
}
// 服务器地址信息,用于绑定socket 绑定serverAddr 开始监听
sockaddr_in serverAddr;
...
//特殊函数指针
/*
AcceptEx:高性能接受新连接的函数(比传统accept()更高效)
GetAcceptExSockaddrs:解析AcceptEx返回的地址信息的辅助函数
*/
GUID guidAcceptEx = WSAID_ACCEPTEX;
GUID guidGetAcceptSockAddrs = WSAID_GETACCEPTEXSOCKADDRS;
// 提取扩展函数指针
DWORD dwBytes = 0;
//通过 WSAIoctl 函数获取 AcceptEx 的函数指针,将其存储在 fnAcceptEx 中,以便后续使用。
...
//通过 WSAIoctl 获取 GetAcceptExSockaddrs 的函数指针,
...
//根据端口数量预投递异步 AcceptEx 请求
for (size_t i = 0; i < MAX_POST_ACCEPT; i++)
{
IOContext *ioContext = new IOContext;
if (false == postAccept(acceptor, ioContext))
{
RELEASE(ioContext);
return false;
}
}
return true;
}
initServer第二次调用CreateIoCompletionPort,将新创建的用于监听的套接字句柄交由完成端口管理,使得accept操作能通过完成端口实现异步通知。根据端口数量投递异步请求。
iocp同时管理请求队列和完成队列,所以在使用时要投递相关IO操作,当事件就绪时根据IOContext进行后续操作。
bool SocketMgr::postAccept(Connection * conn, IOContext * ioContext)
{
DWORD dwBytes = 0;
ioContext->type = IO_OP_TYPE::IO_ACCEPT;//置为accept状态
//WSA_FLAG_OVERLAPPED 标识为重叠io
ioContext->socket = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
//投递AcceptEx请求
if (false == fnAcceptEx(acceptor->socket, ioContext->socket, ioContext->wsaBuf.buf, 0, sizeof(sockaddr_in) + 16, sizeof(sockaddr_in) + 16, &dwBytes, &ioContext->overlapped))
{
if (WSA_IO_PENDING != WSAGetLastError())
{
return false;
}
}
return true;
}
postAccept函数最核心操作,将ioContext的type字段状态值设为IO_ACCEPT,初始化相关参数投递AcceptEx请求。其余postSend、postRecv函数操作类型。
当IO执行完毕,处理数据调用doAccept、doRecv、doSend函数。
bool SocketMgr::doAccpet(Connection * conn, IOContext * ioContext)
{
SOCKADDR_IN *clientAddr = NULL;
SOCKADDR_IN *localAddr = NULL;
int clientAddrLen, localAddrLen;
clientAddrLen = localAddrLen = sizeof(SOCKADDR_IN);
// 1. 获取地址信息 ( GetAcceptExSockAddrs 函数不仅可以获取地址信息,还可以顺便取出第一组数据)
fnGetAcceptExSockAddrs(ioContext->wsaBuf.buf, 0, localAddrLen, clientAddrLen, (LPSOCKADDR *)&localAddr, &localAddrLen, (LPSOCKADDR *)&clientAddr, &clientAddrLen);
// 2. 为新连接建立一个 Connection
Connection *newconn = new Connection;
newconn->socket = ioContext->socket;
memcpy_s(&(newconn->clientAddr), sizeof(SOCKADDR_IN), clientAddr, sizeof(SOCKADDR_IN));
// 3. 将 acceptor 的 IOContext 重置后继续投递 AcceptEx
ioContext->Reset();//重置
if (false == postAccept(acceptor, ioContext))
{
RELEASE(ioContext);
}
// 4. 将新socket和完成端口绑定
if (NULL == CreateIoCompletionPort((HANDLE)newconn->socket, completionPort, (ULONG_PTR)newconn, 0))
{
DWORD dwErr = WSAGetLastError();
if (dwErr != ERROR_INVALID_PARAMETER)
{
doClose(newconn);
return false;
}
}
IOContext *newIoContext = new IOContext;
newIoContext->type = IO_OP_TYPE::IO_RECV;//客户端设为IO_RECV状态
newIoContext->socket = newconn->socket;
// 投递 recv 请求
if (false == postRecv(newconn, newIoContext))
{
doClose(conn);
return false;
}
return true;
}
当postAccept投递的accept操作完成时,也就是接收到用户请求,工作线程会调用doAccept函数。相应的doAccept只需要完成三个功能:获取客户端请求地址信息、为请求建立客户端连接、将客户端连接置为IO_RECV状态并投递postRecv。此时来自客户端的请求被以IO_RECV状态投递,等待接收数据。
bool SocketMgr::doRecv(Connection * conn, IOContext * ioContext)
{
// 创建新的IOContext用于发送数据(回声)
IOContext *sendCtx = new IOContext();
// 复制接收的数据到发送缓冲区
strncpy_s(sendCtx->buffer, IO_BUFFER_SIZE, ioContext->buffer, IO_BUFFER_SIZE - 1);
// 设置发送缓冲区长度为实际数据长度(避免发送空字符)
sendCtx->wsaBuf.len = strlen(ioContext->buffer);
// 绑定到当前连接的socket
sendCtx->socket = conn->socket;
// 投递发送请求,将数据回送客户端
if (!postSend(conn, sendCtx))
{
// 发送失败时释放资源
RELEASE(sendCtx);
}
// 重置接收上下文,继续等待新数据
...
return true;
}
当事件处于IO_RECV状态,工作线程调用doRecv函数。doRecv函数负责从传入的ioContext的buffer缓冲区中读取到客户端发送来的数据,并创建新的发送IOContext完成端口上下文继续进行投递。
DWORD SocketMgr::WorkerThreadProc(LPVOID lpParam)
{
SocketMgr *iocp = (SocketMgr *)lpParam;
OVERLAPPED *ol = NULL;
Connection *conn;
DWORD dwBytes = 0;
IOContext *ioContext = NULL;
while (WAIT_OBJECT_0 != WaitForSingleObject(iocp->stopEvent, 0)){
bool bRet = GetQueuedCompletionStatus(iocp->completionPort, &dwBytes, (PULONG_PTR)&conn, &ol, INFINITE);
// 读取传入的参数
ioContext = CONTAINING_RECORD(ol, IOContext, overlapped);
if (!bRet){
//错误处理
}else{
// 判断是否有客户端断开
if ((0 == dwBytes) && (IO_OP_TYPE::IO_RECV == ioContext->type || IO_OP_TYPE::IO_SEND == ioContext->type)){
iocp->OnDisconnected(conn);
// 回收socket
iocp->doClose(conn);
continue;
}else{
switch (ioContext->type){//按照状态调用对应函数
case IO_OP_TYPE::IO_ACCEPT:
iocp->doAccpet(conn, ioContext);
break;
case IO_OP_TYPE::IO_RECV:
iocp->doRecv(conn, ioContext);
break;
case IO_OP_TYPE::IO_SEND:
iocp->doSend(conn, ioContext);
break;
default:
break;
}
}
}
}
return 0;
}
workerThreadProc主要的功能通过调用GetQueuedCompletionStatus获取到完成的IO事件。将读取到的事件保存在ioContext中,按照状态传入各函数。
至此一个简单的echo功能服务器就实现了,基于iocp按照事件的状态进行投递。最核心的使用封装的IOContext结构体,为后续扩展功能提供了可能性。
- 简单测试与qps测试
通过网络助手测试数据连通性。
使用发包工具开辟50个线程、100个连接、1000000次请求,包长度为64字节测试得到qps数据。
使用发包工具开辟50个线程、100个连接、1000000次请求,包长度为128字节测试得到qps数据。
使用发包工具开辟50个线程、100个连接、1000000次请求,包长度为256字节测试得到qps数据。
使用发包工具开辟50个线程、100个连接、1000000次请求,包长度为512字节测试得到qps数据。
总结
IOCP作为Windows平台的高性能异步IO模型,采用Proactor架构将IO操作完全托管给内核处理,通过完成端口统一管理所有IO事件。其核心机制围绕CreateIoCompletionPort
、异步IO函数和GetQueuedCompletionStatus
三大接口构建,利用OVERLAPPED
结构体和CompletionKey
实现操作关联与上下文传递。在实现层面,通过预投递多个IO请求、工作线程池处理完成事件、结构化IO上下文管理(如IOContext
)等设计,获得了高并发处理能力。