网络I/O中的同步、异步、阻塞和非阻塞概念

时间:2021-03-28 16:44:38

  在学习网络编程过程中,经常会把这几个概念搞混淆。

同步I/O与异步I/O区别

  我们先来看一下操作I/O时涉及的对象和步骤(这里我们以read为例):  

  这里会涉及到两个系统对象,一个是调用这个I/O的应用进程(或线程),另一个就是系统内核。当一个read操作发生时,它会经历两个阶段:1)等待数据就绪 (可读);2)将数据从内核拷贝到应用进程中 。这两个阶段很重要,因为各种I/O模型的区别就是在这两个阶段上各有不同的情况。

  下边我们来看一下同步I/O及异步I/O。

  根据UNP一书给出的定义(POSIX):

  • 同步I/O操作(synchronous I/O operation)导致请求进程阻塞,直到I/O操作完成。UNP第6章中提到的I/O模型——阻塞式I/O模型、非阻塞式I/O模型、I/O复用模型和信号驱动式I/O模型都是同步I/O模型,因为其中真正的I/O操作将阻塞进程。
  • 异步I/O操作(asynchronous I/O operation)不导致请求进程阻塞。

  可以看出,同步I/O和异步I/O的核心区别在于真正的I/O操作会不会阻塞进程。具体来说,同步I/O需要进程真正地去操作I/O,而异步I/O则由内核在I/O操作完成后再通知应用进程结果。

  我们来看一下UNP一书给出的对5种I/O模型的比较表就可能更清楚了:

  网络I/O中的同步、异步、阻塞和非阻塞概念

  由上图,我们可以知道,除了异步I/O模型,其他模型都会实际阻塞于真正的I/O操作(如recvfrom,这里真正的I/O操作不包括图中的‘检查’)。上图也说明了非阻塞式I/O虽然在检查阶段不会阻塞,但在文件描述符就绪(如可读)的时候是会阻塞的,这是它区别于异步I/O很重要的一点。

  对于同步I/O操作,一个典型的例子就是libevent网络库。而对于异步I/O操作,比较有名的例子就是Boost库的ASIO库。ACE库则包括了同步I/O及异步I/O两种方式。

同步I/O

  上边我们知道同步I/O操作包括了阻塞式I/O模型、非阻塞式I/O模型、I/O复用模型和信号驱动式I/O。

阻塞式I/O模型

  在Linux中,默认情况下所有的套接字都是阻塞的。以数据报套接字为例,一个典型的读操作流程大概是这样:

  网络I/O中的同步、异步、阻塞和非阻塞概念

  进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发生错误才返回。最常见的错误是系统调用被信号中断。我们说进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。

非阻塞式I/O模型

  进程把一个套接字设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。如下例:

  网络I/O中的同步、异步、阻塞和非阻塞概念

  前三次调用recvfrom时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回。我们接着处理数据。

  当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(polling)。应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间。

I/O复用模型

  关于I/O复用,知乎上有比较透彻的一个解释:“关于I/O多路复用(又被称为“事件驱动”),首先要理解的是,操作系统为你提供了一个功能,当你的某个socket可读或者可写的时候,它可以给你一个通知。这样当配合非阻塞的socket使用时,只有当系统通知我哪个描述符可读了,我才去执行read操作,可以保证每次read都能读到有效数据而不做纯返回-1和EAGAIN的无用功。写操作类似。操作系统的这个功能通过select/poll/epoll/kqueue之类的系统调用函数来使用,这些函数都可以同时监视多个描述符的读写就绪状况,这样,多个描述符的I/O操作都能在一个线程内并发交替地顺序完成,这就叫I/O多路复用,这里的“复用”指的是复用同一个线程。”

  有了I/O复用(I/O multiplexing),我们就可以调用select或poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上。下图概括展示了I/O复用模型:

  网络I/O中的同步、异步、阻塞和非阻塞概念

  我们阻塞于select调用,等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所读数据报复制到应用进程缓冲区。

  比较图6-3和图6-1,I/O复用并不显得有什么优势,事实上由于使用select需要两个而不是单个系统调用,I/O复用还稍有劣势。不过select的优势在于可以等待多个描述符就绪(与此相对应的方法是多线程+阻塞式I/O,即由每一个线程来调用阻塞式I/O系统调用)。

信号驱动式I/O模型

  我们也可以用信号,让内核在描述符就绪时发送SIGIO信号给我们。这种模型称为信号驱动式I/O(signal-driven I/O)。下图是其概要展示:

  网络I/O中的同步、异步、阻塞和非阻塞概念

  我们首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作, 也就是说它没有被阻塞。当数据报准备好读写时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。

  无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

异步I/O

  异步I/O(asynchronous I/O)由POSIX规范定义。一般来说,用于实现异步I/O的函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前面介绍的信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成下图给出了一个例子:

  网络I/O中的同步、异步、阻塞和非阻塞概念

  我们调用aio_read函数(POSIX异步I/O函数以aio_或lio_开头),给内核传递描述符、缓冲区指针、缓冲区大小(与read相同的三个参数)和文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待I/O完成期间,我们的进程不被阻塞。本例子中我们假设要求内核在操作完成时产生某个信号。该信号直到数据已复制到应用进程缓冲区时才发生,这一点不同于信号驱动式I/O模型。

参考资料

  UNP