RPC設計應該使用哪種網絡IO模型?

2023.02.27
RPC設計應該使用哪種網絡IO模型?

零拷貝帶來的好處就是避免沒必要的CPU拷貝,讓CPU解脫出來去做其他的事,同時也減少了CPU在用戶空間與內核空間之間的上下文切換,從而提升了網絡通信效率與應用程序的整體性能。

网络通信在RPC调用中起到什么作用呢?RPC是解决进程间通信的一种方式。一次RPC调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。服务调用者通过网络IO发送一条请求消息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次RPC调用便结束了。可以说,网络通信是整个RPC调用流程的基础。

1 常见网络I/O模型

两台PC机之间网络通信,就是两台PC机对网络IO的操作。

同步阻塞IO、同步非阻塞IO(NIO)、IO多路复用和异步非阻塞IO(AIO)。只有AIO为异步IO,其他都是同步IO。

1.1 同步阻塞I/O(BIO)

Linux默认所有socket都是blocking。

应用进程发起IO系统调用后,应用进程被阻塞,转到内核空间处理。之后,内核开始等待数据,等待到数据后,再将内核中的数据拷贝到用户内存中,整个IO处理完毕后返回进程。最后应用的进程解除阻塞状态,运行业务逻辑。

系统内核处理IO操作分为两阶段:

  • • 等待数据系统内核在等待网卡接收到数据后,把数据写到内核中
  • • 拷贝数据系统内核在获取到数据后,将数据拷贝到用户进程的空间

在这两个阶段,应用进程中IO操作的线程会一直都处于阻塞状态,若基于Java多线程开发,每个IO操作都要占用线程,直至IO操作结束。

用户线程发起read调用后就阻塞了,让出CPU。内核等待网卡数据到来,把数据从网卡拷贝到内核空间,接着把数据拷贝到用户空间,再把用户线程叫醒。

圖片

1.2 IO多路复用(IO multiplexing)

高并发场景中使用最为广泛的一种IO模型,如Java的NIO、Redis、Nginx的底层实现就是此类IO模型的应用:

  • • 多路,即多个通道,即多个网络连接的IO
  • • 复用,多个通道复用在一个复用器

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

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

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

本质上多路复用还是同步阻塞。

1.3 为何阻塞IO,IO多路复用最常用?

网络IO的应用上,需要的是系统内核的支持及编程语言的支持。

大多系统内核都支持阻塞IO、非阻塞IO和IO多路复用,但像信号驱动IO、异步IO,只有高版本Linux系统内核支持。

无论C++还是Java,在高性能的网络编程框架都是基于Reactor模式,如Netty,Reactor模式基于IO多路复用。非高并发场景,同步阻塞IO最常见。

应用最多的、系统内核与编程语言支持最为完善的,便是阻塞IO和IO多路复用,满足绝大多数网络IO应用场景。

1.4 RPC框架选择哪种网络IO模型?

IO多路复用适合高并发,用较少进程(线程)处理较多socket的IO请求,但使用难度较高。

阻塞IO每处理一个socket的IO请求都会阻塞进程(线程),但使用难度较低。在并发量较低、业务逻辑只需要同步进行IO操作的场景下,阻塞IO已满足需求,并且不需要发起select调用,开销比IO多路复用低。

RPC调用大多数是高并发调用,综合考虑,RPC选择IO多路复用。最优框架选择即基于Reactor模式实现的框架Netty。Linux下,也要开启epoll提升系统性能。

2 零拷贝(Zero-copy)

2.1 网络IO读写流程

圖片

应用进程的每次写操作,都把数据写到用户空间的缓冲区,CPU再将数据拷贝到系统内核缓冲区,再由DMA将这份数据拷贝到网卡,由网卡发出去。一次写操作数据要拷贝两次才能通过网卡发送出去,而用户进程读操作则是反过来,数据同样会拷贝两次才能让应用程序读到数据。

应用进程一次完整读写操作,都要在用户空间与内核空间中来回拷贝,每次拷贝,都要CPU进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),这样是不是很浪费CPU和性能呢?那有没有什么方式,可以减少进程间的数据拷贝,提高数据传输的效率呢?

这就要零拷贝:取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都让应用进程向用户空间写入或读取数据,就如同直接向内核空间写或读数据一样,再通过DMA将内核中的数据拷贝到网卡,或将网卡中的数据copy到内核。

2.2 实现

是不是用户空间与内核空间都将数据写到一个地方,就不需要拷贝了?想到虚拟内存吗?

圖片

虚拟内存

零拷贝有两种实现:

mmap+write

通过虚拟内存来解决。

sendfile

Nginx sendfile

3 Netty零拷贝

RPC框架在网络通信框架的选型基于Reactor模式实现的框架,如Java首选Netty。那Netty有零拷贝机制吗?Netty框架中的零拷贝和我之前讲的零拷贝又有什么不同呢?

上节的零拷贝是os层的零拷贝,为避免用户空间与内核空间之间的数据拷贝操作,可提升CPU利用率。

而Netty零拷贝不大一样,他完全站在用户空间,即JVM上,偏向于数据操作的优化。

Netty这么做的意义

傳輸過程中,RPC不會把請求參數的所有二進制數據整體一下子發送到對端機器,中間可能拆分成好幾個數據包,也可能合併其他請求的數據包,所以消息要有邊界。一端的機器收到消息後,就要對數據包處理,根據邊界對數據包進行分割和合併,最終獲得一條完整消息。

那收到消息後,對數據包的分割和合併,是在用戶空間完成,還是在內核空間完成的呢?

當然是在用戶空間,因為對數據包的處理工作都是由應用程序來處理的,那麼這裡有沒有可能存在數據的拷貝操作?可能會存在,當然不是在用戶空間與內核空間之間的拷貝,是用戶空間內部內存中的拷貝處理操作。Netty的零拷貝就是為了解決這個問題,在用戶空間對數據操作進行優化。

那麼Netty是怎麼對數據操作進行優化的呢?

  • • Netty 提供了CompositeByteBuf 類,它可以將多個ByteBuf 合併為一個邏輯上的ByteBuf,避免了各個ByteBuf 之間的拷貝。
  • • ByteBuf 支持slice 操作,因此可以將ByteBuf 分解為多個共享同一個存儲區域的ByteBuf,避免了內存的拷貝。
  • • 通過wrap 操作,我們可以將byte[] 數組、ByteBuf、ByteBuffer 等包裝成一個Netty ByteBuf 對象, 進而避免拷貝操作。

Netty框架中很多內部的ChannelHandler實現類,都是通過CompositeByteBuf、slice、wrap操作來處理TCP傳輸中的拆包與粘包問題的。

Netty解決用戶空間與內核空間之間的數據拷貝

Netty 的ByteBuffer 採用Direct Buffers,使用堆外直接內存進行Socket的讀寫操作,最終的效果與我剛才講解的虛擬內存所實現的效果一樣。

Netty 還提供FileRegion 中包裝NIO 的FileChannel.transferTo() 方法實現了零拷貝,這與Linux 中的sendfile 方式在原理一樣。

4 總結

零拷貝帶來的好處就是避免沒必要的CPU拷貝,讓CPU解脫出來去做其他的事,同時也減少了CPU在用戶空間與內核空間之間的上下文切換,從而提升了網絡通信效率與應用程序的整體性能。

Netty零拷貝與os的零拷貝有別,Netty零拷貝偏向於用戶空間中對數據操作的優化,這對處理TCP傳輸中的拆包粘包問題有重要意義,對應用程序處理請求數據與返回數據也有重要意義。