Linux IO模型本质是在讲应用程序在进行IO操作时,线程如何等待数据在硬件设备、内核空间、用户空间之间的交换过程。既然到涉及数据的交换复制,有一个概念就必须要提了,那就是零拷贝(zero-copy)。
所谓零拷贝(zero-copy),是指在计算机执行IO操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域。具体来讲,就是数据从网络设备到用户程序空间传递的过程中,减少数据拷贝次数,减少系统调用,实现 CPU 的零参与,彻底消除 CPU 在这方面的负载。
实现零拷贝的最主要技术是 DMA数据传输 技术和 内存区域映射 技术,零拷贝具有以下优点:
- 减少数据在内核缓冲区和用户进程缓冲区之间反复的 I/O 拷贝操作;
- 减少用户进程地址空间和内核地址空间之间因为上下文切换而带来的 CPU 开销。
一、Linux内存管理
要理解零拷贝(zero-copy),首先要对现代操作系统(Unix/Linux)的内存管理机制有所了解。由于操作系统的进程与进程之间是共享 CPU 和内存资源的,因此需要一套完善的内存管理机制防止进程之间内存泄漏的问题。
1.1 虚拟内存
我们都知道,操作系统都有物理内存,也就是通过插在主板内存槽上的内存条而获得的内存空间。内存的主要作用就是在计算机运行时为操作系统和各种程序提供临时储存。
虚拟内存(Virtual Memory) ,则是指将硬盘的一块区域划分来作为内存,它为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。
每个用户进程都维护了一个单独的页表(Page Table),虚拟内存和物理内存就是通过这个页表实现地址空间的映射。下图给出了两个进程 A、B 各自的虚拟内存空间以及对应的物理内存之间的地址映射:
用户进程如果需要申请访问物理内存(或磁盘存储空间)时,需要经历以下过程:
- 用户进程向操作系统发出内存申请请求;
- 操作系统检查进程的虚拟地址空间是否被用完,如果有剩余,给进程分配虚拟地址;
- 操作系统为这块虚拟地址创建内存映射(Memory Mapping),并将它放进该进程的页表(Page Table);
- 操作系统返回虚拟地址给用户进程,用户进程开始访问该虚拟地址;
- CPU 根据虚拟地址在此进程的页表(Page Table)中找到了相应的内存映射(Memory Mapping),但是这个内存映射(Memory Mapping)没有和物理内存关联,于是产生缺页中断;
- 操作系统收到缺页中断后,分配真正的物理内存并将它关联到页表相应的内存映射(Memory Mapping)。中断处理完成后,CPU 就可以访问内存了;
- 缺页中断不是每次都会发生,只有系统觉得有必要延迟分配内存时才用的着,很多时候上面的第 3 步,操作系统就会分配真正的物理内存并和内存映射(Memory Mapping)进行关联。
1.2 内核/用户空间
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也可以访问底层硬件设备。因此,为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是 内核空间(Kernel-space) ,一部分是 用户空间(User-space) 。
在 Linux 系统中,内核模块运行在内核空间,对应的进程处于 内核态 ;而用户程序运行在用户空间,对应的进程处于 用户态 。下图是一个进程的用户空间和内核空间的内存布局:
内核进程和用户进程所占的虚拟内存比例是 1:3,以32位Linux系统为例,虚拟内存为 4G,最高的 1G(从虚拟地址 0xC0000000 到 0xFFFFFFFF)供内核进程使用,称为内核空间;较低的 3G 的字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个用户进程使用,称为用户空间。
内核空间
内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的。
上图左侧区域为内核进程对应的虚拟内存,按访问权限可以分为进程私有和进程共享两块区域:
- 进程私有的虚拟内存:每个进程都有单独的内核栈、页表、task 结构以及 mem_map 结构等;
- 进程共享的虚拟内存:属于所有进程共享的内存区域,包括物理存储器、内核数据和内核代码区域。
用户空间
每个普通的用户进程都有一个单独的用户空间,处于用户态的进程不能访问内核空间中的数据,也不能直接调用内核函数,因此要进行系统调用时,就要将进程切换到内核态。
1.3 Linux内部层级结构
内核态可以执行任意命令,调用系统的一切资源,而用户态只能执行简单的运算,不能直接调用系统资源。用户态必须通过系统接口(System Call),才能向内核发出指令。下图是用户进程执行bash
和cat
命令时的调用示意图:
这样的话,Linux内部层级结构的划分,其实可以分为三部分,自底向上依次是 硬件 、 内核空间 和 用户空间 ,如下图:
二、Linux I/O
了解了Linux的内存管理机制,我们再来看Linux I/O 读写方式。Linux一共提供了三种磁盘与主存之间的数据传输机制:
- 轮询: 基于死循环对 I/O 端口进行不断检测;
- I/O 中断: 当数据到达时,磁盘主动向 CPU 发起中断请求,由 CPU 自身负责数据的传输过程;
- DMA传输: 在 I/O 中断的基础上引入了 DMA 磁盘控制器,由 DMA 磁盘控制器负责数据的传输,降低了 I/O 中断操作对 CPU 资源的大量消耗。
2.1 I/O 中断
在 DMA 技术出现之前,应用程序与磁盘之间的 I/O 操作都是通过CPU中断完成的。每次用户进程读取磁盘数据时,需要经历以下流程:
- 用户进程向 CPU 发起
read()
系统调用,读取数据,由用户态切换为内核态,然后阻塞等待数据的返回; - CPU接收到指令后,对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区;
- 数据准备完成以后,磁盘向 CPU 发起 I/O 中断;
- CPU 收到 I/O 中断后,将磁盘缓冲区中的数据拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区;
- 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。
2.2 DMA传输
DMA 的全称叫 直接内存存取(Direct Memory Access) ,是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。也就是说,基于 DMA 访问方式,系统主内存和硬盘/网卡之间的数据传输可以绕开 CPU 调度。目前大多数的硬件设备,包括磁盘控制器、网卡、显卡以及声卡等都支持 DMA 技术。
整个数据传输过程是在一个 DMA控制器 的控制下进行的。CPU除了在数据传输开始/结束时做一些处理外(开始和结束时要做中断处理),在传输过程中 CPU 可以继续进行其它工作。这样在大部分时间里,CPU 计算和 I/O 操作是并行操作的,整个系统的处理效率就也大大提高。
基于DMA传输,每次用户进程读取磁盘数据时,需要经历以下流程:
- 用户进程向 CPU 发起
read()
系统调用,读取数据,由用户态切换为内核态,然后阻塞等待数据的返回; - CPU 接收到指令后,对 DMA 磁盘控制器发起调度指令;
- DMA 磁盘控制器收到指令后,对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区,CPU 全程不参与此过程。
- 数据读取完成后,DMA 磁盘控制器会接受到磁盘的通知,将数据从磁盘控制器缓冲区拷贝到内核缓冲区。
- DMA 磁盘控制器向 CPU 发出数据读完的信号,由 CPU 负责将数据从内核缓冲区拷贝到用户缓冲区。
- 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。
CPU拷贝由CPU直接处理数据的传送,会一直占用 CPU 的资源;而DMA拷贝由CPU向DMA磁盘控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,从而减轻了 CPU 资源的占有率。
## 三、零拷贝
了解Linux的整个内存管理机制和I/O机制,我们再来理解零拷贝就比较容易了。
### 2.1 传统I/O方式
为了更好的理解零拷贝解决的问题,我们首先了解一下传统 I/O 方式存在的问题。在 Linux 系统中,传统的I/O方式是通过write
和read
两个系统调用实现的。
以“用户程序读取磁盘数据,然后进行网络传输”为例,首先通过read
函数读取文件到缓存区中,然后通过write
方法把缓存中的数据输出到网络端口:
shell read(file_fd, tmp_buf, len); write(socket_fd, tmp_buf, len);
下图分别对应传统 I/O 操作的数据读写流程,整个过程发生 4次上下文切换 , 2次CPU拷贝和2次DMA拷贝 :
我详细解释下上述流程:
- 用户进程通过
read
函数向内核(kernel)发起系统调用,CPU 将用户进程从用户态切换到内核态; - CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer);
- CPU 将读缓冲区中的数据拷贝到用户空间(user space)的用户缓冲区(user buffer);
- CPU 将用户进程从内核态切换回用户态,
read
调用执行返回; - 用户进程通过
write
函数向内核发起系统调用,CPU 将用户进程从用户态切换到内核态; - CPU 将用户缓冲区中的数据拷贝到内核空间的网络缓冲区(socket buffer);
- CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡,进行数据传输;
- CPU 将用户进程从内核态切换回用户态,
write
调用执行返回。
2.2 零拷贝方式
在 Linux 中,零拷贝技术主要有三种实现思路:
- 用户态直接 I/O:应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输。 这种方式依旧存在用户空间和内核空间的上下文切换,硬件上的数据直接拷贝至用户空间,不经过内核空间。因此,这种方式不存在内核空间缓冲区和用户空间缓冲区之间的数据拷贝;
- 减少数据拷贝次数:在数据传输过程中,避免数据在用户空间缓冲区和内核空间缓冲区之间的 CPU 拷贝,以及数据在内核空间内的 CPU 拷贝,这也是当前主流零拷贝技术的实现思路;
- 写时复制:当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么将其拷贝到自己的进程地址空间中,如果只是数据读取操作则不需要进行拷贝操作。
我下面主要介绍前两种,写时复制大家应该已经很熟悉了,不作赘述。
用户态直接 I/O
用户态直接 I/O ,就是让应用进程直接访问硬件设备,数据跨过内核直接进行传输,内核在数据传输过程除了进行必要的虚拟存储配置工作之外,不参与任何其他工作,这种方式能够直接绕过内核,极大提高了性能。
用户态直接 I/O 的缺点也很明显:
- 只能适用于不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,DBMS就是典型的例子;
- 这种零拷贝机制会直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成大量资源的浪费,解决方案是配合异步 I/O 使用。
mmap+write
mmap 是 Linux 提供的一种内存映射文件方法,即将一个进程的地址空间中的一段虚拟地址映射到磁盘文件地址。以“用户程序读取磁盘数据,然后进行网络传输”为例,mmap+write 的伪代码如下:
tmp_buf = mmap(file_fd, len);
write(socket_fd, tmp_buf, len);
使用 mmap 的目的是将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程。
基于 mmap+write 系统调用的零拷贝方式,整个拷贝过程会发生 4次上下文切换 , 1次CPU拷贝和2次DMA拷贝 ,大致流程如下图所示:
- 用户进程通过
mmap
函数向内核发起系统调用,上下文从用户态切换为内核态; - 将用户进程的内核空间的读缓冲区与用户空间的缓存区进行内存地址映射;
- CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区;
- 上下文从内核态切换回用户态,
mmap
系统调用执行返回; - 用户进程通过
write
函数向内核发起系统调用,上下文从用户态切换为内核态; - CPU 将读缓冲区中的数据拷贝到网络缓冲区(socket buffer);
- CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输;
- 上下文从内核态切换回用户态,
write
系统调用执行返回。
mmap 减少了1次CPU拷贝,提升了性能, 特别是针对大文件 。但是,它也有一些缺点:
- 对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是4KB,一个5KB的文件将会映射占用8KB内存,也就会浪费3KB内存;
- 当
mmap
一个文件时,如果这个文件被另一个进程所截获,那么write
系统调用会因为访问非法地址被SIGBUS
信号终止,SIGBUS
默认会杀死进程并产生一个coredump
,服务器可能因此被终止。
Sendfile
Linux内核2.1版本,引入了Sendfile
系统调用,目的是简化通过网络在两个通道之间进行的数据传输过程。还是以“用户程序读取磁盘数据,然后进行网络传输”为例,使用Sendfile系统调用的伪代码如下:
sendfile(socket_fd, file_fd, len);
通过 Sendfile 系统调用, 数据可以直接在内核空间内部进行 I/O 传输,省去了数据在用户空间和内核空间之间的来回拷贝 。与 mmap 内存映射方式不同的是, Sendfile 调用中 I/O 数据对用户空间是完全不可见的。
基于 Sendfile 系统调用的零拷贝方式,整个拷贝过程会发生 2次上下文切换 , 1 次CPU拷贝和2次DMA拷贝 。用户程序读写数据的流程如下:
- 用户进程通过
sendfile
函数向内核发起系统调用,上下文从用户态切换为内核态; - CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区;
- CPU 将读缓冲区中的数据拷贝到的网络缓冲区(socket buffer);
- CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输;
- 上下文从内核态切换回用户态,
sendfile
系统调用执行返回。
相比较于 mmap 内存映射的方式,Sendfile 少了2次上下文切换,但是仍然有1次CPU拷贝操作。
Sendfile 存在的问题是:用户程序不能在中途对数据进行修改,而只是单纯地完成了一次数据传输过程,它只适用于将数据从文件拷贝到 Socket 套接字上的传输过程。
Sendfile+DMA gather copy
Linux内核2.4版本,对 Sendfile 系统调用进行了修改,为DMA拷贝引入了gather
操作:它将内核空间的读缓冲区中对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区( socket buffer)中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区拷贝到网卡设备中。
这样,内核空间中仅剩的1次CPU拷贝操作也不需要了,Sendfile+DMA gather copy的伪代码如下:
sendfile(socket_fd, file_fd, len);
DMA gather copy需要硬件的支持,这种机制下,Sendfile 拷贝方式不再从内核缓冲区的数据拷贝到 Socket 缓冲区,取而代之是仅仅拷贝缓冲区文件描述符和数据长度。
这样 DMA 引擎直接利用 gather 操作将页缓存中数据打包发送到网络中即可,本质是和虚拟内存映射类似的思路:
基于 Sendfile+DMA gather copy 系统调用的零拷贝方式,整个拷贝过程会发生 2次上下文切换 、 0次CPU拷贝以及2次 DMA拷贝 。用户程序读写数据的流程如下:
- 用户进程通过
sendfile
函数向内核发起系统调用,上下文从用户态切换为内核态; - CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区;
- CPU 把读缓冲区的文件描述符(file deor)和数据长度拷贝到网络缓冲区(socket buffer);
- 基于已拷贝的文件描述符(file deor)和数据长度,CPU 利用 DMA 控制器的
gather/scatter
操作直接批量地将数据从内核的读缓冲区拷贝到网卡,从而进行数据传输; - 上下文从内核态切换回用户态,
Sendfile
系统调用执行返回。
Sendfile+DMA gather copy 拷贝方式同样存在用户程序不能对数据进行修改的问题,并且需要硬件的支持,它只适用于将数据从文件拷贝到 Socket 套接字上的传输过程。
Splice
Linux内核2.6.17版本,引入了 Splice 系统调用。Splice 系统调用可以在内核空间的读缓冲区和网络缓冲区之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作。
Splice 系统调用不仅不需要硬件支持,还实现了两个文件描述符之间的数据零拷贝。Splice 的伪代码如下:
splice(fd_in, off_in, fd_out, off_out, len, flags);
基于 Splice 系统调用的零拷贝方式,整个拷贝过程会发生 2次上下文切换 , 0次CPU拷贝以及2次DMA拷贝 。用户程序读写数据的流程如下:
- 用户进程通过
splice
函数向内核发起系统调用,上下文从用户态切换为内核态; - CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区;
- CPU 在内核空间的读缓冲区和网络缓冲区之间建立管道(pipeline);
- CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输;
- 上下文从内核态切换回用户态,
splice
系统调用执行返回。
Splice 拷贝方式使用了 Linux 的 管道缓冲机制 ,可以用于任意两个文件描述符中传输数据,而不是仅限于Socket。但是,Splice拷贝方式也有一些限制:
- 同样存在用户程序不能在中途对数据进行修改的问题;
- 它的两个文件描述符参数中有一个必须是管道设备。
缓冲区共享
缓冲区共享方式完全改写了传统的 I/O 操作,因为传统 I/O 接口都是基于数据拷贝进行的,要避免拷贝就得去掉原先的那套接口并重新改写。所以这种方法是比较全面的零拷贝技术,目前比较成熟的一个方案是在 Solaris 上实现的 fbuf(Fast Buffer,快速缓冲区)。
fbuf 的思想是:每个进程都维护着一个缓冲区池,这个缓冲区池能被同时映射到用户空间(user space)和内核态(kernel space),内核和用户共享这个缓冲区池,这样就避免了一系列的拷贝操作。
缓冲区共享的难度在于管理共享缓冲区池需要应用程序、网络软件以及设备驱动程序之间的紧密合作,而且如何改写 API 目前还处于试验阶段并不成熟。
四、开源框架
4.1 Kafka和RocketMQ
RocketMQ 选择了 mmap+write 这种零拷贝方式,适用于业务级消息这种小块文件的数据持久化和传输。
而 Kafka 采用的是 Sendfile 这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输。但是值得注意的一点是,Kafka 的索引文件使用的是 mmap+write 方式,数据文件使用的是 Sendfile 方式。
消息队列 | 零拷贝方式 | 优点 | 缺点 |
---|---|---|---|
RocketMQ | mmap+write | 适用于小块文件传输,频繁调用时,效率很高 | 不能很好的利用DMA方式,会比sendfile多消耗CPU,内存安全性控制复杂,需要避免JVMCrash问题 |
Kafka | sendfile | 可以利用DMA方式,消耗CPU较少,大块文件传输效率高,无内存安全性问题 | 小块文件效率低于mmap方式,只能是BIO方式传输,不能使用NIO方式 |
4.2 Netty
Netty 中也使用了零拷贝技术,但是和操作系统层面上的零拷贝不太一样, Netty 零拷贝完全是基于Java 层面的用户态的,它的更多的是偏向于数据操作优化这样的概念:
- Netty 通过
DefaultFileRegion
类对java.nio.channels.FileChannel
的 tranferTo 方法进行包装,在文件传输时可以将文件缓冲区的数据直接发送到目的通道(Channel); - ByteBuf 可以通过 wrap 操作把字节数组、ByteBuffer 包装成一个 ByteBuf 对象, 进而避免了拷贝操作;
- ByteBuf 支持 Slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝;
- Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。
其中第 1 条属于操作系统层面的零拷贝操作,后面 3 条只能算用户层面的数据操作优化。
五、总结
本章,我全面阐述了现代操作系统Linux的内存管理机制以及I/O机制,并对操作系统的零拷贝机制进行了全面讲解,包括内存映射 mmap、Sendfile、Sendfile+DMA gather copy 以及 Splice 几种机制,并从系统调用和拷贝次数层面对它们进行了对比。
零拷贝的本质就是两个目的: 减少CPU拷贝和DMA拷贝 ,以及 减少CPU对进程的上下文切换 。
无论是传统 I/O 拷贝方式还是引入零拷贝的方式,2次DMA拷贝都是少不了的,因为两次 DMA 都是依赖硬件完成的。最后,我从 CPU 拷贝次数、DMA 拷贝次数以及系统调用几个方面总结一下本文介绍的几种 I/O 拷贝方式的差别:
拷贝方式 | CPU拷贝 | DMA拷贝 | 系统调用 | 上下文切换 |
---|---|---|---|---|
传统方式(read+write) | 2 | 2 | read/write | 4 |
内存映射(mmap+write) | 1 | 2 | mmap/write | 4 |
sendfile | 1 | 2 | sendfile | 2 |
sendfile+DMAgathercopy | 0 | 2 | sendfile | 2 |
splice | 0 | 2 | splice | 2 |
Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。
它的内容包括:
- 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
- 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
- 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
- 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
- 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
- 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
- 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
- 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw
目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:
想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询
同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。