Skip to content

I/O

在计算机中I/O是 input/output 的缩写。他表示计算机(狭义上就是内存)和外部设置(狭义上就是磁盘、网络)之间进行通信。 input 就是计算机接受数据,output 就是计算机输出数据。

Tips

特定设备的输入或输出取决于视角,以磁盘为主视角,他的输出也就是计算机的输入。不过我们通常是以计算机为主体

Tips

在计算机的世界里,IO 的本质就是计算机与其它设备之间数据转移的过程

操作系统进程空间

操作系统的进程空间分为用户空间(User Space)内核空间(Kernel Sapce)。大多数系统交互操作(经典的就是 I/O 操作)都需要在内核空间中运行,而我们应用程序可以直接控制的只有用户空间。而一些命令会在两个空间之间切换。这些命令也被称为系统调用(System Call)

Tips

之所以要划分不同的空间,主要就是为了安全。

无论是用户空间还是内核空间他们都是位于虚拟内存(操作系统的概念和物理内存是相对应的)上的。每当启用一个新进程,操作系统会为该进程分配一个独立的、连续的虚拟内存地址空间(他们在物理上可能并不连续),例如 32 位操作系统一般会分配 4G 虚拟内存(由 32 位寻址位宽决定的)。

C
str = "i am qige" // 用户空间
x = x + 2
file.write(str) // 切换到内核空间
y = x + 4 // 切换回用户空间

top 命令中能够展示出不同空间的 CPU 执行时间以及虚拟内存的使用:

Bash
top - 16:44:40 up 13 days,  2:36,  5 users,  load average: 0.20, 0.14, 0.10
Tasks: 364 total,   1 running, 363 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.6 us,  0.2 sy,  0.0 ni, 99.1 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem : 128752.0 total,  19509.6 free,   6610.7 used, 103868.4 buff/cache
MiB Swap:   4768.0 total,    797.0 free,   3971.0 used. 122141.3 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 649296 yangguo+  20   0 6617356 889896  25940 S   9.6   0.7 125:50.49 baidunetdisk
 646527 yangguo+  20   0 5741532 456432  64848 S   2.0   0.3  64:49.81 gnome-shell
2622542 yangguo+  20   0 5579892  67240   8304 S   1.7   0.1   7:28.11 Lingma
2622011 yangguo+  20   0 1149072  17016  11344 S   0.7   0.0   0:36.44 node
    873 root     -51   0       0      0      0 S   0.3   0.0  56:35.77 irq/172-nvidia
 649249 yangguo+  20   0  658860  94912  30124 S   0.3   0.1  14:40.06 baidunetdisk
2667827 yangguo+  20   0   15.3g  86532  80544 S   0.3   0.1   0:15.66 gnome-remote-de

其中 Cpu 部分:

  • 0.6 us: 用户空间 CPU 占比
  • 0.2 sy: 系统空间 CPU 占比
  • 99.1 id: 空间 CPU 占比
  • 0.0 wa: 等待 IO 的 CPU 占比

而 Mem 部分:

  • xxx total: 物理内存总量
  • xxx free: 空闲内存总量
  • xxx userd: 使用内存总量
  • xxx buff/cache: 用作内核缓存的内存量

下面的进程中,VIRT 就是表示虚拟内存,RES 可以通俗认为是物理内存占比。

IO 设备和内存之间的数据传输: DMA

一般我们的数据是存储在磁盘上的,应用程序想要读写这些数据肯定就需要加载到内存中,目前主流的是通过 DMA 控制器来实现的:

DMA

  1. 用户进程通过 read 等系统调用接口向操作系统发出 IO 请求,请求读取数据到自己的用户内存缓冲区中,然后该进程进入阻塞状态
  2. 操作系统收到用户进程的请求后,进一步将 IO 请求发送给 DMA 控制器,然后 CPU 就可以去干别的事了
  3. DMA 控制器将 IO 请求转发给磁盘
  4. 磁盘驱动器收到内核的 IO 请求后,把数据读取到自己的缓冲区中,当磁盘的缓冲区被读满后,向 DMA 控制器发起中断信号告知自己缓冲区已满
  5. DMA 收到磁盘驱动器的信号,将磁盘缓冲区中的数据 copy 到内核缓冲区中,此时不占用 CPU(在没有 DMA 控制器的 PIO 模式下这一步需要 CPU 来完成)
  6. 如果内核缓冲区的数据少于用户申请读的数据,则重复步骤3、4、5,直到内核缓冲区的数据符合用户的要求为止
  7. 内核缓冲区的数据已经符合用户的要求,DMA 停止向磁盘发 IO 请求
  8. DMA 发送中断信号给 CPU
  9. CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核空间 copy 到用户空间,系统调用返回
  10. 用户进程读取到数据后继续执行原来的任务

Note

比较特殊的就是整个过程需要涉及多次 copy 操作

Tips

DMA 控制器让 CPU 不在负责繁重的 IO 操作,并且通过 DMA 中断来实现真正的异步 IO 操作。

read/write/sync

IO 操作的核心就是读写:

  • 读操作(read): 操作系统检查内核缓冲区有没有需要的数据,如果内核缓冲区已经有需要的数据了(buf/cache),那么就直接把内核空间的数据 copy 到用户空间,供用户的应用程序使用。如果内核缓冲区没有需要的数据,对于磁盘 IO,直接从磁盘中读取到内核缓冲区(DMA 控制器完成,不需要 CPU 参与)。而对于网络IO,应用程序需要等待客户端发送数据,如果客户端还没有发送数据,对应的应用程序将会被阻塞,直到客户端发送了数据,该应用程序才会被唤醒,从 Socket 协议找中读取客户端发送的数据到内核空间,然后把内核空间的数据 copy 到用户空间,供应用程序使用
  • 写操作(write): 用户的应用程序将数据从用户空间 copy 到内核空间的缓冲区中,这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘或通过网络发送出去,由操作系统决定。除非应用程序显示地调用了sync 命令,立即把数据写入磁盘,或执行 flush 命令,通过网络把数据发送出去

Note

无论是 read 操作还是 write 操作,真正执行都必须位于内核空间。用户应用程序不能直接操作内核空间,需要将数据从内核缓冲区拷贝到用户缓冲区才能够使用。

同步和异步、阻塞和非阻塞

在大多数文章中,对同步异步、阻塞和非阻塞的解释如下:

  • 同步/异步关注的是消息通信机制: 所谓同步,就是在发出一个调用时,在没有得到结果之前, 该调用就不返回。异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果
  • 阻塞/非阻塞关注的是程序在等待调用结果时的状态: 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程

这个解释将同步异步和阻塞非阻塞当作两个维度的概念来分别对待了,他们由一个 bug:

  • 如果“同步”是发起了一个调用后, 没有得到结果之前不返回,那它毫无疑问就是被“阻塞”了(即调用进程处于 “waiting” 状态)
  • 如果“异步”调用发出了以后就直接返回了, 毫无疑问,这个进程没有被“阻塞”

所以按照上面的解释,同步一定是阻塞的,而异步一定是非阻塞的,而实际上在进程通信中,这两个本身就是同义词。进程间的通信是通过 send()receive() 两种基本操作完成的:

  • 阻塞式发送(blocking send): 发送方进程会被一直阻塞, 直到消息被接受方进程收到
  • 非阻塞式发送(nonblocking send): 发送方进程调用 send() 后, 立即就可以执行其他操作
  • 阻塞式接收(blocking receive): 接收方调用 receive() 后一直阻塞, 直到消息到达可用
  • 非阻塞式接收(nonblocking receive): 接收方调用 receive() 函数后,要么得到一个有效的结果,要么得到一个空值,即不会被阻塞必有一个返回值

Note

上述不同类型的发送和接收方式可以随意组合,他们也可以被称为同步发送、异步发送这样的名称,他们在这个维度而言是同义词。

但是在一个进程的维度考虑,阻塞和非阻塞、同步和异步不太一样了,一个进程具有不同的状态:

  1. New: 进程正在被创建
  2. Running: 进程的指令正在被执行
  3. Waiting: 进程正在等待一些事件的发生(例如 I/O 的完成),这就是被阻塞了
  4. Ready: 进程在等待被操作系统调度(分时复用系统)
  5. Terminated: 进程执行完毕(也可能是被强行终止的)

在一个进程中,执行了阻塞任务他就处于 Waiting 状态,此时为了避免浪费 CPU 资源,该进程就被挂起(类似就是应用程序没有响应了),直到阻塞的调用被返回才能重新获取 CPU 的使用权。

而实际上我们操作系统对 IO 的操作通常都是非阻塞的,例如用户程序请求资源,CPU 会像 DMA 发请求获取资源之后就去干其他事情了(因为不只用户程序这一个进程需要 CPU 参与),直到 DMA 获取数据并发起中断 CPU 才会重新响应将结果传给用户程序所在的用户空间,这个过程对于 CPU 而言就是非阻塞的,而对于用户程序而言就是阻塞的

由于这种阻塞的同步代码比较容易理解(代码的执行顺序、编写顺序都是一致的),因此大多数编程语言都提供了阻塞式程序调用。而实际上也存在两种非阻塞的调用接口:

  • 非阻塞 I/O 系统调用: 在该调用下 read() 操作立即返回的是任何可以立即拿到的数据,可以是完整的结果,也可以是不完整的结果,还可以是一个空值。对应到编程语言就是我们所说的流,但是依然是同步编程
  • 异步 I/O 系统调用: 在该调用下 read() 结果必须是完整的,但是这个操作完成的通知可以延迟到将来的一个时间点,对应到编程语言就是异步编程

Tips

非阻塞 I/O 系统调用(流)能够用更小的内存消耗来完成操作,但是对于多 I/O 操作就只能通过多进程来完成(阻塞 I/O 也需要多进程才能够实现真正的并发),而异步 I/O 系统调用能够实现线程级别的并发(不过需要更多的内存),这才是同步和异步编程的核心区别。

参考