【什么是零复制?说说 零复制 底层原理?】

目录

什么是数据复制/数据拷贝(Copy)?

什么是四大内存?

数据发送到网卡的流程

通过到网卡读取数据的流程

接收 SOCKET 数据包的流程

DMA 简介:

什么是零拷贝?零复制的5大类型:

类型1:用户空间和内核空间共享内存:

类型2:直接内存访问(DMA):

类型3:文件描述符传递:

类型4:逻辑内存的数据零复制:

类型5:Java通过JNI直接使用本地内存:

Unsafe类

DirectByteBuffer 直接内存

零拷贝的三大优势

什么是CPU上下文切换/ 用户态与内核态切换?

进程上下文切换

线程上下文切换

中断上下文切换

传统IO中的数据拷贝

C传统程序IO过程中数据拷贝

详解C传统程序典型的IO系统调用流程: 写入流程

详解C传统程序典型的IO系统调用流程: 读取流程

内核空间和用户空间为什么要发生拷贝?

Java传统程序IO流程中数据拷贝

为什么会在JVM堆内外之间发生数据拷贝

回顾:Linux进程内存布局

Java Heap和C Heap哪个效率更高?

JVM GC其间会移动对象的地址

为什么会在堆内外之间发生数据拷贝

堆内外之间发生数据拷贝的必然性

堆内外之间发生数据拷贝减少的解决方案

接下来,看看零复制的三大常见的  实现技术    

零复制实现1:用户空间和内核空间共享内存 mmap     

mmap+write的代码

mmap进行文件IO(读写)

mmap+write零复制进行文件读+Socket写

零复制实现2:文件描述符传递零复制, sendfile 

sendfile 函数

sendfile的使用

老版本sendfile减少了上下文切换

DMA Gather

新版本sendfile减少了最后一次cpu复制

零复制实现3:Java通过JNI直接使用本地内存零复制     


什么是数据复制/数据拷贝(Copy)?

拷贝(Copy)数据,主要包括两种:CPU拷贝/ DMA拷贝。

第一类拷贝:CPU 拷贝(CPU Copy):

在 CPU 拷贝中,数据的传输是由中央处理器(CPU)直接进行的。当数据需要从一个内存区域拷贝到另一个内存区域时,CPU 首先将数据从源内存区域读取到 CPU 寄存器,然后再将数据从寄存器写入到目标内存区域。这个过程需要 CPU 的直接参与,因此称为 CPU 拷贝。CPU 拷贝的过程涉及多次读取和写入操作,较为耗时,特别是在大数据量传输时。

第二类拷贝:DMA 拷贝(Direct Memory Access Copy):

在 DMA 拷贝中,数据的传输是通过 DMA 控制器进行的,而不需要 CPU 的直接参与。

DMA 控制器是一种硬件设备,它能够在 CPU 不直接参与的情况下,将数据从一个内存区域传输到另一个内存区域。

在 DMA 拷贝中,CPU 将传输的任务交给 DMA 控制器,然后 DMA 控制器直接控制数据在内存之间的传输,减少了 CPU 的负担和参与,提高了数据传输的效率。

DMA 拷贝通常用于大数据量的传输,可以有效减少 CPU 的拷贝操作,提高传输速度。

总结起来,CPU 拷贝是指数据传输需要通过 CPU 进行读取和写入操作,而 DMA 拷贝是指数据传输通过 DMA 控制器直接进行,减少了 CPU 的介入。

在性能敏感的场景中,DMA 拷贝通常比 CPU 拷贝更高效。

什么是四大内存?

零拷贝是指CPU不需要在用户空间和用户空间、用户空间和内核空间之间、内核空间和设备共享内存之间拷贝数据,消耗资源。

  • 用户空间和内核空间

  • 用户空间和内核空间

  • 内核空间和物理设备共享内存

什么是用户空间,什么是内核空间,两个概念说起来虚头巴脑的。尼恩在葵花宝典视频中,给大家详细介绍了的,具体请看大家的葵花宝典视频?

以 Java SOCKET 的IO为例,主要涉及四大内存:

图片

数据发送到网卡的流程

图片

通过到网卡读取数据的流程

图片

接收 SOCKET 数据包的流程

接收SOCKET 数据包是一个复杂的过程,涉及很多底层的技术细节,但大致需要以下几个步骤:

  1. 网卡收到数据包。

  2. 将数据包从网卡硬件缓存转移到服务器内存中。

  3. 通知内核处理。

  4. 经过TCP/IP协议逐层处理。

  5. 应用程序通过read()socket buffer读取数据。

图片

NIC(Network Interface Card ,网络接口卡、网卡)在接收到数据包之后,首先需要将数据同步到内核中,这中间的桥梁是rx ring buffer

rx ring buffer 是由NIC和驱动程序共享的一片区域,事实上,rx ring buffer存储的并不是实际的packet数据,而是一个描述符,这个描述符指向了它真正的存储地址,具体流程如下:

  1. 驱动在内存中分配一片缓冲区用来接收数据包,叫做sk_buffer

  2. 将上述缓冲区的地址和大小(即接收描述符),加入到rx ring buffer。描述符中的缓冲区地址是DMA使用的物理地址;

  3. 驱动通知网卡有一个新的描述符;

  4. 网卡从rx ring buffer中取出描述符,从而获知缓冲区的地址和大小;

  5. 网卡收到新的数据包;

  6. 网卡将新数据包通过DMA直接写到sk_buffer中。

图片

这个时候,数据包已经被接受,已经被转移到了sk_buffer中。

sk_buffer 是驱动程序在内存中分配的一片缓冲区,并且sk_buffer 是通过DMA写入的.

由于使用了 DMA, 这里就不依赖CPU直接将数据写到了内存中,对于内核来说, 减轻了负担。

但是, 硬币都有反面。既然DMA 负责写入的操作,这就意味着对内核来说,其实并不知道已经有新数据到了内存中。

那么如何让内核知道有新数据进来了呢?

答案就是中断,NIC 通过中断告诉内核有新数据进来了,并需要进行后续处理。

当NIC把数据包通过DMA复制到内核缓冲区sk_buffer后,NIC立即发起一个硬件中断。

DMA复制完数据后,NIC首先进入第一步 硬中断部分,网卡中断对应的中断处理程序是网卡驱动程序的一部分,CPU执行中断处理程序, 中断处理程序 它发起软中断,进入第3 软中断处理程序部分,开始消费sk_buffer中的数据,交给内核协议栈处理。协议栈处理把数据复制到 SOCKET 的缓存。

图片

这个时候,数据完成了 从网卡的缓冲区进入到了 receive buffer 接受缓冲区, 这就是 内核缓存区,也是内核空间内存。

图片

DMA 简介:

DMA的全称叫直接内存存取(Direct Memory Access), 是一种允许外围设备(硬件子系统)直接访问系统主内存的机制,也就是,基于DMA访问方式,系统主内存与硬盘或网卡之间的数据传输可以绕开CPU的全程调度.目前大多数的硬件设备,包括磁盘控制器,网卡,显卡以及声卡等支持DMA技术. 

图片

整个数据传输操作在一个DMA控制器下进行的.CPU 除了在数据传输开始和结束时做一点处理外(开始和结束时要做中断处理),在传输过程中CPU可以继续进行其他的工作.

为了减少CPU对快速设备入出的操作,可以通过把这 批数据的传输过程交由一块专用的接口卡(DMA接口)来控制,让DMA卡代替CPU控制在快速设备与主存储器之间直接传输数据。

在DMA模式下,CPU只须向DMA控制器下达指令,让DMA控制器来处理数据的传送,数据传送完毕再把信息反馈给CPU,这样就很大程度上减轻了 CPU资源占有率。

DMA传输的优势:给CPU 带来了解脱 。通过 DMA ,大部分时间里 cpu计算和I/0操作可以处于并行操作,使整个IO的效率大大提升.

基于DMA技术的IO基础流程如下: 

图片

什么是零拷贝?零复制的5大类型:

零拷贝(Zero-Copy)是一种数据传输和处理的优化技术,旨在减少数据在系统内部的复制次数,从而提高数据传输的效率和性能。

在传统的数据传输过程中,数据需要在不同的内存区域之间进行多次复制,这会消耗CPU和内存带宽,降低系统性能。

零拷贝技术通过最小化或消除这些复制操作来提升性能。

在零拷贝技术中,主要有以下几种关键思想和机制:

类型1:用户空间和内核空间共享内存:

零拷贝技术允许用户空间和内核空间共享同一块内存区域,这样数据可以直接在内核和用户空间之间传递,避免了复制的开销。

比如,基于mmap的内存映射技术,如下图所示:

图片

类型2:直接内存访问(DMA):

零拷贝技术使用DMA来实现数据传输,DMA允许外设(如网络适配器、磁盘控制器等)直接访问系统内存,而不需要CPU的干预。

这样数据可以在内存和外设之间直接传递,减少CPU拷贝的步骤。

比如,基于DMA的网卡直接内存复制技术,如下图所示:

图片

类型3:文件描述符传递:

零拷贝技术允许在不同的进程之间传递文件描述符,而不是传递实际的数据。这样可以避免数据的多次复制。

比如,使用sendfile 时,直接传递文件文件描述符和偏移量, 实现全部的DMA 复制。

图片

零拷贝技术允许内核空间进行一些数据的复制操作,但这些复制操作是在内核空间内进行的,不会涉及到用户空间,从而减少了用户空间到内核空间的切换。

类型4:逻辑内存的数据零复制:

逻辑内存的数据零复制,指的是不复制数据, 不去移动底层的 字节数据。只维护字节数据的 元数据信息。

我们知道, Java中的内存,无论是Netty的 ByteBuf ,还是 NIO的 bytebuffer,都是冰山结构。

图片

既然是这种 二元结构,那么就可以进行逻辑层面的内存的数据复制, 不去动底层的 字节数组。

比如:Netty提供CompositeByteBuf类实现逻辑内存的数据零复制。

使用方式:

 

CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer(3);
compositeByteBuf.addComponents(true,ByteBuf1,ByteBuf1);
 

需要注意的是addComponents第一个参数必须为true, 那么writeIndex 才不为0,才能从compositeByteBuf中读到数据. 

图片

通过 逻辑内存的数据零复制,可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了底层数组之间的拷贝.

除此之外, netty的 bytebuf 切片, 也是 逻辑内存的数据零复制 的类型。

图片

类型5:Java通过JNI直接使用本地内存:

Java通过JNI直接使用本地内存,进行本地内存的读写,可以避免本地内存到堆内存直接的一次复制。

图片

在Java中分配直接内存大有如下三种主要方式:

  1. Unsafe.allocateMemory()

  2. ByteBuffer.allocateDirect()

  3. native方法

Unsafe类

Java提供了Unsafe类用来进行直接内存的分配与释放

 

public native long allocateMemory(long var1);
public native void freeMemory(long var1);

示例

 

public class DirectMemoryMain {
public static void main(String[] args) throws InterruptedException {
Unsafe unsafe = getUnsafe();
while (true) {
for (int i = 0; i < 10000; i++) {
long address = unsafe.allocateMemory(10000);
// System.out.println(address);
// unsafe.freeMemory(address);
}
Thread.sleep(1);
}
}

// Unsafe无法直接使用,需要通过反射来获取
private static Unsafe getUnsafe() {
try {
Class clazz = Unsafe.class;
Field field = clazz.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (IllegalAccessException | NoSuchFieldException e) {
throw new RuntimeException(e);
}
}
}

DirectByteBuffer 直接内存

虽然Unsafe可以通过反射调用来进行内存分配,但是按照其设计方式,它并不是给开发者来使用的,而且Unsafe里面的方法也十分原始,更像是一个底层设施。

而其上层的封装则是DirectByteBuffer,这个才是最终留给开发者使用的。

DirectByteBuffer的分配是通过ByteBuffer.allocateDirect(int capacity)方法来实现的。

DirectByteBuffer申请内存的源码如下:

 

DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);

// 计算需要分配的内存大小
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));

// 告诉内存管理器要分配内存
Bits.reserveMemory(size, cap);

// 分配直接内存
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);

// 计算内存的地址
if (pa && (base % ps != 0)) {
address = base + ps - (base & (ps - 1));
} else {
address = base;
}

// 创建Cleaner
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

整个DirectByteBuffer分配过程中,比较需要关注的Bits.reserveMemory()和Cleaner,Deallocator,其中Bits.reserveMemory()与分配相关,Cleaner、Deallocator则与内存释放相关。

零拷贝的三大优势

零拷贝的三大优势:

(1)尽可能避免不必要的CPU拷贝,让CPU解脱出来去执行其他的任务;(2)减少内存带宽的占用;(3)减少用户空间和操作系统内核空间之间的上下文切换;

零拷贝技术在各种场景中都有应用,特别是在高性能网络传输、文件系统操作和数据库访问等方面。

它可以显著提升数据传输的效率,降低系统的资源消耗,从而改善系统的性能。

一些常见的应用包括网络数据传输、文件的读写和数据库查询等。

零拷贝技术的目标是尽可能减少数据在系统内部的复制次数,从而提高数据传输的效率和性能。虽然零拷贝可以减少或避免某些复制操作,但并不是绝对不需要拷贝过程。

什么是CPU上下文切换/ 用户态与内核态切换?

在操作系统中,从用户态切换到内核态(核心态)以及从内核态切换回用户态是操作系统进行上下文切换的过程,涉及到处理器的特权级变更和寄存器保存与恢复。

这些切换是为了保护操作系统和应用程序的稳定性和安全性,同时允许内核执行特权指令。

  1. 从用户态切换到内核态: 当应用程序执行特权级操作(例如系统调用、硬件中断处理、异常处理等)时,需要从用户态切换到内核态。以下是从用户态切换到内核态的基本步骤:

    • 触发切换请求: 应用程序通过系统调用或其他方式触发特权级操作的请求。

    • 硬件响应: 处理器检测到特权级操作的请求,触发硬件中断或异常。

    • 保存用户态上下文: 处理器将应用程序的用户态上下文(寄存器值、程序计数器等)保存到内核态的堆栈或内存区域。

    • 切换到内核态: 处理器切换到内核态,将特权级提升为内核态,并跳转到内核态的处理程序(例如中断处理程序)。

    • 执行内核态操作: 内核态的处理程序执行相应的操作,可能包括对资源的访问、状态更新等。

    • 恢复用户态上下文: 处理器从内核态返回时,将之前保存的用户态上下文从堆栈或内存中恢复,使应用程序继续执行。

  2. 从内核态切换回用户态: 当内核态的操作完成后,需要将处理器从内核态切换回用户态,使应用程序继续执行。以下是从内核态切换回用户态的基本步骤:

    • 保存内核态上下文: 内核态处理程序将内核态的上下文(寄存器值、状态等)保存到内核堆栈或内存中。

    • 切换到用户态: 处理器切换回用户态,将特权级降低为用户态,并跳转到之前用户态的执行位置。

    • 恢复用户态上下文: 处理器从用户态返回时,将之前保存的用户态上下文从堆栈或内存中恢复,使应用程序继续执行。

需要注意的是,上下文切换是一种开销较大的操作,因为涉及到寄存器值的保存和恢复,以及特权级的变更。在多任务操作系统中,频繁的上下文切换可能会影响系统性能。

而在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先帮它设置好 CPU 寄存器和程序计数器(Program Counter,PC)。

CPU 寄存器,是 CPU 内置的容量小、但速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此也被叫做 CPU 上下文

图片

知道了什么是 CPU 上下文,我想你也很容易理解 CPU 上下文切换

CPU 上下文切换,就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

我猜肯定会有人说,CPU 上下文切换无非就是更新了 CPU 寄存器的值嘛,但这些寄存器,本身就是为了快速运行任务而设计的,为什么会影响系统的 CPU 性能呢?

所以,根据任务的不同,CPU 的上下文切换就可以分为几个不同的场景,也就是进程上下文切换线程上下文切换以及中断上下文切换

进程上下文切换

Linux 按照特权等级,把进程的运行空间分为内核空间和用户空间,分别对应着下图中, CPU 特权等级的 Ring 0 和 Ring 3。

  • 内核空间(Ring 0)具有最高权限,可以直接访问所有资源;

  • 用户空间(Ring 3)只能访问受限资源,不能直接访问内存等硬件设备,必须通过系统调用陷入到内核中,才能访问这些特权资源。

图片

换个角度看,也就是说,进程既可以在用户空间运行,又可以在内核空间中运行。进程在用户空间运行时,被称为进程的用户态,而陷入内核空间的时候,被称为进程的内核态。

从用户态到内核态的转变,需要通过系统调用来完成。比如,当我们查看文件内容时,就需要多次系统调用来完成:首先调用 open() 打开文件,然后调用 read() 读取文件内容,并调用 write() 将内容写到标准输出,最后再调用 close() 关闭文件。

那么,系统调用的过程有没有发生 CPU 上下文的切换呢?答案自然是肯定的。

CPU 寄存器里原来用户态的指令位置,需要先保存起来。接着,为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置。最后才是跳转到内核态运行内核任务。

而系统调用结束后,CPU寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程。所以,一次系统调用的过程,其实是发生了两次 CPU 上下文切换。

不过,需要注意的是,系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的:

  • 进程上下文切换,是指从一个进程切换到另一个进程运行。

  • 而系统调用过程中一直是同一个进程在运行。

所以,系统调用过程通常称为特权模式切换,而不是上下文切换。但实际上,系统调用过程中,CPU 的上下文切换还是无法避免的。

那么,进程上下文切换跟系统调用又有什么区别呢?

首先,你需要知道,进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。

因此,进程的上下文切换就比系统调用时多了一步:在保存当前进程的内核状态和CPU寄存器之前,需要先把该进程的虚拟内存、栈等保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。

如下图所示,保存上下文和恢复上下文的过程并不是“免费”的,需要内核在 CPU 上运行才能完成。

图片

根据 Tsuna 的测试报告,每次上下文切换都需要几十纳秒到数微秒的 CPU 时间。这个时间还是相当可观的,特别是在进程上下文切换次数较多的情况下,很容易导致 CPU 将大量时间耗费在寄存器、内核栈以及虚拟内存等资源的保存和恢复上,进而大大缩短了真正运行进程的时间。这也正是上一节中我们所讲的,导致平均负载升高的一个重要因素。

另外,我们知道, Linux 通过 TLB(Translation Lookaside Buffer)来管理虚拟内存到物理内存的映射关系。当虚拟内存更新后,TLB 也需要刷新,内存的访问也会随之变慢。特别是在多处理器系统上,缓存是被多个处理器共享的,刷新缓存不仅会影响当前处理器的进程,还会影响共享缓存的其他处理器的进程。

知道了进程上下文切换潜在的性能问题后,我们再来看,究竟什么时候会切换进程上下文。

显然,进程切换时才需要切换上下文,换句话说,只有在进程调度的时候,才需要切换上下文。Linux 为每个 CPU 都维护了一个就绪队列,将活跃进程(即正在运行和正在等待CPU的进程)按照优先级和等待 CPU 的时间排序,然后选择最需要 CPU 的进程,也就是优先级最高和等待CPU时间最长的进程来运行。

那么,进程在什么时候才会被调度到 CPU 上运行呢?

最容易想到的一个时机,就是进程执行完终止了,它之前使用的CPU会释放出来,这个时候再从就绪队列里,拿一个新的进程过来运行。其实还有很多其他场景,也会触发进程调度,在这里我给你逐个梳理下。

其一,为了保证所有进程可以得到公平调度,CPU时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行。

其二,进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。

其三,当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。

其四,当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行。

最后一个,发生硬件中断时,CPU上的进程会被中断挂起,转而执行内核中的中断服务程序。

了解这几个场景是非常有必要的,因为一旦出现上下文切换的性能问题,它们就是幕后凶手。

线程上下文切换

说完了进程的上下文切换,我们再来看看线程相关的问题。

线程与进程最大的区别在于,线程是调度的基本单位,而进程则是资源拥有的基本单位。说白了,所谓内核中的任务调度,实际上的调度对象是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。所以,对于线程和进程,我们可以这么理解:

  • 当进程只有一个线程时,可以认为进程就等于线程。

  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。

  • 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

这么一来,线程的上下文切换其实就可以分为两种情况:

第一种, 前后两个线程属于不同进程。此时,因为资源不共享,所以切换过程就跟进程上下文切换是一样。

第二种,前后两个线程属于同一个进程。此时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

到这里你应该也发现了,虽然同为上下文切换,但同进程内的线程切换,要比多进程间的切换消耗更少的资源,而这,也正是多线程代替多进程的一个优势。

中断上下文切换

除了前面两种上下文切换,还有一个场景也会切换 CPU 上下文,那就是中断。

为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。

跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。所以,即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文,其实只包括内核态中断服务程序执行所必需的状态,包括CPU 寄存器、内核堆栈、硬件中断参数等。

对同一个 CPU 来说,中断处理比进程拥有更高的优先级,所以中断上下文切换并不会与进程上下文切换同时发生。同样道理,由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便尽可能快的执行结束。

另外,跟进程上下文切换一样,中断上下文切换也需要消耗CPU,切换次数过多也会耗费大量的 CPU,甚至严重降低系统的整体性能。所以,当你发现中断次数过多时,就需要注意去排查它是否会给你的系统带来严重的性能问题。

传统IO中的数据拷贝

  • 传统C程序IO流程中数据拷贝

  • 传统的JAVA程序IO流程中数据拷贝

C传统程序IO过程中数据拷贝

图片

详解C传统程序典型的IO系统调用流程: 写入流程

用户程序通过write 写系统调用,启动io操作。

step 1: write 写系统调用导致进程从用户态切换到内核态。

step 2: sys_write 内核函数,将用户空间内存数据放入内核地址空间缓冲区。

这是一次CPU负责的内存复制。

但是,这次将数据放入另一个与文件或者设备相关的缓冲区.

如果该内核地址缓冲区与套接字相关,叫做socket send buffer,如果与文件系统相关,叫做filesystem cache,

step 3:  当DMA引擎将数据从内核缓冲区传递到协议栈引擎时。

这是一次DMA负责的内存复制,CPU不用参与其中。

step 4: write系统调用返回,进程从内核模式切换到用户模式。

详解C传统程序典型的IO系统调用流程: 读取流程

step 1: read系统调用, 首先导致进程从用户模式切换到内核模式。

在阻塞式的IO模式下, 用户进程等待系统调用返回。系统会等待内核缓存中的数据。

step 2:  数据的最初的副本,由DMA引擎执行复制,复制到内核缓存。

这是一次DMA负责的内存复制,CPU不用参与其中。

DMA引擎读取文件内容(或者网卡协议栈缓存内容),并将其存储到内核地址空间缓冲区中。

step 3: sys_read 内核函数,将数据从内核缓冲区复制到用户缓冲区,然后读取的系统调用返回。

这是一次CPU负责的内存复制。

step 4: read调用返回,导致进程从内核切换回用户模式。

现在,数据存储在用户地址空间缓冲区中,并且进程可以重新开始。

内核空间和用户空间为什么要发生拷贝?

用户程序是绝对无法直接访问内核空间的,无法直接访问任意一块物理内存。

这块其实很复杂,和安全性有关,但主要的原因应该是:

用户空间的内存可能并不指向物理内存,直接访问时可能产生缺页异常。

但是Linux内核禁止在中断时产生缺页异常,否则会产生打印oops错误。

这就要求我们用户的缓冲区数据必须要存在内存中,而这个实际上很难控制,所以就会直接把数据复制到内核缓冲区中。

 

//缺页异常处理函数
asmlinkage void
do_page_fault(unsigned long address, unsigned long mmcsr,
long cause, struct pt_regs *regs)
{
//省略一堆。。
//这里注释已经说的很清楚了,不允许在内核中执行缺页中断
/* If we're in an interrupt context, or have no user context,
we must not take the fault. */
if (!mm || faulthandler_disabled())
goto no_context;
}
//打印oops
no_context:
/* Oops. The kernel tried to access some bad page. We'll have to
terminate things with extreme prejudice. */
printk(KERN_ALERT "Unable to handle kernel paging request at "
"virtual address %016lx\n", address);
die_if_kernel("Oops", regs, cause, (unsigned long*)regs - 16);
do_exit(SIGKILL);
 

Java传统程序IO流程中数据拷贝

图片

传统I/O操作的数据读写流程, 触发

  • 4次上下文切换

  • 4次CPU拷贝

  • 2次DMA拷贝

图片

如果使用直接内存的一次传统网络send操作,则需要2次内存复制,如下图所示:

图片

使用java的堆内存地址值,与JNI堆外内存的地址值不一致的原因是:

由于JVM 的有自己的内存模型,JVM缓冲区起始地址和长度,与JNI堆外内存的值不一致,

所以,不能直接传递给JNI函数去调用底层的C语言内存操作函数.

为什么会在JVM堆内外之间发生数据拷贝

回顾:Linux进程内存布局

JVM虚拟机,对操作系统来说只是一个普通的Linux程序,所以除了Java的内存模型外,JVM整体使用的内存也符合Linux的堆栈区分配。

看下面这张经典的Linux进程内存布局图:

图片

不要把JVM的堆栈和C语言的堆栈弄混了,因为JVM属于C++程序,而我们所谓的Java堆实际上是一个C++对象,所以属于C语言的堆之内。至于Java线程的栈分配在C语言的堆还是栈上就看具体的虚拟机实现了,这个其实区别不大。

对于JVM的堆我们暂且叫做:Java Heap,之外的堆叫做C Heap。

对应的,ByteBuffer有allocate方法用来申请堆内和堆外缓冲区。

Java Heap和C Heap哪个效率更高?

其实差别不大,两个都是由malloc申请来的内存,可以理解为Java Heap属于C Heap的一部分,所以两者的读写效率肯定没什么差别。

但Java Heap有一个很重要的特性就是GC,在堆内GC的时候可能会移动其中的对象。这点很关键,因为这意味着我们的Java对象的内存地址并不是固定的

这也解释了一个常见的疑问:‘Java的hashcode是否是内存地址?’,答案是否定的。因为Java规范要求应用运行其间hashcode值不能变,但Java对象的内存地址是会变化的。

JVM GC其间会移动对象的地址

由于JVM GC其间会移动对象的地址,包括byte[],而内核无法感知到内存的移动,很可能会导致数据错误。

这是因为JNI方法访问的内存区域是一个已经确定了的内存区域地质,那么该内存地址指向的是Java堆内内存的话,那么如果在操作系统正在访问这个内存地址的时候,Java在这个时候进行了GC操作,而GC操作会涉及到数据的移动操作

GC经常会进行先标志再压缩的操作。即,将可回收的空间做标志,然后清空标志位置的内存,然后会进行一个压缩,压缩就会涉及到对象的移动,移动的目的是为了腾出一块更加完整、连续的内存空间,以容纳更大的新对象,数据的移动会使对象的地址发生变化。

为什么会在堆内外之间发生数据拷贝

由于JVM只是一个普通的用户程序,所以涉及到系统功能时JVM必须把功能委托给操作系统提供的系统调用执行,这里我们研究一下IO操作中的write和read函数。

由于JVM GC其间会移动对象的地址,包括byte[],而内核无法感知到内存的移动,很可能会导致数据错误。

所以我们在以byte[]的形式写入数据时必须先把数据拷贝到不受JVM堆控制的堆外内存中,这部分就是我们说的C heap,而DirectByteBuffer正处于这片区域。

同样的,read的时候,我们也要保证在系统往缓冲区写入的时候我们不能gc移动内存,否则数据不知道写到了哪里,所以也会导致拷贝发生。

堆内外之间发生数据拷贝的必然性

所以我们在以byte[]的形式写入数据时必须先把数据拷贝到不受JVM堆控制的堆外内存中,这部分就是我们说的C heap,而DirectByteBuffer正处于这片区域。

同样的,read的时候,我们也要保证在系统往缓冲区写入的时候我们不能gc移动内存,否则数据不知道写到了哪里,所以也会导致拷贝发生。

堆内外之间发生数据拷贝减少的解决方案

直接使用堆外内存,如DirectByteBuffer。

这种方式是直接在堆外分配一个内存(即,native memory)来存储数据,程序通过JNI直接将数据读/写到堆外内存中。

因为数据直接写入到了堆外内存中,所以这种方式就不会再在JVM管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操作了。

这样在进行I/O操作时,只需要将这个堆外内存地址传给JNI的I/O的函数就好了。

接下来,看看零复制的三大常见的  实现技术    

  • DirectBuffer

  • mmap+write

  • sendfile

零复制实现1:用户空间和内核空间共享内存 mmap     

mmap+write零复制 就是其中的一种

mmap+write的代码
 

buf = mmap(file, len);
write(sockfd, buf, len);

用 mmap() 替换 read() 系统调用函数。

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。

图片

mmap用户可以直接操作内核缓冲区内的数据,减少了一次数据CPU拷贝,不影响上下问切换

mmap进行文件IO(读写)

图片

mmap+write零复制进行文件读+Socket写

参考的c代码:

 

tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);

图片

经典使用场景:

rocketmq 使用 mmap+write 发送文件里边的消息。

到这里就只有3次复制,其中只有1次CPU 复制,2次DMA 复制

4次上下文切换

那能不能把CPU 复制减少到没有呢?

零复制实现2:文件描述符传递零复制, sendfile 

sendfile 零复制就是其中的一种

Linux Kernel 2.1 引进了 sendfile(),只需要一个系统调用来实现文件发送。

sendfile 函数

 

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。

图片

2次DMA 来实现数据传输,DMA允许外设(如网络适配器、磁盘控制器等)直接访问系统内存,而不需要CPU的干预。

一次CPU复制在内核空间完成。这样数据可以在内核空间和外设之间直接传递,减少CPU拷贝的步骤,不需要用户空间参与。

sendfile的使用

这种方式可以替换上面的mmap+write方式,如:

 

mmap();

write();

首先,sendfile可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。

替换为

 

sendfile();

这样就减少了2次上下文切换,因为少了一个应用程序发起mmap操作,直接发起sendfile操作。

老版本sendfile减少了上下文切换

图片

(1)sendfile系统调用使DMA引擎将文件内容复制到内核缓冲区中。

然后,数据被内核复制到与套接字关联的内核缓冲区中。

(2)第三份复制发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。

到目前为止,我们已经能够避免让内核创建多个副本,但是我们仍然只剩下一个副本。也可以避免吗?

在内核版本2.4中,修改了套接字缓冲区描述符以适应这些要求-在Linux中称为零拷贝。

DMA Gather

Linux2.4内核进行了优化,提供了gather操作,这个操作可以把最后一次CPU Copy去除

于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

  • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;

  • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

Linux Kernel 2.4 改进了 sendfile(),调用接口没有变化:

 

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

什么原理呢?

就是在内核空间Read Buffer和Socket Buffer不做数据复制,而是将Read Buffer的内存地址、偏移量记录到相应的Socket Buffer中,这样就不需要复制(其实本质就是和虚拟内存的解决方法思路一样,就是内存地址的记录

(1)sendfile系统调用导致DMA引擎将文件内容复制到内核缓冲区中。(2)没有数据被复制到套接字缓冲区中。相反,只有包含数据位置和长度信息的描述符才会附加到套接字缓冲区。DMA引擎将数据直接从内核缓冲区传递到协议引擎,从而消除剩余的最终副本。

DMA Gather零拷贝,数据不会在内核缓冲区之间重复。

新版本sendfile减少了最后一次cpu复制

图片

再来一副来自互联网的图:

图片

内核缓冲区直接拷贝到网卡,一次系统调用,两次上下文切换,两次拷贝,没有cpu拷贝,也就是真正意义上的零拷贝

来自互联网小伙伴的测试数据,在同样的硬件条件下,传统文件传输和零拷拷贝文件传输的性能差异,你可以看到下面这张测试数据图,使用了零拷贝能够缩短 65% 的时间,大幅度提升了机器传输数据的吞吐量。

图片

sendfile这种方法不仅减少了多个上下文切换,而且还消除了处理器进行的数据重复。

到这里就只有2次复制,其中只有0次CPU 复制,2次DMA 复制

在许多 http server 中,都引入了 sendfile 的机制,如 nginx、lighttpd 等,它们正是利用 sendfile() 这个特性来实现高性能的文件发送的。

kafka 使用 sendfile() 发送文件。

rocketmq 有类似的api。

零复制实现3:Java通过JNI直接使用本地内存零复制     

DirectBuffer 就是其中的一种,具体的图解如下:

图片

  • 大龄男的最佳出路是 架构+ 管理

  • 大龄女的最佳出路是 DPM,

  • 6
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值