开篇之前先放上本次讲的IOCP project github地址:这里 。这个project中包含了IOCP和select,各自封装成一个动态链接库,可以直接使用。同时项目配有完整的glog支持,方便调试,并可以通过config控制server。如有bug,欢迎大家提出,正在完善过程中,代码可以优化的地方也请大家随时提出,一起进步成长。
本文主要从以下几方面讲解IOCP使用及其原理。
为什么需要完成端口
完成端口能做什么
完成端口原理
如何使用完成端口
1. 为什么需要完成端口
网络通信模型是编写网络程序的一个比较核心的模块,也直接影响着程序的性能,所以选择合适的网络模型是非常有必要的。
IOCP是一种网络通信模型,但是在IOCP出现之前已经有相关网络通信模型在使用了,比较普遍的应该就是select模型,另外windows自己家也单独实现了alertable I/O等。但是提到的select和alertable I/O都存在一些局限,比如select模型其并发处理量受FDSETSIZE宏大小限制,在windows平台上这个大小默认是64,当然也可以自己在包含select之前手动#define其值,但是如果在使用之前就定义一个很大的值难免有点造成资源浪费,libevent就提供了一种自由的方法来使用select,这个在后面的文章中会详细介绍。alertable I/O一个缺陷是多线程之间无法达到负载均衡的,同一个线程发出的IO请求必须由同一个线程来接收,即使其他线程闲着没事干。所以这不能充分利多核系统的强大资源。那么IOCP有没有缺陷呢,当然也有,首先是使用起来不够简单明了,接口设计的不够简洁直观。但是呢性能还是杠杠的。
上面简单的说了IOCP模型与其他模型的一些对比,另外一点很大的区别是,IOCP模型是一种真正意义上的异步通信模型,具体啥是异步啥是同步可以参考我之前的一篇文章。有一点需要说明的是,并不是所有网络通信项目都必须要使用IOCP模型,对于一些已知的连接数较少的网络程序,完全可以用select甚至是每个客户端对应一个线程这种方式。
2. 完成端口能做什么
上面吹了一大波完成端口,那么完成端口究竟能做什么呢。
首先一点是:IOCP会主动帮我们完成网络IO数据复制。这一点其实也就是他与其他网络模型最直接的区别了,一般网络操作包括两个步骤,以recv来说吧,如果是一般模型,那么其第一步是通知等待的线程有数据可以读取,这时候线程会调用recv或者recvfrom等函数将数据从读缓冲区复制到用户空间,然后再做下一步的处理,而IOCP能帮我们的是,他会在内核中帮我们监听那些我们感兴趣的的事件,例如我们希望接收客户端数据,那么我们向完成端口投递一个读事件,完成端口在监测有读事件到来的时候会主动地去帮我们把数据从内存空间复制到用户空间,然后通知我们过来取数据就OK了,这就是IOCP提供的方便之处。
另外一点:IOCP在内部管理线程,实现负载平衡。上面提到了windows的alertable I/O的负载均衡是他一个弊端,那么IOCP是如何自己管理线程调度的呢,简单的说就是以栈的方式进行管理,具体内容接下来一节会详细描述。
3. 完成端口原理
overlapped
提到IOCP就不得不提到overlapped这个数据结构,这个数据结构是IOCP进行异步通信的关键。
typedef struct _OVERLAPPED {
ULONG_PTR Internal;
ULONG_PTR InternalHigh;
union {
struct {
DWORD Offset;
DWORD OffsetHigh;
};
PVOID Pointer;
};
HANDLE hEvent;
} OVERLAPPED, *LPOVERLAPPED;
这个数据结构原本windows是不打算公开的,随着软件编程的发展后来微软的工程师发现编程人员需要用到这个数据结构,所以就把他公开了,但是对于内部的变量名却没有变动,因为微软内部使用这个变量名有太多地方了,如果该变量名可能会带来很多其他的不好的影响,所以就没有更换变量名。具体的变量含义在微软的官方文档中已经明确给出。
Internal: 这个变量用来表明当前IO请求的状态,当我们向完成端口提交一个IO请求的时候如果请求还没有响应,这个值就会是STATUS_PENDING。
InternalHigh:这个变量用来当前IO请求字节数。
Offset:指定文件的起始偏移位置
OffsetHigh:指定开始传输数据的字节数的的高位
hEvent:是事件句柄,在IO请求完成后处于信号状态。
需要注意的是,在网络通信过程中offser与offserhigh是被系统自动忽略的,这两个值在异步读写文件时使用。
在异步IO过程中,只要向可以使用OVERLAPPED数据结构的函数投递一个overlapped数据机构,系统内核就会在后台默默的帮你监听你所投递的IO事件,当你的感兴趣的事件触发的时候系统会在后台帮你收集好数据然后通知你事件完成,并返回给你你投递的Overlapped数据结构的地址,由此可以看出在时间完成之前你是不能改变overlappd的地址的,否则会出现未定义的行为。
在项目中使用Overlapped数据结构一般有两种方法:
① 使用结构体包含,并通过CONTAINING_RECORD抽取IO数据
struct TEST_OVERLAPPED{
OVERLAPPED overlapped_;
WSABUF wsabuf_;
char data[SIZE];
int data_len_;
OP operate_type_;
}
定义这样的数据结构,在传入overlapped函数的时候将其强制转换成(OVERLAPPED*)(TEST_OVERLAPPED), 另外OVERLAPPED数据结构一定要放在新的数据结构头部。operate_type_是自己定义的一个标识,用来表示这个IO请求是什么类型的,当然也可以将本次IO请求的socket句柄放进去,用来表明具体是哪个client的IO。所有的这些数据都可以通过CONTAINING_RECORD宏来抽取,具体CONTAINING_RECORD是如何工作的我就不细讲了,网上一大堆。
② C++类继承overlapped数据结构
这种方法其实与第一种方法很类似,不过比较方便的是不需要用CONTAINING_RECORD来抽取具体信息了。
class MyOverlapped : public OVERLAPPED{
WSABUF wsabuf_;
char data_[DATASIZE];
int data_len_;
OP operate_type_;
SOCKET client_;
}
同样在使用的时候需要将其转换为(OVERLAPPED*)传入overlapped函数,在IO事件完成后再将其转换为我们的类,(MyOverlapped*)(&OVERLAPPED),之后直接读取成员变量即可得出信息。
IOCP内部工作原理
先上一张Jeffrey Richter在windows核心编程里的一个IOCP原理图。一张好图的效果比说n句话效果要好多了。
虽然微软没有公开完成端口具体的实现方式,但是从Jeffrey Richter的windows核心编程可以大概了解完成端口的大概实现。
当我们创建一个完成端口的时候(创建方式下一节具体讲)windows底层会帮我们创建一系列底层设施,以辅助我们后来的通信过程。具体设施如上图所示。
①设备列表
这里的设备列表我们可以简单的认为就是所有连接的socket信息的列表,对于一个socket我们要将他与完成端口关联,完成端口才会在内核中为我们监听我们感兴趣的事件,这里有一个关键的数据结构,dwCompletionKey,这个数据结构需要在我们将socket绑定到完成端口时一起传入内核设备列表中,那么他有什么作用呢?我的理解是这个数据结构主要是为了在内核中标记当前所通信的socket对象具体是哪一个,这个数据结构是由我们自己定义的,所以在这个结构体里我们可以加上我们一些自己想要的信息,因为后来通信过程中内核会将这个数据传送给我们,所以在这个结构中定义一些你感兴趣的字段可以方便后期的一些操作,比如你可以定义一个容器用来存放改设备投递的所有IO操作,这样在后期该socket关闭的时候可以方便清理与其相关的内存,以确保不会造成内存泄漏。
②完成队列
完成队列中存放的是已经完成的IO事件,每一个列表项主要包括dwBytesTransferred, dwCompletionKey, pOverlapped, dwError四个数据,dwBytesTransferred顾名思义就是本次IO事件所传输的字节数,dwCompletionKey就是上面我们所说的用来标识socket信息的数据结构,从这个数据结构我们可以知道当前的IO事件是发生在哪个socket上的,当然你需要在completionKey中设置这个字段,否则你也不知道这个事件是发生在哪个socket上的。pOverlapped数据结构也就是我们之前提到的Overlapped数据结构,这个数据结构里包含了IO数据。
③线程管理设施
线程管理是完成端口的一大特点,也就是内核在内部自己管理一个线程池。与这个线程池相关的基础设施主要有等待线程队列(栈)(以栈的方式管理),已释放的线程列表,已暂停的线程列表。当线程调用GetQueuedCompletionStatus的时候内核会将该线程放入线程栈中,因为GetQueuedCompletionStatus会将线程挂起,一旦有事件发生GetQueuedCompletion返回,这时候内核会将线程放入已释放列表中,如果这个已释放的线程又调用了一些函数将线程挂起,那么内核会将其放入已暂停线程列表中。当GetQueuedCompletionStatus返回后线程处理完数据后再次调用GetQueuedCompletionStatus进行等待时,内核会重新将该线程放到线程等待栈中,这样的一个流程下来我们可以看出,如果在IO事件处理比较慢的情况下一个线程就可以搞定所有的IO请求,这样避免了线程之间的上下文切换带来的性能开销。
4. 如何使用完成端口
铺垫了这么多,终于要讲到如何使用IOCP了。先介绍一下使用完成端口需要用到的几个比较重要的函数。
(1)CreateIOCompletionPort
之前说到过完成端口在API设计上不够清晰,现在提到的这个函数可以充分说明这个问题。这个函数有两个用途。
①CreateIOCompletionPort(-1,NULL,NULL,0)
这种调用方法是用来创建一个新的完成端口时使用的方式,最重要的是第四个参数,第四个参数主要是用来确定在同一时间最多能有多少个线程运行,设置为0代表数量与机器CPU个数一致。
②CreateIOCompletionPort(socket, completionport, pcompletionkey, 0)
这种调用方式使用老将一个socket句柄与已创建好的完成端口相关联,第一个参数代表句柄创建的时候不需要传入,这个主要是用于将一个句柄绑定到完成端口时时使用的,第二个参数代表已创建好的完成端口,第三个参数completionley上面已经说过了,是用来标识一个socket句柄的。
(2)GetQueuedCompletionStatus(
_In_ HANDLE CompletionPort,
_Out_ LPDWORD lpNumberOfBytes,
_Out_ PULONG_PTR lpCompletionKey,
_Out_ LPOVERLAPPED *lpOverlapped,
_In_ DWORD dwMilliseconds)
这个函数主要是将当前线程挂起等待IO事件完成。
CompletionPort:就是上面所创建的端口,另外有一点要说明的是这个端口与socket中使用的端口不是一个概念,你就把这个端口当成一个内核句柄就行。
lpNumberOfBytes:这个代表IO事件传输的字节数
lpCompletionKey:代表当前IO事件所隶属的句柄信息,这个是我们在绑定句柄到完成端口时自己传进去的。
lpOverlapped:这个值就包含了这次异步IO的数据信息。
dwMilliseconds:代表在等待一个IO事件完成时会等多久,如果这个值设为INFINITE,那么这个调用将永远不会超时,如果传入0,那么这个调用会立即返回。
(3)PostQueuedCompletionStatus(
_In_ HANDLE CompletionPort,
_In_ DWORD dwNumberOfBytesTransferred,
_In_ ULONG_PTR dwCompletionKey,
_In_opt_ LPOVERLAPPED lpOverlapped
)
这个函数可以用来模拟IO完成事件,经常用于退出时发送一个模拟的IO完成事件来唤醒在等待中的线程,参数信息之前都有提到就不解释了。
以上三个就是使用完成端口时最主要的三个函数,那么既然要用到Overlapped数据结构来投递IO请求事件,那么socket的发送接收函数也就不能用原来常用的send和recv了,要用到WSAERecv,WSASend两个函数了,因为这两个函数都接收一个overlapped参数。另外还有一个要提到的是,accept没有对应的WSA版本,但是由于accept是一个阻塞函数,所以如果想尽可能提高性能,可以使用微软后期自己封装的一个函数acceptex,这个函数与原始的accept之间的差别在于它是将已经创建好的socket传入函数,那么当其IO事件返回,对应的socket也就已经与客户端建立连接了,这个函数也接受一个overlapped数据结构,所以我们可以把accept事件当做一般的IO事件即可,当GetQueuedCompletionStatus返回时检查completionkey中的socket句柄是不是listen socket,如果是listen socket说明有新的连接接入,这时候需要调用微软自己的函数GetAcceptExSockaddrs来获取客户端的地址信息。在创建好完成端口时可以先投递几个accept IO事件,这样可以在高并发的时候处理的得心应手,当然了,完成端口本身就很强大。有一点要注意的是在每次处理完新的连接时要重新投递新的accept事件,为下一个连接做准备。
IOCP具体如何使用可以看我的GitHub,里面有完整的完成端口项目,对overlapped与completionkey都做了封装,有资源管理器,测试还未发现资源泄露,有问题可以直接提出来,会及时改进。
完成端口使用流程图: