【Operating System】I/O - 同步、异步与阻塞、非阻塞I/O问题

Posted by 西维蜀黍 on 2018-11-06, Last Modified on 2022-12-10

软硬件层面的同步/异步

硬件

在现代操作系统中,I/O是一种与外围设备(peripherals)进行数据交换的方式,I/O包括读或写数据到磁盘/SSD中,或通过网络发送/接收数据,显示信息到显示器上,接收鼠标或键盘的输入。

因此,我们通常说的I/O,不仅仅只限于磁盘文件的读写。*nix将计算机抽象了一番,磁盘文件、硬件、套接字等几乎所有计算机资源都被抽象为了文件。

实现硬件层面异步的机制称为硬件中断(hardware interrupt)

在典型的硬件**同步(synchronous)**场景中,当CPU请求外围设备读取数据时,CPU会进入一个无限循环(infinite loop),在循环中CPU需要不断去检查外围设备是否已经将数据读取到了,这个过程称为轮询(poll)。

而在现代硬件中,当CPU向外围设备发送I/O请求后,就会立刻去执行其他CPU指令(而不是不断的轮询)。当外围设备将数据准备完成后,它会通过电路中断(circuit interrupt)向CPU发送一个信号(signal)。这就是典型的**硬件异步(asynchronous)**场景。因此,CPU就不再需要静静的等待且不断轮询直到外围设备将数据准备完成,因而大大的提高了CPU的利用率。

软件

操作系统作为一层中间件,抽象了硬件设备,向应用程序以系统调用(System Call)的形式提供I/O操作服务。

类似地,当应用程序(通过调用系统调用)发起一个I/O操作后,操作系统会调用对应的设备驱动(device driver)以操作硬件进行这个I/O操作。

对于这个应用程序来说,如果此时需要静静的等待(或者不断的轮询),直到数据被准备完成后,这就是软件层面的同步,这样的编程方式称之为同步编程(synchronous programming)

如果操作系统允许应用程序声明一个回调函数(callback),且当I/O操作完成后,操作系统会自动调用这个回调函数,这就是软件层面的异步,这样的编程方式称为异步编程(asynchronous programming)

设备管理以实现I/O操作

硬件设备向计算机提供输入和输出数据的能力,在这个过程中:

  • 应用程序调用操作系统提供的系统调用API(System Call API)来让操作系统进行I/O操作
  • 操作系统请求设备驱动提供的API来完成I/O操作
  • 不同的硬件设备对应不同的设备驱动,这些设备驱动知道如何让特定的硬件设备完成特定的I/O操作

I/O模型

阻塞I/O模型(Blocking I/O Model)

操作系统内核对于I/O只有两种方式:阻塞与非阻塞。在调用阻塞I/O时,用户线程需要等待 I/O操作完全完成(等待数据被拷贝到内核空间的缓冲区(buffer),数据从内核缓冲区被拷贝到用户线程对应的用户空间缓冲区)后,才能得到结果(内核返回数据给用户线程),如下图所示:

具体来说:

  • 系统调用:用户线程调用read()这个系统调用(System Call);
  • 操作系统内核读取数据:无论对于网络I/O还是磁盘I/O,外部设备需要一定的时间读取该数据(对于网络I/O,就是等待远端数据完成被传输到本地;对于磁盘I/O,就是等待数据从磁盘上被拷贝到内核中)。并且,在外部设备读取完毕后,操作系统内核(kernal)需要一定的时间将该数据读取到操作系统内核的缓冲区。在这个过程中,用户线程会被阻塞(此后,操作系统会将该进程置于休眠状态(sleep));
  • 复制到用户内存并返回数据:出于系统安全考虑,用户态的程序是没有权限直接读取内核态内存(内核的缓冲区)的。因此,当操作系统内核已经将数据拷贝到其内核缓冲区(Buffer)后,内核会负责将数据拷贝到用户进程缓冲区(属于用户进程的特定内存区)。此后,这个用户线程会被操作系统从休眠状态置为唤醒状态(wake)。

注意,这里仅仅以读取(对应read()系统调用)为例,而除此之外,还可以进行write()connect()open()等其他的系统调用(这些系统调用都是等价的(equivalent))。在下文中,也均以read()为例。

阻塞I/O模型与同步编程

阻塞I/O的一个特点是一定要等到系统内核层面完成整个I/O操作后(以已经将数据读取到用户内存中后作为完成的标志),调用才结束(体现为内核将数据和程序的控制流返回给用户线程)。这时,这个用户线程对应的线程从阻塞状态(Blocked)转化为活跃状态。

以这样的思维方式进行的编程,通常也成为同步编程(Synchronous Programming)

阻塞I/O模型的不足

阻塞I/O造成CPU等待I/O,导致CPU在一段时间内等待,最终CPU的处理能力没有得到充分利用。

在基于用户图形界面的场景中(无论是基于浏览器,还是基于传统桌面应用),处理用户操作的线程与主线程通常是同一个线程。此时,若主线程进行一个同步的长耗时I/O操作(比如读取一个文件、进行网络传输),图形界面将一直保持无响应的情况(此时自然其也无法处理用户与界面间的交互),直到这个I/O操作结束。

我们通常用以下两种方式中任一种来解决这个问题:

  • 使用多线程:使用一个新的线程(非主线程)来进行I/O操作,这样就不会因进行I/O操作造成的等待而导致图形界面出现无响应的情况。当然,虽热因为主线程没有被I/O操作所阻塞。因此,用户可以与图形界面进行正常的交互。但是,这时CPU的处理能力仍然没有得到充分利用(CPU需要将被阻塞的线程进行上下文切换);
  • 采用异步编程(Asynchronous Programming):异步编程必须依赖于操作系统提供的异步I/O系统调用,或者由软件中间件抽象后提供的异步API(比如Node.js)。当I/O操作真正完成时(已经将期望的数据复制到内存用户态中),内核通过回调函数(callback)或信号量的方式通知应用程序。

非阻塞I/O模型(Nonblocking I/O Model)

为了提高性能, 内核提供了非阻塞I/O。如图所示:

具体来分析:

  • 系统调用:用户线程设置此I/O为非阻塞操作,并调用read()这个系统调用(System Call)。
  • 操作系统内核立刻返回结果:在这个过程中,用户线程不会被阻塞(因此,该线程也不会被置为休眠状态)。当用户线程进行系统调用后,内核会立即返回一个EWOULDBLOCK错误码,以标识I/O数据并未准备完毕。
  • 用户线程不断发起系统调用:由于当I/O数据准备完毕后,内核不会主动通知用户线程。
    • 因此,用户线程需要以一定的频率不断的发起read()系统调用,以询问该数据是否准备完毕(准备完毕的标志为数据已经被拷贝到内核缓冲区)。这个过程称为轮询(poll)
    • 在数据准备完毕前,用户线程在每次进行系统调用时,内核都会返回一个EWOULDBLOCK错误码,以标识I/O数据并未准备完毕。
    • 在整个轮询过程中,虽然用户线程每次发起系统调用后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,因此轮询操作会消耗了大量的 CPU 的资源。
  • 复制到用户内存并返回数据:当操作系统内核已经将数据拷贝到其内核缓冲区(Buffer)后,用户线程再发起read()这个系统调用(System Call)时,内核会将数据内核缓冲区拷贝到用户内存,并将数据返回给用户线程

非阻塞I/O模型托与阻塞I/O模型的区别

非阻塞I/O跟阻塞I/O的本质差别在于,当用户线程发起一个系统调用(System Call)后,控制流是否会被系统内核立即返回。若会被立即返回,则为非阻塞I/O。

具体来说,在非阻塞I/O中,在用户线程进行系统调用(System Call)后,控制流会被系统内核立即返回(此时数据并没有准备完成,因此仅仅返回控制流,而不包括期望的I/O数据)。而在阻塞I/O中,只有当数据准备完成(体现为数据已经被拷贝到用户内存)后,系统内核才会返回控制流(并附带对应的I/O数据)。

需要特别注意的是,非阻塞模型也可以进一步细分为同步模式和异步模式。由于完整的I/O并没有完成,立即返回的并不是用户线程期望的数据,而仅仅是当前调用的状态。因此,在同步非阻塞模型,为了获取完整的数据,应用程序需要重复调用系统调用以确认I/O操作是否已经完成。这种重复调用判断操作是否完成的技术叫做轮询(poll);而对于异步非阻塞模型,当I/O操作完成后,内核会通过向用户线程发送信号的方式唤醒这个线程且通知I/O操作完成,因此此时不需要进行轮询。在一些资料中,通常默认非阻塞模式是同步的,而事实上非阻塞模型也可以用异步的方式进行,此时就不再需要轮询了。

另外,现代操作系统对计算机进行了抽象,将每一次I/O操作和所有输入输出设备抽象为文件。因此, 内核在进行I/O 操作时,是通过**文件描述符(File Descriptor)**进行管理的,一个I/O操作对应于一个文件描述符,文件描述符类似于应用程序与系统内核之间的凭证。应用程序如果需要进行I/O调用,需要先打开文件描述符,然后再根据文件描述符去实现I/O操作的数据读写。此处,非阻塞I/O与阻塞I/O的区别在于阻塞I/O会完成整个获取数据的过程(以将数据复制到用户空间作为完成标志),而非阻塞I/O则不带数据返回(控制流返回时,只意味着数据已经被复制到内核空间)。要获取数据,还需要通过文件描述符进行读取(目的是将数据从内核态拷贝到用户态)

任意技术都并非完美的。阻塞I/O造成CPU等待浪费,非阻塞(对于同步非阻塞模式而言)可能带来需要不断轮询去确认是否完全完成数据获取的麻烦。粗暴的轮询操作,可能会浪费CPU资源。

I/O多路复用模型 (I/O Multiplexing Model)

I/O多路复用模型是非阻塞I/O模型的延伸,因为I/O多路复用模型允许用户线程可以阻塞地同时检测多个文件描述符对应的I/O操作(每个文件描述符对应一个I/O操作)。

当这些文件描述符对应的I/O操作中任何一个(或多个同时)数据准备完成(数据已经被拷贝至内核空间)时,内核或主动通知用户线程并返回控制流。

Linux提供了三种方式来实现I/O多路复用模型:selectpollepoll方法。关于这几种I/O多路复用模型的区别,在【Linux】Linux 中的 I/O 轮询技术 中进行了详细的讨论。

我们以select为例,I/O多路复用模型如下图所示:

具体来分析:

  • 调用read()系统调用:用户线程以非阻塞的方式发起一个I/O操作。注意,这里read()系统调用可以被非阻塞的调用多次以触发多个I/O操作。
  • 调用select()系统调用:用户线程调用select()系统调用。当多个文件描述符中一个或多个数据准备完成(数据已经拷贝到内核空间)后,系统调用返回控制流给用户线程(并标识数据已可读)。注意,在此过程中,用户线程是被阻塞的。
  • 调用read()系统调用:调用read()系统调用以拷贝数据到用户空间。同样,在此过程中,用户线程也是被阻塞的。

之所以说”I/O多路复用模型是非阻塞I/O模型的延伸“,是因为在传统的非阻塞I/O模型中,一次read()系统调用只能探测到一个I/O操作的数据是否已经准备完成了。在这个过程中,虽然不断调用read()的过程是非阻塞的,但是当数据准备完成时(数据已经被拷贝至内核空间),再次调用read()以将数据从内核空间拷贝至用户空间的过程是阻塞的。下图描述了这个过程:

而前面也提到了,I/O多路复用模型允许用户线程可以阻塞地同时等待多个文件描述符对应的I/O操作。事实上,Linux中的selectpollepoll三种轮询实现对应的轮询系统调用,都会阻塞用户线程,若下图所示:

因此,将I/O多路复用模型与最朴素的非阻塞I/O模型进行对比,从底层实现来说,前者已经将轮询操作从由用户线程负责转移到了由内核负责。

注意,selectpollepoll均只是不同的轮询技术的实现。因此,在轮询之前,用户线程已经通过非阻塞的系统调用发起了I/O操作。而selectpollepoll本身既不包括非阻塞地发起I/O操作这个过程,也不包括数据已经准备完成后,用户线程发起read()去阻塞的去读取数据(将数据从内核空间拷贝至用户空间)的过程。

阻塞I/O模型与I/O多路复用模型

将阻塞I/O模型与I/O多路复用模型进行比对,我们发现:

表面看来,使用I/O多路复用模型没有得到任何好处(在分别将数据读取至内核空间和从内核空间拷贝至用户空间的两个阶段中,用户线程都是被阻塞的)。而且更糟的是,对于一次I/O操作,I/O多路复用模型还需要发起两次系统调用(select()read()),而阻塞I/O模型只需要发起一次(read())。

而实际上,使用I/O多路复用的好处在于我们可以在一个线程内同时处理多个I/O请求(通过等待多个文件描述符)。当任何一个或多个文件描述符的I/O操作完成时,用户线程被内核唤醒。

多线程调用阻塞I/O模型(Multithreading with blocking I/O Model)

另外,看起来,使用多线程调用阻塞I/O与I/O多路复用很类似。即,在这两个模型中,从“用户线程发起系统调用到数据被准备完成并被复制到内核空间”和“数据从内核空间被复制到用户空间”的这两个过程中,用户线程均是被阻塞的。

而且对于I/O多路复用,在轮询结束后,每一次read()调用都只能执行一个I/O操作,因此调用select()后需要多次调用read()。而多线程调用阻塞I/O使用多线程(一个文件描述符对应一个线程)来同时调用阻塞I/O,则当任何一个线程完成了select()后,可以立即调用对应的read()。因此,多个文件描述符对应的read()调用可以被同时(concurrently)进行。

看起来I/O多路复用似乎一无是处。而事实上,在多线程调用阻塞I/O时,CPU的利用率不高,因为CPU需要进行大量的线程上下文切换(线程被I/O阻塞后进行休眠状态,当I/O完成后,该线程从休眠变为活跃状态)。

信号驱动 I/0 模型(Signal-Driven I/O Model)

  • 调用sigactI/On()系统调用:用户线程开启信号驱动 I/0,并调用sigactI/On()系统调用来初始化信号处理程序(Signal Handler)
  • 系统调用立刻返回:当调用sigactI/On()系统调用后,该系统调用会被内核立刻返回,此后用户线程不会被阻塞
  • 获得SIGI/O信号:当数据已经准备完成(已被拷贝到内核空间后),内核会向用户线程中的信号处理程序发送一个SIGI/O信号
  • 调用read()系统调用:在用户线程获得SIGI/O信号后,可以调用read()系统调用以获得数据

信号驱动 I/0 模型的优点在于用户线程不需要阻塞地等待内核拷贝数据至内核空间,而是当拷贝完成后,用户线程会收到拷贝完成的通知。

异步 I/O 模型(Asynchronous I/O Model)

异步 I/O(Asynchronous I/O) 即aio,也可称为POSIX aio。

在《Unix网络编程》一书中对同步I/O和异步I/O的定义是这样的:An asynchronous I/O operatI/On does not cause the requesting process to be blocked.

即,请求 I/O 操作的进程不会被异步 I/O 操作阻塞。因此,异步 I/O 一定是非阻塞的。异步I/O通常与事件通知(Event NotificatI/On)关联。

过程:

  • 发起系统调用:用户线程发起aio_read()(POSIX的异步I/O函数名称总是以aio_iio_开头)系统调用后,内核会立即返回控制流给用户线程
  • 通知用户线程:当数据准备完成,并已经从内核空间拷贝至用户空间后,内核会通过发送信号(signal)的方式主动通知用户线程

只有Windows 的 IOCP才是异步 I/O,因为只需要在调用 WSARecv 或 WSASend 方法读写数据的时候把用户空间的内存 buffer 提交给 kernel,kernel 负责数据在用户空间和内核空间拷贝,完成之后就会通知用户进程,整个过程不需要用户进程参与,所以是真正的异步 I/O。

信号驱动 I/0 模型与异步I/O模型的区别

信号驱动 I/0 模型与异步I/O模型的区别在于,前者在当数据被拷贝到内核空间后就通知用户线程(此后还要经历将数据拷贝至用户空间的过程),而后者会在数据被拷贝至用户空间后才通知用户线程(此时,数据对用户程序而言,已经可用)。因此,对后者来说,在数据从内核空间拷贝至用户空间的过程,用户线程是被阻塞的;而对前者来说,用户线程在整个I/O操作过程中,没有发生任何阻塞。因此,异步I/O可以提高吞吐量、缩短响应时间。

总结

比较以上五种模型(阻塞I/O模型、非阻塞I/O模型、I/O多路复用模型、信号驱动 I/0 模型和异步I/O模型)。只有在异步I/O模型中,在数据从内核空间拷贝至用户空间的过程,用户线程是不被阻塞的(而在前四种模型中,都是被阻塞的)。

再次强调,非阻塞I/O跟阻塞I/O的本质差别在于,当用户线程发起系统调用(System Call)后,控制流是否会被系统内核立即返回。若会被立即返回,则为非阻塞I/O。

POSIX中的I/O调用方法

Blocking Non-blocking
Synchronous write(),read(),open(),close() write(),read(),open(),close() + poll() or select()
Asynchronous - aio_write(), aio_read()

同步I/O与异步I/O

POSIX中分别定义了这两种模式:

  • 同步I/O(Synchronous I/O):同步I/O会不同程度地阻塞用户线程。
    • 阻塞同步I/O:从用户线程调用系统调用以发起I/O操作至数据可用(被拷贝至用户空间)的整个过程中,用户线程均被阻塞;
    • 非阻塞同步I/O:从用户线程调用系统调用以发起I/O操作至数据被拷贝至内核空间过程中,用户线程不会被阻塞(此过程中会进行轮询操作);而数据从内核空间被拷贝至用户空间的过程中,用户线程会被阻塞;
  • 异步(Asynchronous I/O):异步I/O并不会阻塞用户线程,用户线程发起I/O操作后,完全可以去处理其他事务。而当而I/O操作完成时(数据已拷贝至用户空间),内核会通知用户线程(此时用户线程也不需要发起轮询操作)。

总结1:

  • 同步I/O可以以阻塞或非阻塞的方式进行。当以同步阻塞的方式进行时,用户线程会被一直阻塞,直到I/O操作完成;当以同步非阻塞的方式进行时,需要进行轮询操作;
  • 异步I/O只能以非阻塞的方式进行。在这种其情况下,当I/O操作完成后,内核会以发送信号的方式通知。

总结2:

  • 同步阻塞I/O模型同步非阻塞I/O模型,缺点在于用户线程的逻辑相对更复杂了(因为多了轮询的逻辑),同时这也增加了对CPU资源的耗费;而优点在于可以只用一个线程实现同时等待多个文件操作符(一旦有任何一个文件操作符对应的I/O操作完成,则立即返回控制流给用户线程)
  • 而从同步非阻塞I/O模型异步I/O模型,缺点在于需要以异步编程的思维方式思考,且客观上增加了进行异常处理的复杂性;而优点在于真正将CPU资源的耗费将至最低(因为不再需要轮询了)。

Discussion

目前按个人的理解(同时参考了几本较为权威介绍Linux编程的书籍,包括《Unix Network Programming, Volume 1: The Sockets Networking API: Sockets Networking API》和《Linux System Programming Talking Directly to the Kernel and C Library》):

  • 非阻塞I/O跟阻塞I/O的本质差别在于,当用户线程发起系统调用(System Call)后,控制流是否会被系统内核立即返回。若会被立即返回,则为非阻塞I/O(但并不意味着一定是异步I/O)。
  • 同步I/O与异步I/O的本质区别在于:当用户进行通过调用系统调用发起一个I/O后,内核会不会在这个I/O操作完成(数据拷贝至用户空间)后主动通知用户线程,且在此通知发生之前,用户线程不再需要关注且处理这个I/O操作(也不会被阻塞)。若会主动通知,则为异步I/O。

因此:

  • 所有存在轮询操作的I/O均为同步I/O,不管是selectpoll甚至是epoll
  • 非阻塞I/O并不意味着真个I/O过程中,用户线程没有发生任何阻塞。事实上,在调用select()poll()时,均会发生阻塞;而且,在轮询完成后,将数据从内存空间复制到用户空间的过程中,也同样会发生阻塞

而有的blog(如 使用异步 I/O 大大提高应用程序的性能)中认为所有的I/O多路模型(包括selectpollepoll)均属于异步I/O, 且属于异步阻塞 I/O。如下图所示:

个人不是非常赞同这种分类。因为从操作系统角度而言,在I/O多路模型中,虽然内核会将数据已经被准备完成的I/O操作对应的文件描述符放到一个”已完成“集合中。但对于应用程序而言,仍然需要进行轮询操作(而不是在I/O操作完成后,应用程序得到主动的通知,并触发相应的事件回调)。因此,个人认为I/O多路模型仍然应归属到同步模型。

然而,通过中间件通过线程池+I/O多路模型,可”模拟“出异步。Node就是一个典型的例子。即对应Node应用程序而言,完全是异步的(一个I/O操作被触发后,当数据被拷贝到用户态时,开发者声明的回调函数回被自动触发)。之所以称之为”模拟的异步“,是从其层依赖的操作系统系统调用而言的,即Node底层依赖的操作系统系统调用并不是异步的,而是线程池+I/O多路模型。

现实的异步I/O

现实比理想要骨感一些,但是要达成异步I/O的目标,并非难事。前面我们将场景限定在了单线程的状况下,多线程的方式会是另一番风景。

通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了“异步I/O”

这种“异步I/O”不能称之为严格意义上的异步I/O。因为严格意义上的异步,工作线程是不会因为I/O而被阻塞的,因此它在发起I/O请求后,仍然可以去做其他事情。

而从用户线程的角度说,这是异步I/O。然而,对应内核或者CPU而言,无论是采用阻塞还是非阻塞的方式,都没有将CPU资源利用最大化。这是相较于真正意义上的异步I/O而言,因为,前者开启了多线程,这增加了CPU资源的耗费;而后者在此基础之上,还需要进行轮询操作,更加增加了CPU资源的耗费。

glibc的aio便是典型的线程池模拟异步I/O。然而遗憾的是,它存在一些难以忍受的缺陷和 bug,不推荐采用。libev的作者Marc Alexander Lehmann重新实现了一个异步I/O的库:libeio。libeio 实质上依然是采用线程池与阻塞I/O模拟异步I/O

Reference