手写RPC框架--简单理解RPC和零拷贝
1.rpc介绍
rpc 的全称是 Remote Procedure Call,即远程过程调用。从字面上的来看,rpc就是通过网络通信访问另一台机器的应用程序接口。但随着近几年的技术在不断发展,rpc也有了一些新的含义。
目前,我们的rpc组件的基本能力就是屏蔽网络编程细节,实现调用远程方法就跟调用本地(同一个项目中的方法)一样。事实上一个合格的可用于生产的rpc框架还应该具备负载均衡、优雅启停、链路追踪、灰度发布等等功能。这次的课程我们会择优选择其中的一部分功能进行讲解。
2.rpc怎么使用
目前我们对rpc有了一个基本的了解,那 rpc 在项目中应该如何使用呢,如果有同学熟悉dubbo、grpc、或者openFegin那一定很清楚这个问题的答案。
我们就以dubbo为例,大家可以参考dubbo的官网讲解,在springboot中,服务提供者和消费者只要依赖相同接口,就可以使用如下的方式进行远程调用了。
1、服务端只需要使用 @DubboService 注解将对应接口的实现暴露出去。
@DubboService
public class DemoServiceImpl implements DemoService {
@Override
public String sayHello(String name) {
return "Hello " + name;
}
}
2、客户端就可以直接使用 @DubboReference 直接注入使用了。
@Component
public class Task implements CommandLineRunner {
@DubboReference
private DemoService demoService;
@Override
public void run(String... args) throws Exception {
String result = demoService.sayHello("world");
System.out.println("Receive result ======> " + result);
new Thread(()-> {
while (true) {
try {
Thread.sleep(1000);
System.out.println(new Date() + " Receive result ======> " + demoService.sayHello("world"));
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}).start();
}
}
这样就是实现了调用远程接口就和调用本地接口一样的能力。事实上,我们使用 MQ 来处理异步流程、Redis 处理缓存热点数据、MySQL 进行持久化数据,调用其他业务系统接口,都可以说是属于 yrpc 调用,由此可见,rpc 确实是我们日常开发中经常接触的东西,只是被包装成了各种框架,导致我们很少意识到这就是 rpc,让 rpc 变成了我们最“熟悉的陌生人”。
rpc 是整个应用系统的“经络”,这不为过吧?真的很有必要学好 rpc,不仅因为 rpc 是构建复杂系统的基石,还是提升自身认知的利器。
3.rpc的通信流程
rpc能实现调用远程方法就跟调用本地(同一个项目中的方法)一样,*发起调用请求的那一方叫做*调用方,被调用的一方叫做服务提供方。
发起远程调用的核心是网络通信,那我们手动实现rpc框架的核心就是封装通信细节,本小节我们主要和大家一起来思考整个调用过程中的一些流程和细节:
**1、传输协议:**既然 yrpc 存在的核心目的是为了实现远程调用,既然是远程调用那肯定就需要通过网络来传输数据,并且 yrpc 常用于业务系统之间的数据交互,需要保证其可靠性,所以 yrpc 一般默认采用 TCP 来传输。事实上。我们常用的 HTTP 协议也是建立在 TCP 之上的。选择tcp的核心原因还是因为他的效率要比很多应用层协议高很多。
**2、封装一个可用的协议:**选择了合适的传输层协议之后,我们需要基于此建立一个我们自己的通用协议,和http一样需要封装自己的应用层协议,详细的内容会在后边的课程里详细介绍。
**3、序列化:**网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。
**4、压缩:**如果我们觉得序列化后的字节数组体积比较大,我们还可以对他进行压缩,压缩后的字节数组体积更小,能在传输的过程中更加节省带宽和内存。
到这里,一个简单版本的 yrpc 框架就实现了。
那上述几个流程就组成了一个完整的 rpc 吗?
在我看来,还缺点东西。因为对于研发人员来说,这样做要掌握太多的 rpc 底层细节,需要手动写代码去构造请求、调用序列化,并进行网络调用,整个 API 非常不友好。
那我们有什么办法来简化 API,屏蔽掉 rpc 细节,让使用方只需要关注业务接口,像调用本地一样来调用远程呢?
如果你了解 Spring,一定对其 AOP 技术很佩服,其核心是采用动态代理的技术,通过字节码增强对方法进行拦截增强,以便于增加需要的额外处理逻辑。其实这个技术也可以应用到 rpc 场景来解决我们刚才面临的问题。
由服务提供者给出业务接口声明,在调用方的程序里面,rpc 框架根据调用的服务接口提前生成动态代理实现类,并通过依赖注入等技术注入到声明了该接口的相关业务逻辑里面。该代理实现类会拦截所有的方法调用,在提供的方法处理逻辑里面完成一整套的远程调用,并把远程调用结果返回给调用方,这样调用方在调用远程方法的时候就获得了像调用本地接口一样的体验。
4.零拷贝
4.1、零拷贝的概念
学习零拷贝时我们先了解几个buffer缓冲区:
- 当某个程序或已存在的进程需要某段数据时,它只能在用户空间中属于它自己的内存中访问、修改,这段内存暂且称之为
user buffer - 正常情况下,数据只能从磁盘(或其他外部设备)加载到内核的缓冲区,且称之为
kernel buffer - TCP/IP协议栈维护着两个缓冲区:
send buffer和recv buffer,它们合称为socket buffer
(1)DMA操作
DMA 的全称叫直接内存存取(Direct Memory Access),是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。
DMA下读取磁盘数据流程如下:·
- 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。
- CPU 在接收到指令以后对 DMA 磁盘控制器发起调度指令。
- DMA 磁盘控制器对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区,CPU 全程不参与此过程。
- 数据读取完成后,DMA 磁盘控制器会接受到磁盘的通知,将数据从磁盘控制器缓冲区拷贝到内核缓冲区。
- DMA 磁盘控制器向 CPU 发出数据读完的信号,由 CPU 负责将数据从内核缓冲区拷贝到用户缓冲区。
- 用户进程由内核态切换回用户态,解除阻塞状态。
整个数据传输操作是在一个 DMA 控制器的控制下进行的。CPU 除了在数据传输开始和结束时做一点处理外(开始和结束时候要做中断处理),在传输过程中 CPU 可以继续进行其他的工作。这样在大部分时间里,CPU 计算和 I/O 操作都处于并行操作,使整个计算机系统的效率大大提高。
(2)传统读取数据和发送数据
程序传统IO实际上是调用系统的read()和write()实现,通过read()把数据从硬盘读取到内核缓冲区,再复制到用户缓冲区;然后再通过write()写入到socket缓冲区,最后写入网卡设备:
整个过程发生了四次用户态和内核态的切换还有四次IO拷贝, 具体流程是:
- 用户进程通过
read()方法向操作系统发起调用,此时上下文从用户态转向内核态 - DMA控制器把数据从硬盘中拷贝到读缓冲区
- CPU把读缓冲区数据拷贝到应用缓冲区,上下文从内核态转为用户态,
read()返回 - 用户进程通过
write()方法发起调用,上下文从用户态转为内核态 - CPU将应用缓冲区中数据拷贝到socket缓冲区
- DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,
write()返回
(3)零拷贝实现方式
方案一、内存映射(mmap+write)
mmap 是 Linux 提供的一种内存映射文件方法,即将一个进程的地址空间中的一段虚拟地址映射到磁盘文件地址。
mmap 主要实现方式是将**读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,**从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝,然而内核读缓冲区(read buffer)仍需将数据拷贝到内核写缓冲区(socket buffer)。

基于 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次上下文切换。

整个过程发生了2次用户态和内核态的上下文切换和3次拷贝,具体流程如下:
- 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
- DMA控制器把数据从硬盘中拷贝到读缓冲区
- CPU将读缓冲区中数据拷贝到socket缓冲区
- DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,sendfile调用返回
sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。
sendfile 只适用于把数据从磁盘中读出来往 socket buffer 发送的场景
方案三、sendfile+DMA scatter/gather
Linux2.4内核版本之后对sendfile做了进一步优化,通过引入新的硬件支持,这个方式叫做DMA Scatter/Gather 分散/收集功能。
它将读缓冲区中的数据描述信息–内存地址和偏移量记录到socket缓冲区,由 DMA 根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次CPU拷贝的过程。

整个过程发生了2次用户态和内核态的上下文切换和2次拷贝,其中更重要的是完全没有CPU拷贝,具体流程如下:
- 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
- DMA控制器利用scatter把数据从硬盘中拷贝到读缓冲区离散存储
- CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区
- DMA控制器根据文件描述符和数据长度,使用scatter/gather把数据从内核缓冲区拷贝到网卡
- 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次上下文切换。虽然极大地提升了性能,但是需要依赖新的硬件设备支持。