设备---windows操作系统上允许通信的任何东西,比如文件、目录、串行口、并行口、邮件槽、命名管道、无名管道、套接字、控制台、逻辑磁盘、物理磁盘等。绝大多数与设备打交道的函数都是CreateFile/ReadFile/WriteFile等。所以我们不能看到**File函数就只想到文件设备。
与设备通信有两种方式,同步方式和异步方式。同步方式下,当调用ReadFile函数时,函数会等待系统执行完所要求的工作,然后才返回;异步方式下,ReadFile这类函数会直接返回,系统自己去完成对设备的操作,然后以某种方式通知完成操作。
重叠I/O----顾名思义,当你调用了某个函数(比如ReadFile)就立刻返回做自己的其他动作的时候,同时系统也在对I/0设备进行你要求的操作,在这段时间内你的程序和系统的内部动作是重叠的,因此有更好的性能。所以,重叠I/O是用于异步方式下使用I/O设备的。
重叠I/O需要使用的一个非常重要的数据结构OVERLAPPED。
完成端口---是一种WINDOWS内核对象。完成端口用于异步方式的重叠I/0情况下,当然重叠I/O不一定非使用完成端口不可,还有设备内核对象、事件对象、告警I/0等。但是完成端口内部提供了线程池的管理,可以避免反复创建线程的开销,同时可以根据CPU的个数灵活的决定线程个数,而且可以让减少线程调度的次数从而提高性能。
typedef struct _OVERLAPPED {
ULONG_PTR Internal;
ULONG_PTR InternalHigh;
DWORD Offset;
DWORD OffsetHigh;
HANDLE hEvent;
} OVERLAPPED;
这个结构包含5个成员,其中的 Offset、OffsetHign和hEvent必须在调用ReadFile和WriteFile之前进行初始化,其他两个成员由驱动程序来设置,当I/O操作完成时可以检查他们的值。
hEvent成员: 用来接收I/O完成通知的4种方法(触发设备内核对象、触发时间内核对象、使用可提醒I/O、使用I/O完成端口)中,其中一种方法(使用I/O完成端口)会用到这个成员。
I/O完成端口的设计初衷就是与线程池配合使用,从而提高服务应用程序的性能。
服务应用程序的两种架构模型:
-串行模型:一个线程等待一个客户请求,当请求到达时,线程被唤醒并对客户请求进行处理
-并行模型:一个线程等待一个客户请求,当请求到达时,创建一个新的线程来处理请求
显而易见,串行模型的缺点是不能很好地同时处理多个请求,并行模型中windows内核在各可运行的线程之间进程上下文切换花费太多时间。
I/O完成端口背后的理论是并发运行的线程数量必须有一个上限。
I/O完成端口是一个很复杂的内核对象,可以使用CreateIoCompletionPort来创建个I/O完成端口
HANDLE CreateIoCompletionPort (
HANDLE FileHandle, // handle to file
HANDLE ExistingCompletionPort,// handle to I/O completion port
ULONG_PTR CompletionKey, // completion key
DWORD NumberOfConcurrentThreads // number of threads to execute concurrently
);
事实上这个函数确执行了两线不同的任务:1.创建一个I/O完成端口;2.将一个设备与一个I/O完成端口关联起来
第一步:为了只创建一个I/O完成端口,我们给CreateIoCompletionPort的前三个参数分贝传入INVALID_HANDLE_VALUE,NULL,0。
第二步:将设备与I/O完成端口关联起来,如果传入的参数ExistingCompletionPort值不为NULL,则将它和FileHandle关联起来。
当创建一个I/O完成端口的时候,系统内核实际上会创建5个不同的数据结构,它们分别是:
1. 设备列表:表示与该端口相关联的一个或多个设备
2. I/O完成队列(先入先出):当设备的一个异步I/O请求完成时,系统会检查设备是否与一个I/O完成端口想关联,如果是,则将该I/O请求追加到I/O完成队列的末尾。
3. 等待线程队列(后入先出):当线程池中的每个线程调用GetQueuedCompletionStatue(切换线程到睡眠状态)的时候,调用线程的线程标识符会被添加到这个等待线程队列中,使得I/O完成端口内核对象始终都能够知道,有哪些线程当前正在等待对已完成的I/O请求进行处理(也就是等待I/O完成队列中有一项添加进来)
4. 已释放线程列表:可以理解为这些线程可以切换到运行状态,等待线程队列和已暂停线程列表中的线程被唤醒后添加到该列表。
5. 已暂停线程列表:已释放的线程调用一个函数将自己挂起时将该线程的线程标识符添加到该列表, 当挂起的线程被唤醒时再添加到已释放线程列表
弄明白这5个数据结构,就很容易搞清楚I/O完成端口是如何管理多线程的了。
I/O完成端口体系结构假定可运行线程的数量只会在很短一段时间内高于最大允许的线程数量,一旦线程进入下一次循环并调用GetQueuedCompletionStatus,可运行线程的数量就会迅速下降,这也就是为什么线程池中的线程数量应该大于在完成端口中设置的并发线程数量。
怎么来确定线程池中应该有多线线程? 可以使用启发式算法来对线程池进行管理。
线程间传递数据:
线程间传递数据最常用的办法是在_beginthreadex函数中将参数传递给线程函数,或者使用全局变量。但是完成端口还有自己的传递数据的方法,答案就在于CompletionKey和OVERLAPPED参数。
CompletionKey被保存在完成端口的设备表中,是和设备句柄一一对应的,我们可以将与设备句柄相关的数据保存到CompletionKey中,或者将CompletionKey表示为结构指针,这样就可以传递更加丰富的内容。这些内容只能在一开始关联完成端口和设备句柄的时候做,因此不能在以后动态改变。
OVERLAPPED参数是在每次调用ReadFile这样的支持重叠I/0的函数时传递给完成端口的。我们可以看到,如果我们不是对文件设备做操作,该结构的成员变量就对我们几乎毫无作用。我们需要附加信息,可以创建自己的结构,然后将OVERLAPPED结构变量作为我们结构变量的第一个成员,然后传递第一个成员变量的地址给ReadFile函数。因为类型匹配,当然可以通过编译。当GetQueuedCompletionStatus函数返回时,我们可以获取到第一个成员变量的地址,然后一个简单的强制转换,我们就可以把它当作完整的自定义结构的指针使用,这样就可以传递很多附加的数据了。太好了!只有一点要注意,如果跨线程传递,请注意将数据分配到堆上,并且接收端应该将数据用完后释放。我们通常需要将ReadFile这样的异步函数的所需要的缓冲区放到我们自定义的结构中,这样当GetQueuedCompletionStatus被返回时,我们的自定义结构的缓冲区变量中就存放了I/0操作的数据。
CompletionKey和OVERLAPPED参数,都可以通过GetQueuedCompletionStatus函数获得。