如果不希望进程在对文件描述符执行I/O操作时被阻塞,我们可以创建一个新的进程来执行I/O。此时父进程可以去执行其他的任务,而子进程将阻塞直到I/O操作完成。如果我们需要处理多个文件描述符上的I/O,那么需要为每个文件描述符创建一个子进程。这种方法的问题在于开销昂贵且复杂。创建及维护进程对系统来说都是开销,而且一般来说子进程需要使用IPC机制来通知父进程有关I/O操作的状态。
如果使用多线程而不是多进程,能够占用更少的支援。但是线程间仍然需要通信,以告之有关I/O的操作状态。尤其是如果使用多线程技术来最小化需要处理大量并发客户的线程数量时。编码工作变的复杂。(多线程特别有用的一个地方是如果应用程序需要调用一个会执行阻塞式I/O操作的第三方库,那么可以通过在分离的线程中调用这个库从而避免应用被阻塞。)
I/O多路复用
I/O多路复用技术,使的单个进程或线程能同时检查多个描述符。同时检查多个描述符,看它们中的任何一个是否可以执行I/O操作。(准确的说,是看I/O系统调用是否可以非阻塞的执行)。需要注意这种技术都不会执行实际的I/O操作。它们只是告诉我们某个文件描述符已经处于就绪状态了。这时需要调用其他的系统调用来完成I/O操作。
- 系统调用select()和poll()在UNIX系统中已经存在了很长时间。同其他技术相比,它们主要的优势在于可移植性,主要缺点在于当同时检查大量文件描述符时性能延展性不佳。
select和poll存在的问题:
- 每次调用select或poll,内核都必须检查所有被指定的文件描述符,看它们是否处于就绪状态。当检查大量处于处于密集范围内的文件描述符时,该操作耗费的时间将大大超过接下来的操作。在轮询描述符上花费太多时间,那么应用程序响应I/O时间的延时可能会达到无法接受的程度。
- 每次调用sellect或poll,程序都必须传递一个表示所有需要检查的文件描述符的结构体到内核,内核检查多描述符后,修改这个结构体并返回给程序。对于poll,随呆检查文件描述符数量的增加,传递给内核的结构体的大小也随之增加。当检查大量的文件描述符时,从用户空间到内核空间的来回拷贝这个结构体将占用大量的CPU时间。对于select来说,这个结构体的大小固定是FD_SETSIZE,与待检查的描述符无关。
- select或poll调用完成后,程序必须检查返回的数据结构中的每个元素,以此查明哪个文件描述符处于就绪状态。
(select和poll糟糕的性能延展性源自这些API的局限性:通常,程序重复调用这些系统调用所检查的文件描述符集合是相同的,可是内核并不会在每个调用成功后记录下它们。信号驱动式I/O以及epoll可以使内核记录下进程中感兴趣的文件描述符)
- epoll()的关键优势在于它能让应用程序高效的检查大量的文件描述符。epoll的性能优势源自内核能够“记住”进程正在监视的文件描述符列表这一事实,与之相反的是,select和poll都必须反复告诉内核哪些文件描述符需要监视。其主要缺点在于它专属于Linux。(其他类UNIX提供了类似epoll的机制。比如,Solaris提供了特殊文件/dev/poll文件,其他一些BSD变种提供了kqueue(相比epoll,这是一种更为通用的检查机制))。
(select和poll有更好的可移植性,而epoll则有更好的性能。对于某些应用来说,编写一个软件抽象层来检查文件描述符事件是非常值得做的。有了这个抽象层,可移植的程序就可以在提供epoll机制的系统上应用epoll或类似API,而在其他系统上继续使用select和poll。如Libevent库就是这样一个抽象层。)
水平触发和边沿触发
区分两种文件描述符就绪的通知模型:
- 水平触发条件:文件描述符此时可以非阻塞的执行I/O
- 边沿触发条件:自上次状态检查以来出现了新的I/O活动(比如新的输入)
I/O模型 | 水平触发 | 边沿触发 |
select(), poll() | Y |
|
信号驱动I/O |
| Y |
epoll() | Y | Y |
当采用水平触发通知时,我们可以在任意时刻检查文件描述符的就绪状态,只要处于就绪状态,就可以对其执行I/O操作,然后重复检查文件描述符,例如如果数据一次性没有读完,我们可以继续检查描述符状态,此时会发现描述符仍然处于就绪状态,然后我们可以继续读,以此类推。
与之相反的是,当我们采用边沿触发时,只有当有I/O事件发生时我们才会收到通知。在另一个I/O事件到来前我们不会收到任何新的通知。另外,当文件描述符收到I/O事件通知时,通常我们并不知道要处理多少I/O,因此应按如下规则来设计:
- 收到通知后,程序应该在相应描述符上尽可能多的执行I/O。如果程序没那么做,那么就有可能失去执行I/O的机会。因为直到产生另一个通知为止,在此之前程序都不会再接受到通知了。这将可能导致数据丢失。而且如果我们在某个时刻仅仅对一个描述符执行大量的I/O操作,可能会让其他的文件描述符处于饥饿状态。
- 如果采用循环来对文件描述符执行尽可能多的I/O,而文件描述符又被置为阻塞的,那么最终当没有更多I/O可执行时,I/O系统调用将阻塞。基于这个原因,每个被检查的文件描述符通常都应该置为非阻塞模式,在得到通知后重复执行I/O操作,直到相应的系统调用以错误码EAGAIN或EWOULDBLOCK的形式失败返回。
选择非阻塞I/O模型配合使用
为什么选择非阻塞型I/O会很有用:
- 如上所述,非阻塞I/O通常和提供有边缘触发通知机制的I/O模型一起使用
- 尽管水平触发的系统调用,比如select、poll通知我们流式套接字的文件描述符已经写就绪,如果我们在单个write()或send()调用中写入足够大块的数据,那么该调用将阻塞。
- 如果多个进程(或线程)在同一个打开的文件描述符上执行I/O操作,那么可能出现竞争(race condition),I/O多路复用的系统调用与接下来的实际的I/O操作不是原子的,从某个特定的进程角度看,文件描述符就绪状态可能会在通知就绪和执行I/O调用之间发生变化,比如在这之间,另外一个进程对相同的文件描述符执行了I/O操作。结果就是本进程的阻塞式的I/O调用将可能被阻塞。(这种情况将发生在所有I/O模型上,无论它们是采用水平触发还是边缘触发)