计算机底层6 I/O

2023年 10月 10日 33.5k 0

程序员在开发一些功能时,经常要注意的就是“交互逻辑”,这里的“交互”就是用户通过计算机界面(如键盘、鼠标)与计算机系统进行交流,以执行任务、获取信息或控制系统。或者说,交互也指计算设备的输入与输出(Input/Output, I/O)。在这篇文章中,我们将会介绍I/O 的实现原理,了解I/O 与CPU,与程序的关联,还有一些高级的I/O 技术。

1 CPU 是如何处理I/O 操作的

就像CPU 内部有寄存器,键盘,鼠标内部也有自己的寄存器:设备寄存器。

CPU 中的寄存器可以临时存储从内存读取的数据或者存储CPU 计算的中间结果,而设备寄存器中存放的则是一些和设备有关的信息,主要有两种寄存器:

  • 存放数据的寄存器:比如用户按下键盘,信息就会存放在这类寄存器中。
  • 存放控制信息以及状态信息的寄存器,通过读写这类寄存器可以对设备进行控制或者查看设备情况。
  • 所以,其实设备在底层中,也就是一堆寄存器而已,获取设备产生的数据或者对设备进行控制,都是通过读写寄存器来完成的。

    1.1 如何读写设备寄存器

    1.1.1 I/O 机器指令

    我们可以设计出特定的机器指令来专门读写设备寄存器,这类特殊的指令就是I/O 指令,在这种方案下,设备会被赋予唯一的地址,I/O 指令会指明设备的地址,这样CPU 发出I/O 指令后,硬件电路就知道应该去读写哪个设备了。

    这种方案很容易理解,但是我们从CPU 的角度想想,在上一篇文章计算机底层5 缓存cache的开头,我们介绍了冯·诺伊曼结构,我们发现,CPU 和内存是隔离开的,也就是说,内存和我们这篇文章讲的设备寄存器,其实在CPU 看来,都是“外部设备”,我们能不能像读写内存一样简单地读取设备寄存器?

    1.1.2 内存映射I/O

    我们可以这样,把内存地址空间的一部分分给内存,把另外一部分分给设备寄存器,因为CPU 只知道要从地址空间中的某个地址获取数据或者指令,至于这个地址来自谁,CPU 并不关心。

    这种把一部分地址空间分给设备,从而可以像读写内存一样操作设备的方法就是内存映射I/O。

    image.png

    所以,一共有两种I/O 实现方法:

  • 使用特定的I/O 机器指令
  • 复用内存读写指令,把地址空间的一部分分配给设备
  • 1.2 CPU 如何获取当前设备的工作状态

    1.2.1 轮询:一遍遍问

    这是最容易想到的一种方法:不断地检测设备状态寄存器,看是否有状态改变,没有就继续检测。

    如果以键盘为例子,轮询就可能是这样的:

    while(没人按键) {
        ...;
    }
    读取键盘寄存器信息
    

    我们学习了这么久的计算机底层知识,很容易就能看出这种方法的坏处:CPU 一直在循环中空跑,浪费CPU 资源。在本质上,轮询是一种同步的处理方式,把同步改为异步,是一种很常见的优化方式。

    1.2.2 中断处理

    我们在计算机底层4 CPU:6.3 中断与中断函数栈中就介绍过中断处理。

    中断的本质是打断当前CPU 的执行流,跳转到具体的中断处理函数中,当中断处理函数执行完成后,再跳转回来。

    有了中断机制,CPU 就可以不用一直在循环中空跑,浪费资源了,而是可以一直处理自己的事情,等到设备触发中断机制再去处理,处理完成后,再继续执行之前被中断的任务,而在之前被中断的程序看来,CPU 一直在执行自己的指令,就好像从来没中断过。

    image.png

    那么,CPU 是如何检测中断信号和如何保存并恢复被中断程序的执行状态的呢?

  • 中断请求(IRQ): 外部设备(如键盘、鼠标等)或软件程序产生中断请求信号。这通常是通过向 CPU 的中断控制器发送信号来完成的。
  • 中断控制器: 中断控制器是一个硬件组件,通常负责管理多个中断源。它会将中断请求信号传递给 CPU,以通知 CPU 有一个中断事件需要处理。
  • 中断向量表: CPU 有一个中断向量表,其中包含与不同中断类型相关联的中断服务程序的地址。每个中断类型都有一个唯一的标识符,称为中断向量号。
  • 中断处理程序: 当 CPU 接收到中断请求后,它会查找中断向量表,找到与中断请求相关的中断向量号。然后,CPU会跳转到相应的中断处理程序的地址,开始执行与该中断相关的代码。
  • 中断服务程序(ISR): ISR 是特定中断类型的代码段,它负责处理中断事件。ISR 可以保存当前进程的上下文,执行中断处理操作,然后在完成后还原上下文,以便继续执行原来的程序。
  • 中断结束: 一旦中断处理程序执行完毕,CPU 会回到之前的程序执行点,继续执行原来的任务。
  • 同时,中断处理的过程仍然是依赖栈这种数据结构完成的。

    2 磁盘处理I/O时,CPU 在干什么

    其实对于现代操作系统来说,其实磁盘处理I/O 是不需要CPU 参与的,在磁盘处理I/O请求的这段时间里,CPU 会被操作系统调度去执行其他有用的操作。

    假设CPU 开始执行线程A,执行一段时间后,发起涉及磁盘的I/O 请求,磁盘相比于CPU 是非常慢的,因此线程A无法继续向前推进,此时操作系统就把CPU 时间分配给了线程B,这样线程B 开始运行,磁盘也在处理线程A 发起的I/O 请求,也就是CPU 和磁盘都在做自己的事情,它们是独立,互不依赖的,当磁盘处理完I/O后,CPU 继续执行线程A。

    image.png

    那么为什么磁盘处理I/O 不需要CPU 参与呢?

    2.1 设备控制器

    设备控制器是计算机体系结构中的一种硬件,它负责管理和控制外部设备,以便与计算机系统进行通信和数据交换。每种类型的外部设备都需要一个相应的设备控制器来协调其操作和与计算机系统的交互。

    注意不要把设备控制器和设备驱动混淆,设备驱动是一段属于操作系统的代码,而设备控制器是一种硬件,目的是为了接收设备驱动的命令,来控制外部设备。

    image.png

    可以说,设备控制器是一座桥梁,架设起了操作系统和外部设备,设备控制器越来越复杂,目的就是为了解放CPU。

    我们说过,CPU 是计算机的核心,资源宝贵,那么CPU 应该亲自去把磁盘的数据复制到内存中吗?显而易见,数据的复制,搬运,都是很简单的工作,不应该浪费宝贵的CPU 资源去做这些简单的事情,那么把设备控制器中的数据复制到内存这些工作是由谁来完成呢?

    答案是一种机制:直接存储器访问 DMA

    2.2 直接存储器访问 DMA

    如上面所说,DMA 的目的很简单:在不需要CPU 的情况下,直接在设备和内存之间传输数据。

    现在我们来简单看看DMA 的工作过程:CPU 虽然不需要直接去把数据从设备复制到内存,但CPU 需要去下达指令告诉DMA 该怎么去复制数据,是把数据从内存写入设备还是把数据从设备读取给内存,读写多少数据,从哪里开始读取,这些数据都必须要CPU 告诉DMA,此后DMA 才能展开工作。

    DMA 明确了自己的目标后,开始进行总线仲裁,也就是申请对总线的使用权,此后开始操作设备。

    image.png

    那么,CPU 怎么知道数据传输完了呢?答案还是中断机制:当DMA 完成数据传输时,再通过中断机制来通知CPU。

    3 读取文件时,程序经历了什么

    假设现在有一个单核CPU 系统,该系统中正在运行A 和B 两个进程,CPU 正在运行进程A,进程B 处在就绪队列中,这个时候,进程A 需要读取文件,这个时候,进程A 进入了I/O 阻塞队列,操作系统向磁盘发起了I/O 请求,磁盘开始工作,DMA 把数据拷贝到某一块内存中,同时CPU 被调度去执行进程B,于是把进程B 从就绪队列中取出,CPU 开始运行进程B。

    上面这个过程中,我们发现,都是操作系统在调度,使得CPU 资源,磁盘都能得到充分的利用。

    此后,磁盘完成I/O 读取,DMA 把数据都拷贝到了进程A 的内存里面,此时,DMA 也向CPU 发出中断信号,CPU 接收到中断信号后,去处理中断处理函数,同时进程B 又回到就绪队列中,发现数据拷贝完毕,这个时候,操作系统就把进程A 从I/O 阻塞队列中取出,放入就绪队列中,这个时候,就绪队列中有两个任务A和B,操作系统需要决定到底是把CPU 分配给A 还是B。

    在这里就会出现两种情况,第一种是刚刚分配给进程B 的CPU 时间片还没有用完,那么这个时候就应该让B 继续运行,A 继续在就绪队列中等待,当分配给B 的时间片用完后,系统中的定时器发出定时器中断信号,CPU 又跳转到中断处理函数,操作系统把进程B 又放到就绪队列,把进程A 取出,并分配CPU 给进程A,当然,这里的进程B 被暂停运行只是因为操作系统分配给B 的时间片用完了,而不是发起了阻塞式I/O 请求而被暂停。

    而另外一种就很简单,因为操作系统分配给B 的CPU 时间片用完了,那么B 直接进入就绪队列,CPU 执行进程A。

    当然,无论是进程A 或者是进程B,它们都不会觉得自己被暂停过,进程对自己被暂停的事一无所知,这就是操作系统的魔力。

    除此之外,在这篇文章中,我们简单认为文件数据直接被拷贝到了进程的内存中,但是实际上,I/O数据首先要被拷贝到操作系统的内部,然后操作系统再将其拷贝到进程地址空间中,也就是还有一层操作系统的拷贝。

    关于I/O 的理论知识我们已经介绍的不少了,接下来我们来认识一下两种比较高级的I/O 技术:I/O 多路复用和mmap。

    4 I/O 多路复用

    首先,“一切皆文件”,所有I/O 设备都可以被抽象成文件这个概念,磁盘,网络数据等等都可以被当作文件对待。

    所有的I/O 操作也可以通过文件读写来实现,这一抽象可以让程序员使用一套接口就能操作所有外部设备,如用open来打开文件,用read/write来读写文件,用seek来改变读写位置,用close来关闭文件,这就是文件这个概念的强大之处。

    那么我们应该在哪里找到文件呢?

    我们需要借助“文件描述符”:当我们打开文件时,内核会返回给我们一个文件描述符,当进行文件操作时,我们需要把该文件描述符告诉内核,内核获取到文件描述符后,就能找到该描述符也就是一个数字所对应的文件信息并且完成文件操作。

    有了文件描述符,进程可以对文件一无所知,比如文件是否储存在磁盘上,储存在磁盘的什么位置,当前读到了哪里,这些信息都由操作系统打理,程序员只需要针对文件描述符编程即可。

    在介绍完文件描述符,我们现在正式介绍I/O 多路复用:

    I/O 多路复用(I/O Multiplexing)是一种用于处理多个输入/输出操作的计算机编程技术。它允许一个程序在同一时间内监听和处理多个输入或输出通道,而不需要为每个通道创建一个单独的线程或进程。 这可以提高程序的性能和效率。

    具体来说,I/O 多路复用指的是这样一个过程:

  • 我们得到了一堆文件描述符,无论是与网络相关的,还是与文件相关的。
  • 通过调用某个函数让内核去监视这一堆文件描述符,当其中有可以进行的读写操作时,再返回。
  • 当该函数返回后,我们就可以获取到具备读写条件的文件描述符,并对其进行相应的处理。
  • 我们也因此可以看到I/O 多路复用的一些特点和用途:

  • 单线程处理多个通道: 通过 I/O 多路复用,一个单独的线程可以同时监听多个输入或输出通道,而不需要为每个通道创建一个线程。这可以减少线程创建和上下文切换的开销,从而提高程序性能。
  • 非阻塞 I/O: I/O 多路复用通常与非阻塞 I/O 操作一起使用。非阻塞 I/O 允许程序在等待数据就绪时继续执行其他任务,而不会阻塞整个进程或线程。 这对于处理大量连接或客户端的服务器程序非常有用。
  • 事件驱动编程: I/O 多路复用是事件驱动编程的一种基本技术。程序会等待特定事件的发生,然后根据事件类型执行相应的操作。 这种方式适用于网络编程、图形用户界面(GUI)应用程序等需要实时响应事件的场景。
  • 高效网络编程: 在网络编程中,I/O 多路复用可以用于同时管理多个客户端连接。服务器可以监听多个套接字,以确定哪个套接字已经准备好读取或写入数据。
  • 节省资源: 相对于多线程或多进程模型,I/O 多路复用可以减少资源消耗,因为它使用一个线程来管理多个通道,而不是为每个通道分配一个线程或进程。
  • 常见的 I/O 多路复用的系统调用包括 selectpollepoll(在Linux系统中)等,它们在不同的操作系统上有不同的实现。

    5 mmap:像读写内存一样操作文件

    对于程序员来说,读写内存是一件很自然的事情,但是读写文件就要复杂得多。

    我们读写内存:

    int arr[10];
    arr[0] = 2;
    

    如此简单,甚至没有意识到自己在读写内存。

    但是当我们读取文件时:

    char buf[1024];
    int fd = open("/filepath/abc.txt");
    read(fd, buf, 1024);
    

    这就是我们上面讲的内容,通过read告诉操作系统,要开始读取abc.txt 文件了,把信息准备好后,传给我们文件描述符,有了文件描述符,我们就可以知道这个文件的一切信息。

    我们也可以直观地看到,操作文件要比操作内存复杂,根本原因在于磁盘寻址方式与内存不同(内存的寻址粒度是字节,而磁盘寻址则是“块”),以及CPU 和外部设备之间的速度差异。

    所以,我们能不能像读写内存那样去读写磁盘文件呢?

    还记得虚拟内存的概念吗?每个进程都认为自己独占一整份内存,那么文件也可以让使用者认为其保存在一段连续的磁盘空间中,这样的话,我们就可以把这段空间映射到进程的地址空间中。

    这就是mmap

    mmap(Memory-Mapped Files)是一种在Unix和类Unix操作系统中的内存映射文件的系统调用,它允许程序将文件映射到内存中的一段地址空间,从而使文件的内容可以直接在内存中访问,而不需要通过标准的文件读写操作。

    image.png

    这样,我们在读这段映射的内存地址空间的时候,实际上就是在操作文件,我们就可以像操作内存一样操作文件了。

    这一切还是操作系统的功劳。当我们读取到这段映射的地址空间时,可能会因为与之对应的文件没有加载到内存中而出现缺页中断,这个时候,CPU 去处理中断函数,这个时候会发起真正的磁盘I/O 请求,将文件读取到内存并且建立好虚拟内存到物理内存之间的关联,此后,程序就可以像读写内存一样读写磁盘文件了。

    当我们进行写操作的时候,我们依然可以直接修改这块内存,操作系统会在背后将修改的内容写回磁盘。

    由此我们可以知道,即使有了mmap,我们依然需要真正地读写磁盘,只不过这个过程由操作系统完成,我们看起来可以像读写普通内存那样直接读写磁盘文件。

    那么,mmap 和传统读写操作(read/write)相比,哪个更好呢?

    我们常用的标准读写操作,比如read/write,其底层涉及系统调用,使用时,需要先把数据从内核态拷贝到用户态,写数据的数据也需要从用户态拷贝到内核态,这些拷贝都是有开销的。

    image.png

    mmap则没有这个问题,mmap在读写磁盘的时候,不会招致系统调用和数据拷贝,但是,内核中也需要有特定的数据结构来维护进程地址空间与文件的映射关系,这也是有性能开销的。同时还有缺页中断带来的开销。

    因此,我们不能肯定mmap在性能上就比传统读写操作要好,或者说,谈到性能,单纯的理论分析有时候并不好用,需要基于真实的场景用分析工具进行测试才能做出判断。

    但是在大文件处理的场景下,这里的大文件指的是大小超过物理内存的文件,如果我们使用传统读写操作(read/write),那么我们必须一块一块把文件搬到内存,处理完一部分再去处理另外一份,如果不慎申请过多内存,那么还可能会招致OOM killer(因内存不足而杀死一部分进程)。

    但如果使用mmap,我们借助虚拟内存,只要我们的进程地址空间足够大,就可以直接把整个大文件映射到进程地址空间中,即使该文件大小超过物理内存也没有问题,操作系统并不关心,对映射区域的修改将直接写入磁盘文件。

    mmap 的妙用:动态链接库

    在计算机底层1 如何从编程语言一步步到可执行程序中我们介绍过动态库,无论有多少程序依赖次动态库,可执行程序本身都不会包含该库的代码,无论多少进程都用到了这份动态库,在磁盘中也只有一份,这个时候,我们也可以用mmap将其直接映射到各个依赖该库的进程地址空间中。

    image.png

    这样尽管每个进程都认为自己的地址空间加载了这个库,但是实际上在物理内存中,这个库只有一份。

    相似的,如果我们有很多进程都以只读的方式依赖同一份数据,那么我们可以使用mmap

    6 总结

    这是计算机底层系统的最后一篇文章,主要讲了I/O,我们介绍了CPU 是如何处理I/O 请求的,同时,为了充分利用计算机系统中速度迥异的硬件资源,操作系统调度设备控制器,DMA,利用中断机制,最大限度地利用资源,提升效率,还有两种高级的I/O 技术:I/O 多路复用,mmap 它们能够提升程序性能,像操作内存一样操作磁盘文件。它们的背后都离不开伟大的操作系统。

    7 过往文章

    计算机底层1 如何从编程语言一步步到可执行程序

    计算机底层2 程序在运行时发生了什么

    计算机底层3 内存

    计算机底层4 CPU

    计算机底层5 缓存cache

    8 参考资料

    • 陆小风. 计算机底层的秘密. 电子工业出版社, 2023.
    • 计算机底层的秘密 gitbook

    相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论