IOCP 详解

IOCP 详解

网络上关于epoll的介绍资料多如牛毛,大多数已经讲解的非常细致。相比而言epoll只有三个接口调用,IOCP却有一堆的接口调用,更关键的是Windows的闭源性质,我们不知道调用之后Windows究竟做了哪些操作。众所周知IOCP是基于proactor模式的,系统内核已经帮我们做好了发送和接收数据的过程,所以我们不用自己调用recv和send之类原始的socket API。下面详细记录下IOCP的使用过程:

1 基础API
2 收发数据相关API
3 IOCP内核数据结构
4 其他细节

IOCP基础API

创建IOCP

首先我们需要向操作系统内核申请一个完成端口句柄:

HANDLE
WINAPI
CreateIoCompletionPort(
    _In_ HANDLE FileHandle,
    _In_opt_ HANDLE ExistingCompletionPort,
    _In_ ULONG_PTR CompletionKey,
    _In_ DWORD NumberOfConcurrentThreads
    );

这就是IOCP第一个接口的声明,我们先不讨论具体每个参数的意义,因为当我们创建IOCP句柄时并不需要向接口传递什么实质性的参数,这是IOCP第一步,向内核申请一个完成端口句柄。

iocp_handler = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, threads_num);

我们只要传递三个0和IOCP最多支持的线程数量(The maximum number of threads that the operating system can allow to concurrently process I/O completion packets for the I/O completion port. This parameter is ignored if the ExistingCompletionPort parameter is not NULL.If this parameter is zero, the system allows as many concurrently running threads as there are processors in the system.)MSDN上说最后一个参数是告诉完成端口最多支持的线程个数,如果为0的话将默认为CPU内核个数。我们这里将这个数设置为《windows核心编程》建议的CPU内核数*2。

绑定socket

已经有了IOCP句柄,接下来我们就需要将需要监测的socket绑定到这个端口上,我们用到的API还是这个!不过这次我们传递的参数恰恰相反,除了最后一个没用,前边倒要传递一些实际的参数:
第一个参数FileHandle是我们要监测的文件句柄,注意是这个句柄不一定非得是socket,ICOP可以监测很多异步调用的返回,不过在这里我们只用来做网络IO复用,所以传递的是已经准备好的socket(一定是listen调用之后的或者accept接收完成的,而且是异步的socket)。
第二个参数就是我们第一步调用时候返回的IOCP的句柄。
第三个参数字面意义叫做完成键,这个其实是IOCP交给用户保存网络会话上下文的一个渠道,类似于epoll中epoll_event的data成员,当我们阻塞监测到接收到新事件到来时,会从IOCP中得到这个我们传进去的值。这个只是保存网络会话上下文的其中之一,一会儿我们会看到还可以通过别的方式保存针对每个连接类似于上下文这样的信息。其类型是ULONG_PTR,所以我们可以传递任意指针进去。

等待完成事件

接下来我们要做的就是等待事件从IOCP中返回。IOCP和epoll的最大不同是IOCP是天然集成在线程池的上的。这点使得抽象两种IO复用模型比较困难。epoll每次从内核中返回一个触发事件的列表,而IOCP每次只能触发一个事件。我们在子线程中调用第二个API来等待事件触发:

BOOL
WINAPI
GetQueuedCompletionStatus(
    _In_ HANDLE CompletionPort,
    _Out_ LPDWORD lpNumberOfBytesTransferred,
    _Out_ PULONG_PTR lpCompletionKey,
    _Out_ LPOVERLAPPED * lpOverlapped,
    _In_ DWORD dwMilliseconds
    );

第一个参数CompletionPort,是我们从操作系统获取的IOCP句柄。
第二个参数lpNumberOfBytesTransferred是该事件从socket中读取或写入的数据,注意这里和epoll有巨大区别,epoll是事件触发之后,我们自己来负责读写数据,而IOCP则是数据读写完成后再来通知我们(Reactor和Proactor的差别)。
第三个参数是完成键,就是我们第二次调用CreateIoCompletionPort时传递的第三个参数,我们可以从这个参数里获取到网络连接的上下文之类的信息。
第四个参数是个Windows定义好的结构体,叫做重叠结构体:

typedef struct _OVERLAPPED {
    ULONG_PTR Internal;
    ULONG_PTR InternalHigh;
    union {
        struct {
            DWORD Offset;
            DWORD OffsetHigh;
        } DUMMYSTRUCTNAME;
        PVOID Pointer;
    } DUMMYUNIONNAME;

    HANDLE  hEvent;
} OVERLAPPED, *LPOVERLAPPED;

Offset和OffsetHigh构成一个64位的偏移量,表示文件的时候应该从哪里开始进行IO操作,非文件设备会忽略这两个二变量,我们进行网络IO时需要将其设置为0。
hEvent IOCP返回时会返回传递进去的内容,这里可以保存用户上下文之类的信息(我倒是没有用到)。
Internal保存已处理的IO请求的错误码。
InternalHigh表示异步请求完成时,已经传输的字节数。
这里我们可以自定义一个结构体,成员变量中包含一个OVERLAPPED类型的变量,在IOCP事件返回时通过CONTAINING_RECORD宏来获取自定义结构体的指针,这是第二个可以绑定用户数据(连接上下文的地方,另外一个是GetQueuedCompletionStatus绑定socket时传递的第三个参数)。

唤醒等待线程

有些场景我们需要唤醒等待的线程来执行一些别的操作或者关闭线程,可以通过这个函数来实现:

BOOL
WINAPI
PostQueuedCompletionStatus(
    _In_ HANDLE CompletionPort,
    _In_ DWORD dwNumberOfBytesTransferred,
    _In_ ULONG_PTR dwCompletionKey,
    _In_opt_ LPOVERLAPPED lpOverlapped
    );

第一个参数CompletionPort是IOCP 句柄
后三个参数将等待线程唤醒时会在GetQueuedCompletionStatus的函数中返回,这时可以商定一些特殊值来区分不同的唤醒操作。
每调用一次PostQueuedCompletionStatus都会唤醒一个阻塞在GetQueuedCompletionStatus函数的线程。
IOCP相关的基础API只有这三个,但因为proactor模式不需要我们自己去做实际手法数据的操作,所以我们和内核交换数据的API都和以往的方式有所不同。

数据交互API

创建socket

IOCP管理的socket一定需要是异步的,申请异步socket可以通过

SOCKET
WSAAPI
WSASocket(
    _In_ int af,
    _In_ int type,
    _In_ int protocol,
    _In_opt_ LPWSAPROTOCOL_INFOW lpProtocolInfo,
    _In_ GROUP g,
    _In_ DWORD dwFlags
    );

函数来完成,调用时在最后一个参数传入WSA_FLAG_OVERLAPPED标识,来告诉内核这个socket是异步非阻塞的。

投递accept

在投递accept事件的时候不同于epoll简单的当作一个读取事件来完成,而需要用到一个windows拓展函数:

BOOL
(PASCAL FAR * LPFN_ACCEPTEX)(
    _In_ SOCKET sListenSocket,
    _In_ SOCKET sAcceptSocket,
    _Out_writes_bytes_(dwReceiveDataLength+dwLocalAddressLength+dwRemoteAddressLength) PVOID lpOutputBuffer,
    _In_ DWORD dwReceiveDataLength,
    _In_ DWORD dwLocalAddressLength,
    _In_ DWORD dwRemoteAddressLength,
    _Out_ LPDWORD lpdwBytesReceived,
    _Inout_ LPOVERLAPPED lpOverlapped
    );

第一个参数sListenSocket是服务器监听socket.
第二个参数sAcceptSocket是收到新连接的客户端socket,这里又有点异乎寻常,我们需要首先调用WSASocket创建接收的新socket,而不同以往内核自己创建这个客户端socket.
第三个参数lpOutputBuffer是读取到内容的缓冲buff,IOCP接收到新连接的时候并不立马返回触发接收事件,而是等到这个连接收到实际数据时再返回,从而避免了空连接到应用程序上。
第四个参数dwReceiveDataLength是接受数据的缓冲区长度。
第五个参数dwLocalAddressLength是本地的地址结构体长度。
第六个参数dwRemoteAddressLength是对端地址结构体长度。
第七个参数lpdwBytesReceived是传输字节数返回,我们没有用到,因为我们用这个函数仅仅向IOCP投递accept事件,实际接受操作并不通过这个接口来完成。
第八个参数lpOverlapped是每个连接都要有的重叠IO结构体。
我们调用的时候需要这样传递参数

AcceptEx((SOCKET)event->_accept_socket->GetSocket(), (SOCKET)event->_client_socket->GetSocket(), &context->_lapped_buffer, context->_wsa_buf.len - ((sizeof(SOCKADDR_IN) + 16) * 2),
        sizeof(SOCKADDR_IN) + 16, sizeof(SOCKADDR_IN) + 16, &dwBytes, lapped);

接受的数据和对端地址信息都会写进一个buff里。

这个函数看起来非常复杂,而且是一个函数指针不是函数申明,我们还需要通过

int
WSAAPI
WSAIoctl(
    _In_ SOCKET s,
    _In_ DWORD dwIoControlCode,
    _In_reads_bytes_opt_(cbInBuffer) LPVOID lpvInBuffer,
    _In_ DWORD cbInBuffer,
    _Out_writes_bytes_to_opt_(cbOutBuffer, *lpcbBytesReturned) LPVOID lpvOutBuffer,
    _In_ DWORD cbOutBuffer,
    _Out_ LPDWORD lpcbBytesReturned,
    _Inout_opt_ LPWSAOVERLAPPED lpOverlapped,
    _In_opt_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
    );

函数来获取这个函数指针的值,接下来说的几个其他的拓展函数需要同样通过这个WSAIoctl接口来获取。
第一个参数是个临时的socket.
第二个参数需传入SIO_GET_EXTENSION_FUNCTION_POINTER标识
第三个参数传入需要获取函数指针的GUID,这里我们传的是WSAID_ACCEPTEX,获取accept拓展函数指针。
第五个参数返回指定的函数指针。

接收accept返回

我们同样需要一个拓展函数指针来完成,获取这个函数指针时需要给WSAIoctl传递的GUID WSAID_GETACCEPTEXSOCKADDRS。这个函数不简单,能同时接收传递过来的第一组数据:

VOID
(PASCAL FAR * LPFN_GETACCEPTEXSOCKADDRS)(
    _In_reads_bytes_(dwReceiveDataLength+dwLocalAddressLength+dwRemoteAddressLength) PVOID lpOutputBuffer,
    _In_ DWORD dwReceiveDataLength,
    _In_ DWORD dwLocalAddressLength,
    _In_ DWORD dwRemoteAddressLength,
    _Outptr_result_bytebuffer_(*LocalSockaddrLength) struct sockaddr **LocalSockaddr,
    _Out_ LPINT LocalSockaddrLength,
    _Outptr_result_bytebuffer_(*RemoteSockaddrLength) struct sockaddr **RemoteSockaddr,
    _Out_ LPINT RemoteSockaddrLength
    );

第一个参数lpOutputBuffer是我们投递accept时传递的缓存buff。
第二个参数dwReceiveDataLength是buff长度,我们同样要减去两个地址的长度。
第三第四个参数是本地和远端的地址结构体长度。
剩下的四个参数是地址返回和返回地址的长度。
这个函数的作用是在内核给之前投递accept时传递的buff填写数据之后,从这个buff中提取出我们需要的数据信息

投递读取事件

投递读取事件不需要特殊的拓展函数:

int
WSAAPI
WSARecv(
    _In_ SOCKET s,
    _In_reads_(dwBufferCount) __out_data_source(NETWORK) LPWSABUF lpBuffers,
    _In_ DWORD dwBufferCount,
    _Out_opt_ LPDWORD lpNumberOfBytesRecvd,
    _Inout_ LPDWORD lpFlags,
    _Inout_opt_ LPWSAOVERLAPPED lpOverlapped,
    _In_opt_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
    );

第一个参数s是读取数据的socket。
第二个参数lpBuffers是读取数据的写入buff,这里可以类似Linux的readv分块读取,传递LPWSABUF 类型数组
第三个参数是lpBuffers 数组的长度。
第四个参数是读取字节的长度,我们这里是异步模式,投递到IOCP中,所以不用这个参数。
第五个参数是标识符来控制函数行为(接收带外信息等),我们仅使用函数的默认行为。
第六个参数是每个socket都需要有的重叠结构体。
最后一个参数传nullptr。
IOCP内核读取完成之后才会通知我们处理数据,所以我们需要提前将接收buff准备好。当有读取事件触发时我们不需要再调用其他的接口,直接处理投递时传递进去的缓存buff即可。读取数据的长度可以通过lpOverlapped参数的InternalHigh成员获取或者GetQueuedCompletionStatus的第二个参数返回。

投递发送事件

投递发送事件类似于投递接收事件,不需要通过特殊的拓展函数来完成:

int
WSAAPI
WSASend(
    _In_ SOCKET s,
    _In_reads_(dwBufferCount) LPWSABUF lpBuffers,
    _In_ DWORD dwBufferCount,
    _Out_opt_ LPDWORD lpNumberOfBytesSent,
    _In_ DWORD dwFlags,
    _Inout_opt_ LPWSAOVERLAPPED lpOverlapped,
    _In_opt_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
    );

第一个参数s是读取数据的socket。
第二个参数lpBuffers是写入数据的缓存buff,注意这里的buff已经写入了需要发送的数据。同样类似与Linux的writev函数可以分块写。
第三个参数是lpBuffers 数组的长度。
第四个参数是读取字节的长度,我们这里是异步模式,投递到IOCP中,所以不用这个参数。
第五个参数是标识符来控制函数行为(接收带外信息等),我们仅使用函数的默认行为。
第六个参数是每个socket都需要有的重叠结构体。
最后一个参数传nullptr。
当有写入事件返回时说明内核已经完成了我们交给它的发送事件,发送的长度信息和读取时获取的方式一样,可以参考上个API说明。

投递连接请求

这里又需要用到windows的拓展函数,需要给WSAIoctl传递WSAID_CONNECTEX来获取:

BOOL
(PASCAL FAR * LPFN_CONNECTEX) (
    _In_ SOCKET s,
    _In_reads_bytes_(namelen) const struct sockaddr FAR *name,
    _In_ int namelen,
    _In_reads_bytes_opt_(dwSendDataLength) PVOID lpSendBuffer,
    _In_ DWORD dwSendDataLength,
    _Out_ LPDWORD lpdwBytesSent,
    _Inout_ LPOVERLAPPED lpOverlapped
    );

第一个参数s是请求连接的socket。
第二第三个参数是请求连接的地址和地址长度。
第四第五个参数配合可以分块的在连接请求的同时完成部分数据的发送(在接收时也是接收socket的同时接收数据)。
第六个参数是发送数据长度的返回,异步调用时没有用到。
最后一个参数是重叠结构体。

投递断开连接请求

这里又需要用到windows的拓展函数,需要给WSAIoctl传递WSAID_DISCONNECTEX来获取:

BOOL
(PASCAL FAR * LPFN_DISCONNECTEX) (
    _In_ SOCKET s,
    _Inout_opt_ LPOVERLAPPED lpOverlapped,
    _In_ DWORD  dwFlags,
    _In_ DWORD  dwReserved
    );

第一个参数是操作的socket。
第二个参数是重叠结构体。
后两个参数传0即可。

IOCP的内核步骤

虽然我们仅仅调用了两个接口来和IOCP打交道,但是系统却已经为接下来的工作建立了所有需要的内核数据结构。

设备列表

设备列表包含绑定socket到IOCP时传递的socket和完成键(第三个参数CompletionKey),socket和完成键一一对应:
这里写图片描述
添加:每一次调用GetQueuedCompletionStatus向IOCP绑定新的socket时都会添加新项。
删除:当socket被关闭时删除相应项。

IO完成队列(先入先出)

记录了每个完成项的传输字节数,完成键,重叠结构体和错误码:
这里写图片描述
添加:当IO请求完成或者PostQueuedCompletionStatus被调用时添加新项
删除:完成端口从等待线程中返回时删除对应项。

等待线程队列(后入先出)

这个队列里记录了所有调用GetQueuedCompletionStatus阻塞等待事件的线程id:
这里写图片描述
添加:当线程调用GetQueuedCompletionStatus时添加记录
删除:完成队列不为空,且正在运行的线程数小于最大并大线程数。

已释放线程列表

这个列表记录了所有非阻塞状态的线程:
这里写图片描述
添加:完成端口在等待线程唤醒一个线程或者已经暂定线程被唤醒
删除:线程再次调用GetQueuedCompletionStatus等待事件时或者线程用一个函数将自己挂起时

已暂停线程队列

这里写图片描述
添加:当的线程调用函数将自己挂起时
删除:挂起的线程被唤醒
这五个内核数据结构的项相互转移添加,来配合实现了内核IO事件删除添加的过程。

其他细节

事件分类

线程触发事件返回时的事件类型的区分需要我们自己来维护,我们可以定义一个 enum来区分事件,之后将事件保存在连接的上下文中,上文我们已经提到IOCP又两个地方都可以传入连接的山下文信息(完成键和重叠结构体),我们可以在事件返回时获取连接的上下文信息来分别触发的事件类型:

enum EVENT_FLAG {
    EVENT_READ          = 0x0001,       //read event
    EVENT_WRITE         = 0x0002,       //write event
    EVENT_ACCEPT        = 0x0004,       //accept event
    EVENT_TIMER         = 0x0008,       //timer event
    EVENT_CONNECT       = 0x0010,       //connect event
    EVENT_DISCONNECT    = 0x0020        //disconnect event
};
何时析构连接上下文

上文提到IOCP在收到close socket时将socket从IOCP中删除,这里有个问题就是删除时IOCP会将该socket之前投递的事件全部返回,之前投递过几次事件,GetQueuedCompletionStatus就会返回几次。我们并不知道之前具体投递了几次事件,而且GetQueuedCompletionStatus之后的处理逻辑中会取连接的上下文来区分具体触发的事件类型。所以一定要在最后一次返回时析构连接的上下文,否则必然引起奔溃。一定不要在socket还在IOCP管理中或者还未完全删除时析构连接的上下文!
这里我们可以定义一个变量来记录事件投递的数量,当投递新的事件时++,触发事件时–,由于IOCP基于proactor模型,所有的事件都是投递一次,返回一次(与epoll不同)。当这个变量为0时再析构连接的上下文。

如何获取客户端关闭事件

当GetQueuedCompletionStatus函数返回时,第二个参数为0且返回值为真时,就可以认为客户端关闭了连接,客户端奔溃时也会发生这种情况。但是依赖这个条件来判断客户端有没有断开还是不能百分之百的保证正确率,所以应用层还是需要通过心跳来监测客户端连接。

Github:https://github.com/caozhiyi/CppNet

  • 7
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值