🐼作者简介:一名大三在校生🎋
空有想法,没有实践,难成大事

专栏前言:探索RPC框架的奥秘

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_rpc

简介:在现代软件开发中,随着微服务架构的普及,远程过程调用(RPC)框架成为了连接服务之间通信的桥梁。我有决定开发了一款高性能的RPC框架,它不仅实现了服务之间的高效调用,还集成了关键的服务治理功能,如负载均衡、熔断机制和限流策略,以确保系统的稳定性和可靠性。

核心技术:本项目采用Netty作为其强大的底层通信组件,确保了网络通信的高效与稳定。同时,通过与ZooKeeper的结合,实现了服务的注册与发现,为服务治理提供了坚实的基础。

下面我将提供一个全面的视角,来理解RPC框架的内部工作原理及其在实际开发中的应用。欢迎大家持续关注订阅专栏!!!


文章目录

  • 专栏前言:探索RPC框架的奥秘
  • 第二章 网络传输
  • 一、零拷贝
  • 1、零拷贝的概念
  • 2、Netty中的零拷贝
  • 二、IO 多路复用
  • 欢迎添加微信,加入我的核心小队,请备注来意


第二章 网络传输

实现远程调用,网络传输是基石,本章我们从网络传输的技术选型上给大家讲解一下,还是这张图:

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_rpc_02

我们可以使用java中的socket api来实现我们的yrpc远程调用框架,但毋庸置疑netty是最好的选择。他提供了非常友好的api供我们使用,同时还完美的提供了IO 多路复用以及零拷贝的实现,作为一个基础框架,性能、扩展性和易用性是最重要的几个方面之一。本课程的主要内容是讲解yrpc,而非netty,所以需要大家先自行学习一下netty相关的知识,这里我们只对面试中常问的io多路复用和零拷贝给大家做一个详细介绍:

一、零拷贝

1、零拷贝的概念

学习零拷贝时我们先了解几个buffer缓冲区:

  • 当某个程序或已存在的进程需要某段数据时,它只能在用户空间中属于它自己的内存中访问、修改,这段内存暂且称之为user buffer
  • 正常情况下,数据只能从磁盘(或其他外部设备)加载到内核的缓冲区,且称之为kernel buffer
  • TCP/IP协议栈维护着两个缓冲区:send bufferrecv buffer ,它们合称为 socket buffer

(1)DMA操作

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

DMA下读取磁盘数据流程如下:·

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

用户进程由内核态切换回用户态,解除阻塞状态。

整个数据传输操作是在一个 DMA 控制器的控制下进行的。CPU 除了在数据传输开始和结束时做一点处理外(开始和结束时候要做中断处理),在传输过程中 CPU 可以继续进行其他的工作。这样在大部分时间里,CPU 计算和 I/O 操作都处于并行操作,使整个计算机系统的效率大大提高。

(2)传统读取数据和发送数据

程序传统IO实际上是调用系统的read()write()实现,通过read()把数据从硬盘读取到内核缓冲区,再复制到用户缓冲区;然后再通过write()写入到socket缓冲区,最后写入网卡设备:

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_rpc_03

整个过程发生了四次用户态和内核态的切换还有四次IO拷贝, 具体流程是:

  1. 用户进程通过read()方法向操作系统发起调用,此时上下文从用户态转向内核态
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区
  3. CPU把读缓冲区数据拷贝到应用缓冲区,上下文从内核态转为用户态,read()返回
  4. 用户进程通过write()方法发起调用,上下文从用户态转为内核态
  5. CPU将应用缓冲区中数据拷贝到socket缓冲区
  6. DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回

(3)零拷贝实现方式

方案一、内存映射(mmap+write)

mmap 是 Linux 提供的一种内存映射文件方法,即将一个进程的地址空间中的一段虚拟地址映射到磁盘文件地址。

mmap 主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝,然而内核读缓冲区(read buffer)仍需将数据拷贝到内核写缓冲区(socket buffer)。

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_php_04


基于 mmap + write 系统调用的零拷贝方式,整个过程发生了4次用户态和内核态的上下文切换和3次拷贝,具体流程如下:

  • 用户进程通过mmap()方法向操作系统发起调用,上下文从用户态转向内核态
  • DMA控制器把数据从硬盘中拷贝到读缓冲区
  • 上下文从内核态转为用户态,mmap调用返回
  • 用户进程通过write()方法发起调用,上下文从用户态转为内核态
  • CPU将读缓冲区中数据拷贝到socket缓冲区
  • DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回
  • mmap 主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存。

方案二、sendfile

通过使用sendfile函数,数据可以直接在内核空间进行传输,因此避免了用户空间和内核空间的拷贝,同时由于使用sendfile替代了read+write从而节省了一次系统调用,也就是2次上下文切换。

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_php_05


整个过程发生了2次用户态和内核态的上下文切换和3次拷贝,具体流程如下:

  1. 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区
  3. CPU将读缓冲区中数据拷贝到socket缓冲区
  4. DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,sendfile调用返回

sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。

sendfile 只适用于把数据从磁盘中读出来往 socket buffer 发送的场景

方案三、sendfile+DMA scatter/gather

Linux2.4内核版本之后对sendfile做了进一步优化,通过引入新的硬件支持,这个方式叫做DMA Scatter/Gather 分散/收集功能。

它将读缓冲区中的数据描述信息–内存地址和偏移量记录到socket缓冲区,由 DMA 根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次CPU拷贝的过程。

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_网络协议_06


整个过程发生了2次用户态和内核态的上下文切换和2次拷贝,其中更重要的是完全没有CPU拷贝,具体流程如下:

  1. 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
  2. DMA控制器利用scatter把数据从硬盘中拷贝到读缓冲区离散存储
  3. CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区
  4. DMA控制器根据文件描述符和数据长度,使用scatter/gather把数据从内核缓冲区拷贝到网卡
  5. sendfile()调用返回,上下文从内核态切换回用户态

DMA gather和sendfile一样数据对用户空间不可见,而且需要硬件支持,同时输入文件描述符只能是文件,但是过程中完全没有CPU拷贝过程,极大提升了性能。

总结:

  • 由于CPU和IO速度的差异问题,产生了DMA技术,通过DMA搬运来减少CPU的等待时间。
  • 传统的IO read/write方式会产生2次DMA拷贝+2次CPU拷贝,同时有4次上下文切换。
  • 通过mmap+write方式则产生2次DMA拷贝+1次CPU拷贝,4次上下文切换,通过内存映射减少了一次CPU拷贝,可以减少内存使用,适合大文件的传输。
  • sendfile方式是新增的一个系统调用函数,产生2次DMA拷贝+1次CPU拷贝,但是只有2次上下文切换。因为只有一次调用,减少了上下文的切换,但是用户空间对IO数据不可见,适用于静态文件服务器。
  • sendfile+DMA gather方式产生2次DMA拷贝,没有CPU拷贝,而且也只有2次上下文切换。虽然极大地提升了性能,但是需要依赖新的硬件设备支持。

2、Netty中的零拷贝

操作系统层面的零拷贝主要避免在用户态(User-space)内核态(Kernel-space)之间来回拷贝数据。

Netty中的 zero-copy 不同于操作系统,它完全是在用户态(java 层面),更多的偏向于优化数据操作这样的概念,体现在:

  • Netty 提供了 CompositeByteBuf 类, 它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf, 避免了各个 ByteBuf 之间的拷贝
  • 通过 wrap 操作, 我们可以将 byte[] 数组、ByteBufByteBuffer等包装成一个 Netty ByteBuf 对象, 进而避免了拷贝操作
  • ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf, 避免了内存的拷贝
  • 通过 FileRegion 包装的FileChannel.transferTo 实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel, 避免了传统通过循环 write 方式导致的内存拷贝问题

上述的 Netty 包装了 FileChannel.transferTo 实际上也是对操作系统 sendfile 的一个封装, 我们可以理解为 Netty 即支持了系统层面的零拷贝, 还有一个重要作用就是:防止在 JVM 中进行不必要的复制

(1)ByteBuf
ByteBuf是Netty进行数据读写交互的单位,结构如下:

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_网络协议_07

  1. ByteBuf 是一个字节容器,容器里面的的数据分为三个部分,第一个部分是已经丢弃的字节,这部分数据是无效的;第二部分是可读字节,这部分数据是 ByteBuf 的主体数据,
  2. 从 ByteBuf 里面读取的数据都来自这一部分;最后一部分的数据是可写字节,所有写到 ByteBuf 的数据都会写到这一段。最后一部分虚线表示的是该 ByteBuf 最多还能扩容多少容量
  3. 以上三段内容是被两个指针给划分出来的,从左到右,依次是读指针(readerIndex)、写指针(writerIndex),然后还有一个变量 capacity,表示 ByteBuf 底层内存的总容量
  4. 从 ByteBuf 中每读取一个字节,readerIndex 自增1,ByteBuf 里面总共有 writerIndex-readerIndex 个字节可读,当 readerIndexwriterIndex 相等的时候,ByteBuf 不可读
  5. 写数据是从 writerIndex 指向的部分开始写,每写一个字节,writerIndex 自增1,直到增到 capacity,这个时候,表示 ByteBuf 已经不可写了
  6. ByteBuf 里面其实还有一个参数 maxCapacity,当向 ByteBuf 写数据的时候,如果容量不足,那么这个时候可以进行扩容,直到 capacity 扩容到 maxCapacity,超过 maxCapacity 就会报错

(2)CompositeByteBuf 零拷贝

Composite buffer实现了透明的零拷贝,将物理上的多个 Buffer 组合成了一个逻辑上完整的 CompositeByteBuf.

比如在网络编程中, 一个完整的 http 请求常常会被分散到多个 Buffer 中。用 CompositeByteBuf 很容易将多个分散的Buffer组装到一起,而无需额外的复制:

ByteBuf header = Unpooled.buffer();// 模拟http请求头
ByteBuf body = Unpooled.buffer();// 模拟http请求主体
CompositeByteBuf httpBuf = Unpooled.compositeBuffer();
// 这一步,不需要进行header和body的额外复制,httpBuf只是持有了header和body的引用
// 接下来就可以正常操作完整httpBuf了
httpBuf.addComponents(header, body);

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_网络_08

而 JDK ByteBuffer 完成这一需求:

ByteBuffer header = ByteBuffer.allocate(1024);// 模拟http请求头
ByteBuffer body = ByteBuffer.allocate(1024);// 模拟http请求主体

// 需要创建一个新的ByteBuffer来存放合并后的buffer信息,这涉及到复制操作
ByteBuffer httpBuffer = ByteBuffer.allocate(header.remaining() + body.remaining());
// 将header和body放入新创建的Buffer中
httpBuffer.put(header);
httpBuffer.put(body);
httpBuffer.flip();

相比于JDK,Netty的实现更合理,省去了不必要的内存复制,可以称得上是JVM层面的零拷贝

(3)wrap 操作实现零拷贝

例如我们有一个 byte 数组, 我们希望将它转换为一个 ByteBuf 对象, 以便于后续的操作, 那么传统的做法是将此 byte 数组拷贝到 ByteBuf 中, 即:

byte[] bytes = ...
ByteBuf byteBuf = Unpooled.buffer();
byteBuf.writeBytes(bytes);

这样的操作是有一次额外的拷贝,如果使用Unpooled相关的方法,包装这个byte数组生成一个新的的ByteBuf,而不需要进行拷贝,如:

byte[] bytes = ...
ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);

Unpooled.wrappedBuffer 方法来将 bytes 包装成为一个 UnpooledHeapByteBuf 对象, 而在包装的过程中, 是不会有拷贝操作的. 即最后我们生成的生成的 ByteBuf 对象是和 bytes 数组共用了同一个存储空间, 对 bytes 的修改也会反映到 ByteBuf 对象中

Unpooled 提供的方法可以将一个或多个 buffer 包装为一个 ByteBuf 对象, 从而避免了拷贝操作.

(4)通过 slice 操作实现零拷贝

slice 操作和 wrap 操作刚好相反, Unpooled.wrappedBuffer 可以将多个 ByteBuf 合并为一个 而 slice 操作将一个 ByteBuf 切片为多个共享一个存储区域的 ByteBuf 对象,如:

ByteBuf byteBuf = ...
ByteBuf header = byteBuf.slice(0, 5);
ByteBuf body = byteBuf.slice(5, 10);

用 slice 方法产生 byteBuf 的过程是没有拷贝操作的, headerbody 对象在内部其实是共享了 byteBuf 存储空间的不同部分而已。

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_rpc_09

二、IO 多路复用

常见的网络 IO 模型分为四种:

  • 同步阻塞 IO(BIO)
  • 同步非阻塞 IO(NIO)
  • IO 多路复用
  • 异步非阻塞 IO(AIO)

在这四种 IO 模型中,只有 AIO 为异步 IO,其他都是同步 IO

下图是应用程序发起一次网络IO的流程:

【探索RPC框架的奥秘】网络传输之零拷贝与IO多路复用技术_jvm_10


第一步:应用程序发起IO申请(阻塞和非阻塞)

第二步:内核执行方法调用(同步和异步)

多路复用 IO 是在高并发场景中使用最为广泛的一种 IO 模型,如 Java 的 NIO、Redis、Nginx 的底层实现就是此类 IO 模型的应用,经典的 Reactor 模式也是基于此类 IO 模型。

那么什么是 IO 多路复用呢?通过字面上的理解,多路就是指多个通道,也就是多个网络连接的 IO,而复用就是指多个通道复用在一个selector上。

多个网络连接的 IO 可以注册到一个selector上,当用户进程调用了 select,那么整个进程会被阻塞。同时,内核会“监视”所有 selector 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从内核中拷贝到用户进程。

这里我们可以看到,当用户进程发起了 select 调用,进程会被阻塞,当发现该 select 负责的 socket 有准备好的数据时才返回,之后才发起一次 read,整个流程要比阻塞 IO 要复杂,似乎也更浪费性能。但它最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

同样好比我们去餐厅吃饭,这次我们是几个人一起去的,我们专门留了一个人在餐厅排号等位,其他人就去逛街了,等排号的朋友通知我们可以吃饭了,我们就直接去享用了。

yrpc 调用在大多数的情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及 IO 模型本身的特点,在 yrpc 框架的实现中,在网络通信的处理上,我们会选择 IO 多路复用的方式。开发语言的网络通信框架的选型上,我们最优的选择是基于 Reactor 模式实现的框架,如 Java 语言,首选的框架便是 Netty 框架(Java 还有很多其他 NIO 框架,但目前 Netty 应用得最为广泛),并且在 Linux 环境下,也要开启 epoll 来提升系统性能(Windows 环境下是无法开启 epoll 的,因为系统内核不支持)。