Unix下可用的5种I/O模型
- 阻塞式I/O
- 非阻塞式I/O
- I/O复用
- 信号驱动式I/O
- 异步I/O
一个输入操作通常包括两个不同的阶段
(1)等待数据准备好
(2)从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达,当所等待分组到达时,它被复制到内核中的某个缓冲区,第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
阻塞式I/O模型
最流行的I/O模式是阻塞式I/O模型。在上图中,进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发生错误时才返回。进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。
对于许多应用来说,传统的阻塞式I/O模型已经足够了,但这不代表所有的应用都能得到满足。特别有些应用需要处理以下某项任务,或者两者都需要兼顾
- 如果可能的话,以非阻塞的方式来检查文件描述符上是否有进行I/O操作,也就是非阻塞I/O模型
- 同时检查多个文件描述符,看它们中的任何一个是否可以执行I/O操作,也就是I/O复用模型
非阻塞式I/O模型
进程把一个套接字设置成非阻塞是在通知内核:当所请求的I/O操作如果不需要将本进程置于休眠状态才能完成时,不要把本进程置于休眠状态,而是返回一个错误。
前三次调用recvfrom时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误,第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回,我们接着处理数据。
当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(poling),应用进程持续轮询内核,以查看某个操作是否就绪。这样做往往耗费大量CPU时间。
如果不希望进程在对文件描述符执行I/O时被阻塞,我们可以创建一个新的进程来执行I/O,此时父进程就可以去处理其他的任务了,而子进程将阻塞直到I/O操作完成。如果我们需要处理多个文件描述符上的I/O,此时可以为每个文件描述符创建一个子进程,这种方法的问题在于开销昂贵且复杂。创建及维护进程对系统来说都是开销,而且一般来说子进程需要使用某个IPC进制来通知父进程有关I/O操作的状态。
使用多线程而不是多进程,这将占用较少的资源,但线程之间仍然需要通信,以告知其他线程有关I/O操作的状态,这使得编程工作变得复杂,尤其是如果我们使用线程池技术来最小化处理大量并发客户的线程数量时(多线程特别有用的一个地方是如果应用程序需要调用一个会执行阻塞式I/O操作的第三方库时,那么可以通过在分离的线程中调用这个库从而避免应用被阻塞)。
由于非阻塞式I/O和多进(线)程都有各自的局限性,下列备选方案往往更可取。
- I/O多路复用允许进程同时检查多个文件描述符以找出它们中的任何一个是否可执行I/O操作,系统调用select()和poll来执行I/O多路复用
- 信用驱动I/O是指当有输入或者数据可以写到指定的文件描述符上时,内核向请求数据的进程发送一个信号。进程可以处理其他的任务,当I/O操作可执行时通过接收信号来获得通知。当同时检查大量的文件描述符时,信号驱动I/O相比select()和poll()有显著的性能提升
- epoll API是Linux专用的特性,首次出现是在Linux 2.6版中,同I/O多路复用API一样,epoll允许进程同时检查多个文件描述符,看其中任意一个是否能执行I/O操作。同信号驱动I/O一样,当同时检查大量文件描述符时,epoll能提供更好的性能。
实际上I/O多路复用,信号驱动I/O以及epoll都是用来实现同一个目标的技术—-同时检查多个文件描述符,看它们是否准备好了执行I/O操作(准备地说,是看I/O系统调用是否可以非阻塞地执行)。
I/O复用模型
有了I/O复用,我们就可以调用select或poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上。
我们阻塞于select调用,等待数据报套接字变为刻可读,当select返回套接字可读这一条件时,我们调用recvfrom把所读数据报复制到应用进程缓冲区。
select和poll()存在的问题:
系统调用select()和poll()是用来检查多个文件描述符就绪状态的方法,它们是可移植的,长期存在且被广泛使用的。但是当检查大量的文件描述符时,这两个API都会遇到一些问题。
- 每次调用select()和poll(),内核都必须检查所有被指定的文件描述符,看它们是否处于就绪态。当检查大量处于密集范围内的文件描述符时,该操作耗费的时间将大大超过接下来的操作。
- 每次调用select()和poll(),程序都必须传递一个表示所有需要被检查的文件描述符的数据结构到内核,内核检查过描述符后,修改这个数据结构并返回给程序(此外,对于select来说,我们还必须在每次调用前初始化这个数据结构)。对于poll()来说,随着待检查的文件描述符数量的增加,传递给内核的数据结构大小也会随之增加。当检查大量文件描述符时,从用户空间到内核空间拷贝这个数据结构将占用大量的CPU时间,对于select来说,这个数据结构的大小固定为FD_SETSIZE,与待检查的文件描述符数量无关。
- select()或poll()调用完成后,程序必须检查返回的数据结构中的每个元素,以此查明哪个文件描述符处于就绪态了。
select()和poll()糟糕的性能延展性源自于这些API的局限性,通常,程序重复调用这些系统调用所检查的文件描述符集合都是相同的,可是内核并不会在每次调用成功后就记录下它们。
信用驱动式I/O以及epoll都可以使内核记录下进程感兴趣的文件描述符,通过这种机制消除了select()和poll()的性能延展问题。这种解决方案可根据发生的I/O事件来延展,而与被检查的文件描述符个数无关。结果就是,当需要检查大量的文件描述符时,信号驱动I/O和epoll能提供更好的性能表示。
信号驱动式I/O
我们也可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。我们称这种模式为信号驱动式I/O。
我们首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数,该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号,我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。
无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞,主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
信号驱动式I/O VS I/O复用模型:
在同时需要检查大量文件描述符(比如数千个)的应用程序中—-例如某种类型的网络服务端程序—同select()和poll相比,信号驱动I/O能提供显著的性能优势。信号驱动I/O能达到这么高的性能是因为内核可以“记住”要检查的文件描述符,且仅当I/O事件实际发生在这些文件描述符上时才会向程序发送信号。结果就是采用信号驱动I/O的程序性能可以根据发生的I/O事件的数量来扩展,而与被检查的的文件描述符的数量无关。
异步I/O模型
我们调用aio_read函数给内核传递描述符,缓冲区指针,缓冲区大小和文件偏移,并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待I/O完成期间,我们的进程不被阻塞。
异步I/O模型与信号驱动模型的区别在于:信号驱动式I/O是由内核通知我们何时启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。
各种I/O模型的比较
我们可以看到,前面4种I/O模型的主要区别在第一阶段,因为它们的第二阶段是一样的:在数据从内核复制到调用者的缓冲区期间,进程阻塞与recvfrom调用。相反,异步I/O模型在这两个阶段都要处理,从而不同于4种模型。
同步I/O与异步I/O对比
POSIX把这两个术语定义如下:
- 同步I/O操作导致请求进程阻塞,直到I/O操作完成。
- 异步I/O操作不导致请求进程阻塞。
根据上面的定义,前面4种模型–阻塞式I/O模型,非阻塞式I/O模型,I/O复用模型和信号驱动式I/O模型都是同步I/O模型,因为其中真正的I/O操作(recvfrom)将阻塞进程。只有异步I/O模型与POSIX定义的异步I/O匹配。
阻塞和非阻塞是从函数调用角度来说的,而同步和异步是从“读写是谁完成的”角度来说的。
- 阻塞:如果读写没有就绪或者读写没有完成,则该函数一直等待。
- 非阻塞:函数立即返回,然后让应用程序轮询。
- 同步:读写由应用程序完成。
- 异步:读写由操作系统完成,完成之后,回调或者事件通知应用程序。
参考资料
《UNIX 网络编程 卷1:套接字联网API》
《Linux/UNIX 系统编程手册》