从零开始实现一个简单的RPC框架(4)


关于请求处理的具体逻辑:编码/解码->消息的派发->业务处理->返回响应,这些逻辑能否通过合理分配到具体线程上,设计合理的线程模型,是能否得到高性能的网络服务的关键

1. 说说什么是Reactor模型

Reactor模型是基于IO多路复用+线程池开发出来的一种模型,它的核心设计是事件驱动能够处理一个或者多个输入源多路复用,能够分发事件给对应的处理器

什么叫多路复用

分用和复用是比较重要的概念,比如说在传输层中就存在这种设计,比如说在当TCP连接为上层的应用程序交付数据的时候,根据TCP报文的首部的目的端口完成分用,这里之所以说是分用,是因为说这些数据在底层是基于同一个运输层协议TCP协议进行传输的,而最终却能够交付到不同的进程,产生了分叉,这种就叫做复用

至于复用,就是说从应用层传递下来的报文,能够通过相同的传输层协议传输到对等端完成分用。

而这里的I/O多路复用,它指的是当多个I/O输入源来的时候,都会通过一个或者多个请求处理器完成。

复用体现在:一段时间内,多个请求复用了同一个进程/线程,而不是说像BIO那样,一个线程就对应于一个请求,从而节约了线程的创建与销毁甚至是上下文切换的开销。

那么Reactor模型就是这样的一种模型

一般来说,Reactor模型具有两种角色,第一种是Reactor,它负责解析请求,并且将这个请求分发给具体的处理线程,第二种是Handler模型角色,它负责处理具体的任务,对于任务而言,可以分为连接请求的任务和读写请求的任务,因此Handler又可以细分为:

  • Handler:负责处理具体的读写任务
  • Acceptor:负责处理连接请求,负责将连接队列中的socket进行accpet()

2. 什么是单Reactor单线程模型

如图所示,在这个单Reactor单线程模型中,可以这样进行解读

首先它的模型非常简单,一开始有一个连接请求到来的时候,它的调用链是:先经过Reactor,然后Reactor判断它是一个连接请求,然后就会将这个请求分发给具体的Acceptor,然后Acceptor通过调用accpet()方法来接收这个请求,将这个socket注册到底层的epoll上,进行fd之间的数据传输。

如果是一个读写请求来的时候,那么就会将fd取出来,然后在内核中调用I/O函数,这个过程实际上就是将读写事件分发给提前注册好的Handler进行处理,由于是单线程模型,因此Handler也是单线程的,这个线程需要完成读取数据业务处理发送响应,其中读取数据发送响应都可能涉及到I/O,因此这时候,如果有线程因为I/O操作而阻塞,这就会导致线程资源的浪费,而且单线程模型无法充分利用多核CPU的特点。

CPU利用率为什么低?首先以常规的select/poll来说,这些读写请求都需要内核利用CPU对这些FD进行轮询,这就会导致CPU空转,从而利用率就低了,还有一个问题就是,一个线程只能跑在一个CPU上,一旦某个线程获取了CPU,但是耗时长,就会导致无法接收其他请求。

这也就是说,ReactorHandlerAcceptor都是同一个线程在执行操作,一个环节的操作耗时将会影响其他环节。

3. 什么是单Reactor和多线程模型

单Reactor和单线程模型中,我们说到了关于线程只有一个的问题,最主要的问题是无法充分利用多核CPU以及在请求到来的时候,只能串行执行请求,当因为IO时间阻塞的时候,无法工作,CPU无法被利用,而通常来说,最为耗时的是执行业务逻辑的时候,所以如果可以在这时候就可以开启多个线程,这样的话就解耦了Reactor部分Handler线程的操作,当Handler阻塞或者耗时长的时候,可以将线程换下CPU,从而达到一个调度的平衡,提高系统的吞吐量

有什么缺点?在这个模型中,Reactor的工作依然是由一个线程来完成的,也就是说无论是读写请求还是建立连接的请求,都会打到同一个线程上,这是啥意思呢?比如说现在有很多个连接同时到来,那么这些连接的建立面临着和请求分发的竞争的问题

假设一个场景,连接同时到来,然后Reactor忙着给这些读写请求分发Handler,但是这时候大批量的连接请求涌进来,那么这些请求就得不到响应了,会被直接丢弃。从而出现了服务拒绝的现象。

4. 什么是主从 Reactor和多线程模型

为了解决单Reactor多线程中,一个Reactor同时负责接收多种请求的问题,因此可以采取一种方案,再将读写请求和读写请求的线程进行分离,当他们进行分离之后,可以达到一种效果:

一个线程专门负责分发请求,一个线程专门负责分发连接建立请求,这样的话两个线程同时工作,可以显著地提高系统的并发量

Reactor主线程接收到连接事件,然后给Acceptor处理,网络连接建立之后,主的Reactor会将连接分配给子Reactor进行后续的监听,子Reactor分配到连接之后,负责监听该连接上的读写事件,读写事件到来的时候,会分发给Worker线程池的Handler进行处理。

5. 说说Netty的线程模型

Netty针对服务端的设计是在主从Reactor多线程模型的基础上进行的修改。

基于主从Reactor模型,它将主ReactorAcceptor单独抽象出来成了一个BossGroup

将从ReactorHandler(不包含Acceptor)抽象成了WorkerGroup

  • BossGroup里的线程会监听连接事件,与客户端建立了网络连接之后,会构造出一个连接描述对象,称为NioSocketChannel,表示这是一条连接对象,之后就会将这个连接对象注册到WorkerGroup中的某个线程上负责监听
  • WorkerGroup里的线程会监听这条连接上的读写事件,当监听到读写事件的时候,会通过pipeline添加的多个Handler进行处理。

总结:Netty是基于主从Reactor进行开发的,其中主ReactorAcceptor被抽象为了BossGroup,这个对象的任务主要是描述连接要怎么建立,建立完毕之后要如何处理,要分发到哪里去,具体的可以表现为将建立连接的请求到来的时候,将这个请求分发到其中的一个处理线程Acceptor,然后当处理当这个连接处理完毕后,就可以建立一个连接描述对象,叫做NioEventChannel,然后将这个对象注册到WorkerGroup中的某个线程上,这个线程负责具体的逻辑处理。

6. Netty的核心组件有哪些?怎么用的?

EventLoopGroup / EventLoop:一个EventGroup当中会包含一个或者多个EventLoop,EventLoop提供了next()方法,通过此方法可以获取某一个可用的线程,它从表面上看是一个不断循环的线程

EventLoop最常见的实现类是NioEventLoop,一个NioEventLoop包含了一个selector对象,可以支持多个Channel注册在其上,该NioEventLoop可以同时服务多个Channel,这多个Channel同时被这一个NioEventLoop实现了多路复用,它不是一个纯粹的IO线程,除了负责IO的读写之外,还需要兼顾下面的任务

  • 系统任务:通过调用 NioEventLoop 的 execute(Runnable task) 方法实现,Netty 有很多系统任务,当 I/O 线程和用户线程同时操作网络资源时,为了防止并发操作导致的锁竞争,将用户线程的操作封装成任务放入消息队列中,由 I/O 线程负责执行,这样就实现了局部无锁化。
  • 定时任务:通过调用 NioEventLoop 的 schedule(Runnable command, long delay, TimeUnit unit) 方法实现。

Channel是对网络连接的抽象,其核心功能就是执行网络I/O操作,是服务端和客户端进行I/O数据交互的媒介

  • 当客户端连接成功后,将新建一个Channel
  • ChannelEventLoopGroup获得一个EventLoop,并且注册上去
  • Channel与客户端进行网络连接,关闭和读写,生成相对应的event,改变了selectinKey信息,触发EventLoop调度线程进行执行
  • 如果是读事件,那么就执行线程调度pieline来处理逻辑

事件是怎么触发的,在Channel注册到上面的时候,会产生一个selectionKey,当有事件到来的时候,这个selectionKey会被修改,当它被selector查询到的时候,就会触发响应的回调函数

什么是pipeline

pipeline是一组事件处理的逻辑链表,一个PipeLine由多个Handler串成一个有序的链表,一个Handler处理完了之后,调用next()即可进行下一步的处理

它的调用链既可以是正向的,也可以是反向的,处理入站的事件,Handler按照正向的顺序执行,处理出站事件的时候,Handler就可以按照反向的顺序执行

什么是ByteBuf

当需要以字节的形式发送和接收数据的时候,发送端和消费端都需要一个高效的数据容器来存储字节数据

ByteBuf简单来说就是一个字节数组,它维护了一个读索引写索引,分别用来控制对ByteBuf中数据的读写操作,还有一个capacity用来记录缓冲区的总长度,当写数据超过capacity的时候,ByteBuf会自动扩容,直到达到maxCapacity

关于ByteBuf的分类

  • 什么是堆缓冲区?

堆缓冲区其实就是在JVM的堆空间中分配一个字节数组,堆缓冲可以实现快速分配,由GC收集器管理

  • 什么是直接缓冲区?

直接缓冲区使用的是直接内存,不会占用JVM的空间,在完成Socket数据传输的时候有较好的读写性能,因为不需要从内核缓冲区拷贝到用户进程缓冲区,但是在分配内存空间和释放内存的时候,会比较复杂,可能造成内存泄漏等问题

  • 什么是复合缓冲区?

我们可以创建多个不同的 ByteBuf,然后提供一个这些 ByteBuf 组合的视图,也就是 CompositeByteBuf。它就像一个列表,可以动态添加和删除其中的 ByteBuf。
例如:一条消息由 header 和 body 两部分组成,将 header 和 body 组装成一条消息发送出去,可能 body 相同,只是 header 不同,使用CompositeByteBuf 就不用每次都重新分配一个新的缓冲区。

7. 什么是内存的池化技术?

Unpolled:非池化的内存管理方式,每次分配的时候都直接调用系统API向操作系统申请ByteBuf,在使用完成之后,通过系统调用进行释放,Unpooled是将内存管理直接交给系统,不做任何的特殊处理,使用起来会比较方便,对于申请和释放操作不频繁,操作成本比较低的ByteBuf来说,是比较好的选择

Pooled:是池化的内存管理方式,这个方式来预先申请一大块的内存形成内存池,在需要申请ByteBuf的时候,会将一部分合理的空间封装成ByteBuf给服务使用,使用完成后会回收到内存池中。


文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录