IOCP的全称就是I/O Completion ports。
虽然名称看上去相似,但是它和APCs 中所用的I/O completion routines 没有任何关联。
IOCP 可以解决目前为止我们看到的所有问题:
- 与WaitForMultipleObjects()不同,这里不限制handles个数。
- I/O completion ports 允许一个线程将一个请求暂时保存下来,而由另一个线程为它做实际服务。
- I/O completion ports 默默支持 scalable 架构。
服务器的线程模型
有3 个基本的方法可以决定一个服务器上需要多少个线程:
- 单独一个线程。 在一个文件服务器或一个简单的web服务器上,单一线程可以使用overlapped I/O来搬移数据,但是如果这个线程还必须进行任何其他操作,整个服务器就会陷入泥沼中。
- 每个client 给予一个线程,如果你为每个client产生一个线程那么理论上每个人都可以有不错的反应时间,因为CPU的能量被平均分配了,然而事实上系统资源是有限的,到了某种情况下,系统效率就会急剧下降,比如如果有2000个client,这种做法就不切实际了。
- 每个CPU给予一个线程,这种做法让每一个CPU尽可能忙碌,不至于出现哪一个CPU有过度饱和的情况,如果你使用event对象或APCs,此法将难以实现,因为它们都是否紧密地和某个线程绑在一起,此时我们就需要依赖一种特殊的同步对象,也就是I/O completion ports。
I/O completion port 是一种非常特殊的核心对象,用来综合一堆线程,让它们为overlapped请求服务。
其所提供的功能可以跨越多个CPUs。
Completion Ports 做了什么
IOCP允许你将启动overlapped 请求的线程和提供服务的线程拆伙。
为了使用IOCP,你的程序应该产生一堆线程,统统在IOCP上等待着,这些线程都将成为能够处理completed overlapped I/O request的线程之一 ——只要线程在I/O completion port上等待,它就自然而然成为了那种线程。
每次有新的文件因为overlapped I/O开启你就可以让它的文件handle和I/O completion port产生关联,一旦这样的关系建立起来,任何文件操作如果成功完成,便会导致I/O completion packet 被送到 completion port去。这是发生操作系统之内的操作,对应用程序而言是透明的。
为了回应I/O completion packet,completion port释放了一个等待中的线程,如果目前没有线程正在等待,completion port就不会产生新线程。
释放出来的线程被给予足够的信息后就可以执行,处理该操作请求,但它还是属于原来的那一堆(被指定给此completion port)的线程,所不同的是,之前它是等待中的线程(waiting),现在它是一个作用中的线程(active),当这个线程将overlapped I/O请求处理完毕后,它应该再次在这个I/O completion port上等待。
现在我们再来描述一个completion port ,它是一个机制,用来管理一堆线程如何为completed overlapped I/O request服务。然而,completion port远比一个简单的分派器要丰富的多,它可以保持一个CPU或多个CPUs尽可能忙碌,但也避免它们被太多线程淹没,I/O completion port企图保持并行处理的线程个数在某个数字左右,一般而言你希望所有的CPUs都忙碌,所以一般而言并行处理的线程个数就是CPUs的个数。
I/O completion port运作过程中令人迷惑的部分就是,当一个线程被阻塞时它将发出通过并提交另一个线程。假设在单一CPU系统中有两个线程都正在一个I/O completion port上等待,线程1被唤醒,并且从网络上活得一包数据,为了服务这个数据包,线程1必须从磁盘上读取一个文件,所以它调用CreateFile()和ReadFile(),但是并不在overlapped模式中。Completion port 于是通告说线程1被磁盘I/O滞留了,并且提交线程2以使目前作用的线程个数到达需求数量。
当线程1 从磁盘操作中返回时,或许现有有两个线程同时在执行——甚至即使作用中的线程个数应该是1(因为CPU的个数只是1),这一行为令人惊讶,但却是正确的。Completion Port不会再提交另一个线程,除非作用中的线程个数再次降低到1以下。
操作概观
使用一个completion port 的快速摘要如下:
1. 产生一个I/O completion port
2. 让它和一个文件handle 产生关联
3. 产生一堆线程
4. 让每一个线程都在completion port 上等待
5. 开始对着那个文件handle 发出一些overlapped I/O 请求
当新文件被开启时,它们可以在任何时候与I/O completion port 产生关联。在completion port上等待的线程不应该做为completion port 服务以外的事情,因为这些线程一直都将是completion port 所持续追踪的那一堆线程的一部分。
步骤1:产生一个I/O Completion Port
I/O completion port 是一核心对象,你必须使用CreateIoCompletionPort()才能产生它:
HANDLE WINAPI CreateIoCompletionPort(
_In_ HANDLE FileHandle, //文件或设备的handle,可以使用INVALID_HANDLE_VALUE,表示产生一个与任何文件handle都无关的port
_In_opt_ HANDLE ExistingCompletionPort,//如果被指定,那么上一栏的FileHandle就会被加到这个port上,指定NULL可以产生一个新的port
_In_ ULONG_PTR CompletionKey,//用户自定义的的一个数值,将被交给提供服务的线程,这个值和FileHandle有关
_In_ DWORD NumberOfConcurrentThreads//与此completion port 有关联的线程个数
);
返回值:
如果函数成功将返回一个I/O completion port的handle,如果函数失败那么则传回FALSE
任何文件只要附着到一个I/O completion port身上,都必须先以FILE_FLAG_OVERLAPPED开启,如果已经附着上去,就不能够再以ReadFileEx()或WriteFileEx()操作它。你可以任意关闭这样一个文件,没有安全上的顾虑。
还有需要注意的是,最后一个参数:
If this parameter is zero, the system allows as many concurrently running threads as there are processors in the system.
如果你设定为0,那么在CPU系统上就会有尽可能多的线程运行起来。
步骤2:与一个文件handle产生关联
CreateCompletionPort()通常被调用两次,一次先指定FileHandle为INVALID_HANDLE_VALUE,并设定ExistingCompletionPort 为NULL,用以产生一个port。
然后再为每一个欲附着上去的文件handle调用一次CreateIoCompletionPort(), 这些调用将ExistingCompletionPort设定为第一次调用所传回的handle。
例如:
HANDLE hPort;
HANDLE hFiles[MAX_FILES];
int index;
hPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,0,0);
for(index =0; index<OPEN_FILES; index++){
CreateIoCompletionPort(hFiles[index],hPort,0,0);
}
步骤3 : 产生一堆线程
一旦completion port 产生出来,你就可以设立在该port上等待的那些线程了。 I/O completion port并不自己产生那些线程,它只是使用由你产生的线程。因此你必须自己以CreateThread() 或_beginthreadex()或AfxBeginThread()产生出线程。
当你一产生这些线程时,它们都应该在completion port上等待,当线程开始为各个请求服务时,池子里的线程的组织如下:
目前正在执行的线程
+ 被阻塞的线程
+ 在completion port上等待的线程
----------------------------------------
池子里所有线程的个数
因为如此,所以你应该产生比CPU个数还多的线程,如果你只有一个CPU,而你也只产生了一个线程,那么当该线程阻塞时,你的CPU也变成闲置的了。由于池子里没有其他线程,completion port 也就没有办法为任何数据包服务,甚至即使CPU的能量游刃有余。
合理的线程个数应该是CPU个数的两倍加上2,你当然也可以产生更多的线程,但记住线程不是免费的。
步骤4 :在一个I/O Completion Port 上等待
work线程初始化自己后,它应该调用GetQueuedCompletionStatus(),这个操作像是WaitForSingleObject() 和 GetOverlappedResult()的组合。函数的规格如下:
BOOL WINAPI GetQueuedCompletionStatus(
_In_ HANDLE CompletionPort,//将在其上等待的completion port
_Out_ LPDWORD lpNumberOfBytes,//一个指针,指向被传输的数据字节数
_Out_ PULONG_PTR lpCompletionKey,//一个指针,由CreateIoCompletionPort()所定义的key
_Out_ LPOVERLAPPED *lpOverlapped,//其实应该命名为lplpOverlapped,因为它指向的是overlapped结构的指针的地址
_In_ DWORD dwMilliseconds//等待的最长时间
);
返回值
如果函数成功将一个completion packet 从队列中取出,并完成一个成功的操作,函数将传回TRUE,并填写由lpNumberOfBytesTransferred 、lpCompletionKey、lpOverlapped所指向的变量内容。
如果操作失败,但completion packet已经从队列中取出,则函数传回FALSE,lpOverlapped 指向失败之操作。调用GetLastError()可获知为什么I/O操作会失败。
如果函数失败,则传回FALSE,lpOverlapped 设为NULL。
与其他的核心对象不同,在completion port上等待的线程是以先进后出(FILO)的次序提供服务。没有什么理由需要担忧次序的公平性,因为所有的线程都做完全相同的事情。
使用FILO,一个进行中的线程调用GetQueuedCompletionStatus()就可以取得下一个请求request,并保持执行状态,没有阻塞,这是非常有效率的。如果线程等待太长的时间,就有可能被置换出去(page out)。最近执行过的线程则通常还在内存中,不需要先置换进来才能执行。
步骤5: 发出overlapped I/O请求
这些请求可以启动一个能被I/O completion port所驾驭的I/O操作:
- ConnectNamePipe()
- DeviceIoControl()
- LockFileEx()
- ReadFile()
- TransactNamePipe()
- WaitCommEvent()
- WriteFile()
为了使用completion port ,主线程可以对着一个与此completion port 有关联的文件,进行读、写、或其他任何操作。该线程不需要调用WaitForMultipleObjects(),因为池子里各个线程都调用过GetQueuedCompletionStatus()。一旦I/O操作完成,一个等待中的线程将会自动被释放,以服务该操作。
避免Completion Packets
常常会有这种情况:你读一个文件,但是操作完成时,你并不希望I/O completion port 被通告。网络服务器就是一个例子,在那里,线程从一个文件读进一个针对named pipe 或socket的请求,然后将回应写到同一个文件中。问题是文件已经以overlapped I/O状态打开了,所以写入操作将被异步。而当写入操作完成时,completion port将会收到一个completion packet,如果写入操作不是很重要,那么服务器花费许多时间处理不怎么重要的completion packets就显得得不偿失。
解决之道,将每个操作均会引发一个completion port 通告的开关关闭。
我们可以用受激发的event对象的机制取而代之,你设定一个overlapped结构,内含一个manual reset event对象,放在hEvent一栏,然后把handle最低位设为1. ————有点类似于hack行为,但是文件中是这么交代的。
OV