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模式仅接管了事件描述符,只在在事件就绪(可读、可写)时通知回调函数执行对应的操作。

flowchart LR %% 定义 2 组 IO 节点(检测 + 操作) subgraph IOGroup1 Det1[io检测] Op1[io操作] end subgraph IOGroup2 Det2[io检测] Op2[io操作] end %% 核心流程节点 IOMultiplex[io 多路复用] Reactor(reactor) IOActions[accept、connect、read...] %% 流程逻辑:检测 → 多路复用 → reactor 调度 → 操作执行 Det1 --> IOMultiplex Det2 --> IOMultiplex IOMultiplex --> Reactor Reactor --> Op1 Reactor --> Op2 Reactor --> IOActions

​ 而proactor模式不仅将事件进行统一管理,并且将IO操作交给内核处理,只返回给用户数据。

flowchart LR %% 定义 2 组 IO 节点(检测 + 操作) subgraph IOGroup1 Det1[io检测] Op1[io操作] end subgraph IOGroup2 Det2[io检测] Op2[io操作] end %% IOCP 核心节点 IOCP(iocp) %% 流程逻辑:IO 检测/操作 交给 IOCP 接管,操作完成发通知 Det1 --> IOCP Op1 --> IOCP Det2 --> IOCP Op2 --> IOCP %% 操作完成后,IO 向 IOCP 发完成通知 IOCP -->|完成通知| Op1 IOCP -->|完成通知| Op2

​ 可以看到,用户通过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架构虽然在机制上有着本质上的不同,但在代码框架上没有大的区别。需要显示地实例化管理对象,通过固定接口向管理对象提交事件,最后后续事件通知进行下一步操作。

1

服务器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测试

2

​ 通过网络助手测试数据连通性。

​ 使用发包工具开辟50个线程、100个连接、1000000次请求,包长度为64字节测试得到qps数据。

3

​ 使用发包工具开辟50个线程、100个连接、1000000次请求,包长度为128字节测试得到qps数据。

4

​ 使用发包工具开辟50个线程、100个连接、1000000次请求,包长度为256字节测试得到qps数据。

5

​ 使用发包工具开辟50个线程、100个连接、1000000次请求,包长度为512字节测试得到qps数据。

6

总结

​ IOCP作为Windows平台的高性能异步IO模型,采用Proactor架构将IO操作完全托管给内核处理,通过完成端口统一管理所有IO事件。其核心机制围绕CreateIoCompletionPort、异步IO函数和GetQueuedCompletionStatus三大接口构建,利用OVERLAPPED结构体和CompletionKey实现操作关联与上下文传递。在实现层面,通过预投递多个IO请求、工作线程池处理完成事件、结构化IO上下文管理(如IOContext)等设计,获得了高并发处理能力。

posted @ 2025-07-03 14:41  +_+0526  阅读(33)  评论(0)    收藏  举报