Linux的I/O原理和零拷贝机制的实现 | 青训营笔记

113 阅读23分钟
  • 这是我参与「第三届青训营 -后端场」笔记创作活动的的第一篇笔记

一,物理内存和虚拟内存

在很久以前,还没有虚拟内存概念的时候,程序寻址用的都是物理地址。程序能寻址的范围是有限的,这取决于CPU的地址线条数。比如在32位平台下,寻址的范围是2^32也就是4G。并且这是固定的,如果没有虚拟内存,且每次开启一个进程都给4G的物理内存,就可能会出现很多问题:

  • 因为我的物理内存时有限的,当有多个进程要执行的时候,都要给4G内存,很显然你内存小一点,这很快就分配完了,于是没有得到分配资源的进程就只能等待。当一个进程执行完了以后,再将等待的进程装入内存。这种频繁的装入内存的操作是很没效率
  • 由于指令都是直接访问物理内存的,那么我这个进程就可以修改其他进程的数据,甚至会修改内核地址空间的数据,这是我们不想看到的
  • 因为内存是随机分配的,所以程序运行的地址也是不正确的。

因此现代操作系统提供了一种对主存的抽象概念,即是虚拟内存(Virtual Memory) 。虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。

虚拟内存的实现

虚拟内存是计算机系统内存管理的一种技术。 它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间)。而实际上,虚拟内存通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换,加载到物理内存中来

虚拟内存是和进程息息相关的,每一个进程都有自己的一个虚拟内存,所以不同的进程相同虚拟内存地址通常指向的不是同一个物理地址

每个进程所能使用的虚拟地址大小和 CPU 位数有关。在 32 位的系统上,虚拟地址空间大小是 2 ^ 32 = 4G,在 64位系统上,虚拟地址空间大小是 2 ^ 64= 2 ^ 34G,而实际的物理内存可能远远小于虚拟内存的大小

虚拟内存的具体实现是通过在每个进程内部维护一个单独的页表(Page Table) ,虚拟内存和物理内存就是通过这个页表实现地址空间的映射的

进程开始要访问一个地址,它可能会经历下面的过程

  • 每次我要访问地址空间上的某一个地址,都需要把地址(虚拟内存地址)翻译为实际物理内存地址
  • 所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上
  • 进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录
  • 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)
  • 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常(缺页中断)
  • 缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪, 如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。

优点

在用户进程和物理内存(磁盘存储器)之间引入虚拟内存主要有以下的优点:

  • 地址空间:提供更大的地址空间,并且地址空间是连续的,使得程序编写、链接更加简单
  • 进程隔离:不同进程的虚拟地址之间没有关系,所以一个进程的操作不会对其它进程造成影响
  • 数据保护:每块虚拟内存都有相应的读写属性,这样就能保护程序的代码段不被修改,数据块不能被执行等,增加了系统的安全性
  • 内存映射:有了虚拟内存之后,可以直接映射磁盘上的文件(可执行文件或动态库)到虚拟地址空间。在进程需要时再加载到内存中,或者在内存吃紧的时候将这部分内存清空掉,提高物理内存利用效率
  • 共享内存:比如动态库只需要在内存中存储一份,然后将它映射到不同进程的虚拟地址空间中,让进程觉得自己独占了这个文件。进程间的内存共享也可以通过映射同一块物理内存到进程的不同虚拟地址空间来实现共享
  • 物理内存管理:物理地址空间全部由操作系统管理,进程无法直接分配和回收,从而系统可以更好的利用内存,平衡进程间对内存的需求
  • 减少内存空间碎片:在程序需要分配连续空间的时候,只需要在虚拟内存分配连续空间,而不需要物理内存时连续的,实际上,往往物理内存都是断断续续的内存碎片。这样就可以有效地利用我们的物理内存

二,内核空间和用户空间

操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(Kernel-space) ,一部分是用户空间(User-space)

在 Linux 系统中,内核模块运行在内核空间,对应的进程处于内核态;而用户程序运行在用户空间,对应的进程处于用户态。

内核进程和用户进程所占的虚拟内存比例是 1:3

1,内核空间

内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的,按访问权限可以分为进程私有和进程共享两块区域。

  • 进程私有的虚拟内存:每个进程都有单独的内核栈、页表、task 结构以及 mem_map 结构等。
  • 进程共享的虚拟内存:属于所有进程共享的内存区域,包括物理存储器、内核数据和内核代码区域。

2,用户空间

每个普通的用户进程都有一个单独的用户空间,处于用户态的进程不能访问内核空间中的数据,也不能直接调用内核函数的 ,因此要进行系统调用的时候,就要将进程切换到内核态才行。用户空间包括以下几个内存区域:

  • 运行时栈
  • 运行时堆
  • 代码段
  • 未初始化的数据段
  • 已初始化的数据段
  • 内存映射区域

(不太了解,以后再查查)

三. Linux的内部层级结构

内核态可以执行任意命令,调用系统的一切资源,而用户态只能执行简单的运算,不能直接调用系统资源。用户态必须通过系统接口(System Call),才能向内核发出指令。

  • 内核空间可以访问所有的 CPU 指令和所有的内存空间、I/O 空间和硬件设备。
  • 用户空间只能访问受限的资源,如果需要特殊权限,可以通过系统调用获取相应的资源。
  • 用户空间允许页面中断,而内核空间则不允许。
  • 内核空间和用户空间是针对线性地址空间的
  • 所有内核进程(线程)共用一个地址空间,而用户进程都有各自的地址空间。

有了用户空间和内核空间的划分后,Linux 内部层级结构可以分为三部分,从最底层到最上层依次是硬件、内核空间和用户空间,如下图所示:

四,IO读写方式

Linux 提供了轮询I/O 中断以及 DMA 传输这 3 种磁盘与主存之间的数据传输机制。

1,轮询

轮询是最简单的方式,使用死循环对io设备进行轮询,查看io设备是否就绪,需要阻塞到等待的io设备就绪才会结束死循环。

2,I/O中断

I/O中断的方式就是使用中断的操作,让用户进程在等待I/O就绪的过程中放弃对CPU的使用权,然后由进程调度系统调度,使一个就绪的进程获得CPU使用权,然后等到I/O设备准备就绪,输入输出设备控制器通过中断请求线向处理器发出 中断信号,使获得CPU的当前进程中断,同时让先前预设好的进程 获得CPU的使用权,处理I/O获得的数据。

具体的I/O过程如下:

  1. 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。
  2. CPU 在接收到指令以后对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区
  3. 数据准备完成以后,磁盘向 CPU 发起 I/O 中断
  4. CPU 收到 I/O 中断以后将磁盘缓冲区中的数据拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区
  5. 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。

中断的缺点:

  • 如果I/O设备很多,如果这些I/O设备都通过中断处理方式进行并行操作,那么中断次数的急剧增加会造成CPU无法响应中断和出现数据丢失现象。
  • 如果I/O控制器的数据缓冲区比较小,在缓冲区装满数据之后将会发生中断。那么,在数据传送过程中,发生中断的机会较多,这将耗去大量的CPU处理时间。

3,DMA

DMA 的全称叫直接内存存取(Direct Memory Access),是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。

DMA工作过程

  • 当进程要求设备输入数据时,CPU把准备存放输入数据的内存起始地址以及要传送的字节数分别送入DMA控制器中的内存地址寄存器和传送字节计数器。
  • 发出数据传输要求的进行进入等待状态。此时正在执行的CPU指令被暂时挂起进程调度程序调度其他进程占据CPU
  • 输入设备不断地窃取CPU工作周期,将数据缓冲寄存器(DR)中的数据源源不断地写入内存,直到所要求的字节全部传送完毕。
  • DMA控制器在传送完所有字节时,通过中断请求线发出中断信号。CPU在接收到中断信号后,转入中断处理程序进行后续处理。
  • 中断处理结束后,CPU返回到被中断的进程中,或切换到新的进程上下文环境中,继续执行。

当输入设备把一个数据送入DMA控制器的数据缓冲寄存器DR后,DMA控制器立即取代CPU接管地址总线的控制权,根据送入DMA控制器的内容,将数据送入相应的内存单元,这称为窃取CPU的工作周期

参照上图,具体的执行流程是:

  • 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。
  • CPU 在接收到指令以后对 DMA 磁盘控制器发起调度指令。
  • DMA 磁盘控制器对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区,CPU 全程不参与此过程。
  • 数据读取完成后,DMA 磁盘控制器会接受到磁盘的通知,将数据从磁盘控制器缓冲区拷贝到内核缓冲区
  • DMA 磁盘控制器向 CPU 发出数据读完的信号,由 CPU 负责将数据从内核缓冲区拷贝到用户缓冲区
  • 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。

DMA与中断的区别:

  • 中断方式是在数据缓冲寄存器满之后发出中断,要求CPU进行中断处理,而DMA方式则是在所要求传送的数据块全部传送结束时要求CPU 进行中断处理。这就大大减少了CPU进行中断处理的次数
  • 中断方式的数据传送是在中断处理时由CPU控制完成的,而DMA方式则是在DMA控制器的控制下不经过CPU控制完成的。这就排除了CPU因并行设备过多而来不及处理以及因速度不匹配而造成数据丢失等现象。
  • 中断源的急迫程度来看,DMA高于中断
  • 从CPU响应时间来看,CPU对中断的响应是在执行完一条指令 (指令周期) 之后,而对DMA的响应则可以在指令执行过程中的任何一个机器周期结束时。

五,传统IO方式

从上面的I/O操作的描述中,我们可以看到,I/O操作是需要进行对用户态内核态的切换,并且也需要在各个缓冲区里进行复制,那么具体会有哪些操作呢?

1,传统读操作

当应用执行read操作的时候,如果要读取的数据是在存在于该进程的页内存里,那就直接读取,如果数据不存在,则先将数据从磁盘加载数据到内核空间的读缓存(read buffer)中,再从读缓存拷贝到用户进程的页内存中。

read(file_fd, tmp_buf, len);

基于传统的 I/O 读取方式,read 系统调用会触发 2 次上下文切换1 次 DMA 拷贝1 次 CPU 拷贝,发起数据读取的流程如下:

  • 用户进程通过 read() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  • CPU利用DMA控制器将数据从主存或硬盘 拷贝到内核空间(kernel space)的 读缓冲区 (read buffer)
  • CPU读缓冲区 (read buffer)中的数据拷贝到用户空间(user space)的 用户缓冲区 (user buffer)。
  • 上下文从内核态(kernel space)切换回用户态(user space) ,read 调用执行返回。

2,传统写操作

当应用程序准备好数据,执行 write 系统调用发送网络数据时,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(socket buffer)中,然后再将写缓存中的数据拷贝到网卡设备完成数据发送。

write(socket_fd, tmp_buf, len);

基于传统的 I/O 写入方式,write() 系统调用会触发 2 次上下文切换1 次 CPU 拷贝1 次 DMA 拷贝,用户程序发送网络数据的流程如下:

  • 用户进程通过 write() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  • CPU 将用户缓冲区( user buffer)中的数据拷贝到内核空间(kernel space)的 网络缓冲区(socket buffer)
  • CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer) 拷贝到网卡进行数据传输。
  • 上下文从内核态(kernel space)切换回用户态(user space) ,write 系统调用执行返回。

六,零拷贝

在 Linux 中零拷贝技术主要有 3 个实现思路:用户态直接 I/O、减少数据拷贝次数以及写时复制技术。

1,用户态直接I/O

用户态直接 I/O 使得应用进程或运行在用户态(user space)下的库函数直接访问硬件设备,数据直接跨过内核进行传输,内核在数据传输过程除了进行必要的虚拟存储配置工作之外,不参与任何其他工作,这种方式能够直接绕过内核,极大提高了性能。

缺点:用户态直接 I/O 只能适用于不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统就是一个代表。其次,这种零拷贝机制会直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成大量资源的浪费,解决方案是配合异步 I/O 使用

2,mmap + write

mmap解决的是数据从内核空间复制到用户空间的问题,从而减少了 1 次 CPU 拷贝操作,其原理是将利用Linux系统提供的mmap的内存文件映射方法,将磁盘文件直接映射的到用户空间和内核空间的虚拟地址上,这样用户就可以直接在用户空间操作文件,而不用在内核空间和用户空间之间进行复制了。

使用 mmap 的目的是将内核中读缓冲区(read buffer) 的地址与用户空间的缓冲区(user buffer) 进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer) 的过程。但是内核读缓冲区(read buffer) 仍需将数据到内核写缓冲区(socket buffer)

mmap的原理其实就是在在虚拟内存空间和磁盘空间建立一个映射,也就是让虚拟内存的逻辑地址映射磁盘上的数据,如果进程需要用到这个数据就会产生中断,从磁盘上加载到内存,而在映射之后也就是调用了mmap()后该函数会返回一个指针,我们就可以提供指针对该内存的数据进行操作。

由于使用了映射,所以使用mmap的文件需要对齐,也就是得是页大小的整数倍,所以mmap更适合大文件,因为如果页大小是4kB,那5KB的文件需要对齐到8KB,比较浪费内存。

mmap 的拷贝虽然减少了 1 次拷贝,提升了效率,但也存在一些隐藏的问题。当 mmap 一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,服务器可能因此被终止。(还不太了解这个)


3,sendfile

sendfile 系统调用在 Linux 内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。sendfile 系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数

通过 sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝。与 mmap 内存映射方式不同的是, sendfile 调用中 I/O 数据对用户空间是完全不可见的。也就是说,这是一次完全意义上的数据传输过程。

基于 sendfile 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 sendfile() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)
  3. CPU 将读缓冲区(read buffer)中的数据拷贝到的网络缓冲区(socket buffer)
  4. CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
  5. 上下文从内核态(kernel space)切换回用户态(user space),sendfile 系统调用执行返回。

相比于mmap,sendfile减少了两次上下文切换,但是仍然需要一次cpu拷贝,但是sendfile有一个最大的问题就是,数据对用户空间是不可见的,所以用户无法修改数据,仅仅只是一次简单的数据传输而已。

4,sendfile + DMA gather copy

Linux 2.4 版本的内核对 sendfile 系统调用进行修改,为 DMA 拷贝引入了 gather 操作。它将内核空间(kernel space)的读缓冲区(read buffer) 中对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区( socket buffer) 中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡设备中,这样就省去了内核空间中仅剩的 1 次 CPU 拷贝操作。

在硬件的支持下,sendfile 拷贝方式不再从内核缓冲区的数据拷贝到 socket 缓冲区,取而代之的仅仅是缓冲区文件描述符和数据长度的拷贝,这样 DMA 引擎直接利用 gather 操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思路类似。

基于 sendfile + DMA gather copy 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 sendfile() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)
  3. CPU 把读缓冲区(read buffer)文件描述符(file descriptor)和数据长度拷贝到网络缓冲区(socket buffer)
  4. 基于已拷贝的文件描述符(file descriptor)和数据长度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地将数据从内核的读缓冲区(read buffer)拷贝到网卡进行数据传输
  5. 上下文从内核态(kernel space)切换回用户态(user space) ,sendfile 系统调用执行返回。

sendfile + DMA gather copy 拷贝方式同样存在用户程序不能对数据进行修改的问题,而且本身需要硬件的支持,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。

5,splice

sendfile 只适用于将数据从文件拷贝到 socket 套接字上,同时需要硬件的支持,这也限定了它的使用范围。Linux 在 2.6.17 版本引入 splice 系统调用,不仅不需要硬件支持,还实现了两个文件描述符之间的数据零拷贝。

splice 系统调用可以在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作。

基于 splice 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝

splice 拷贝方式也同样存在用户程序不能对数据进行修改的问题。除此之外,它使用了 Linux 的管道缓冲机制,可以用于任意两个文件描述符中传输数据,但是它的两个文件描述符参数中有一个必须是管道设备

6,写时复制

在某些情况下,内核缓冲区可能会被多个进程共享,如果某个进程想要这个共享区进行write操作,由于write不提供任何锁操作,这就会对共享区域中的数据造成破坏,写时复制的引入就是Linux用来保护数据的。

写时复制指的是在多个进程共享一块数据的时候,如果一个进程要对这份数据进行修改,那么就想要将其拷贝到自己的进程地址空间,这样做不影响其他进程对这块数据的操作,每个进程要修改的时候才会拷贝,所以叫写时复制。这种方法某种程度上能降低系统的开销,如果某个进程永远都不会对访问数据进行更改,那么就永远不需要拷贝。

7,缓冲区共享

缓冲区共享方式完全改写了传统的 I/O 操作,因为传统 I/O 接口都是基于数据拷贝进行的,要避免拷贝就得去掉原先的那套接口并重新改写,所以这种方法是比较全面的零拷贝技术,目前比较成熟的一个方案是在 Solaris 上实现的 fbuf(Fast Buffer,快速缓冲区)。

fbuf 的思想是每个进程都维护着一个缓冲区池,这个缓冲区池能被同时映射到用户空间(user space)和内核态(kernel space)内核和用户共享这个缓冲区池,这样就避免了一系列的拷贝操作。

缓冲区共享的难度在于管理共享缓冲区池需要应用程序、网络软件以及设备驱动程序之间的紧密合作,而且如何改写 API 目前还处于试验阶段并不成熟。

参考资料:

虚拟内存与物理内存的联系与区别

深入剖析Linux IO原理和几种零拷贝机制的实现

I/O系统:中断、DMA、通道