rpc 面试之netty的各种模型


前言

这里记录面经看到的,有的guide里面有的爷就不写了

Netty的线程模型,主从线程模型

大部分网络框架都是基于 Reactor 模式设计开发的。

在 Netty 主要靠 NioEventLoopGroup 线程池来实现具体的线程模型的 。

我们实现服务端的时候,一般会初始化两个线程组:

  1. bossGroup :接收连接。
  2. workerGroup :负责具体的处理,交由对应的 Handler 处理。

单线程模型

一个线程单独处理客户端连接以及 I/O 读写,涉及到 accept、read、decode、process、encode、send 等事件。

对于高负载、高并发,并且对性能要求比较高的场景不适用。

none
//1.eventGroup既用于处理客户端连接,又负责具体的处理。
EventLoopGroup eventGroup = new NioEventLoopGroup(1);
//2.创建服务端启动引导/辅助类:ServerBootstrap
ServerBootstrap b = new ServerBootstrap();
          boobtstrap.group(eventGroup, eventGroup)

多线程模型

一个 Acceptor 线程只负责监听客户端的连接,一个 NIO 线程池负责处理 I/O 读写。

多线程模型满足绝大部分应用场景,并发连接量不大的时候没啥问题,但是遇到并发连接大的时候就可能会出现问题,成为性能瓶颈。

none
// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
  //2.创建服务端启动引导/辅助类:ServerBootstrap
  ServerBootstrap b = new ServerBootstrap();
  //3.给引导类配置两大线程组,确定了线程模型
  b.group(bossGroup, workerGroup)

主从多线程模型

主从Reactor线程模型的特点是:服务端用于接收客户端连接的不再是一个单独的NIO线程,而是一个独立的NIO线程池。Acceptor接收到客户端TCP连接请求处理完成后(可能包含接入认证等),将新创建的SocketChannel注册到IO线程池(sub reactor线程池)的某个IO线程上,由它负责SocketChannel的读写和编解码工作。Acceptor线程池仅仅只用于客户端的登录、握手、安全认证,一旦链路建立成功,就将链路注册到后端subReactor线程池的IO线程上,由IO线程负责后续的IO操作。

利用主从NIO线程模型,可以解决1个服务端监听线程无法有效处理所有客户端连接的性能不足问题。

它的工作流程总结如下:

  1. 从主线程池中随机选择一个Reactor线程作为Acceptor线程,用于绑定监听端口,接收客户端连接
  2. Acceptor线程接收客户端连接请求之后创建新的SocketChannel,将其注册到主线程池的其他Reactor线程上,由其负责接入认证、IP黑名单过滤、握手等操作。
  3. 步骤2完成之后,业务层的链路正式建立,将SocketChannel从主线程池的Reactor线程的多路复用器上摘除,重新注册到Sub线程池的线程上,用于处理IO的读写操作
java
EventLoopGroup bossGroup = new NioEventLoopGroup(1);//创建了用于接收客户端连接的主事件循环组 bossGroup,参数 1 表示线程数为 1。
        EventLoopGroup workerGroup = new NioEventLoopGroup();//创建了用于处理客户端请求的工作事件循环组 workerGroup,采用默认线程数。

        DefaultEventExecutorGroup serviceHandlerGroup = new DefaultEventExecutorGroup(
                RuntimeUtil.cpus() * 2,
                ThreadPoolFactoryUtil.createThreadFactory("service-handler-group", false)
        );
        /....../
         .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            // 30 秒之内没有收到客户端请求的话就关闭连接
                            ChannelPipeline p = ch.pipeline();
                            p.addLast(new IdleStateHandler(30, 0, 0, TimeUnit.SECONDS));
                            p.addLast(new RpcMessageEncoder());
                            p.addLast(new RpcMessageDecoder());
                            p.addLast(serviceHandlerGroup, new NettyRpcServerHandler());//这个handler就会在serviceHandlerGroup上执行
                        }
                    });

漏桶算法

我们可以把发请求的动作比作成注水到桶中,我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。

如果想要实现这个算法的话也很简单,准备一个队列用来保存请求,然后我们定期从队列中拿请求来执行就好了(和消息队列削峰/限流的思想是一样的)。

漏桶算法可以控制限流速率,避免网络拥塞和系统过载。不过,漏桶算法无法应对突然激增的流量,因为只能以固定的速率处理请求,对系统资源利用不够友好。

实际业务场景中,基本不会使用漏桶算法。

令牌桶算法

不过现在桶里装的是令牌了,请求在被处理之前需要拿到一个令牌,请求处理完毕之后将这个令牌丢弃(删除)。我们根据限流大小,按照一定的速率往桶里添加令牌。如果桶装满了,就不能继续往里面继续添加令牌了。

令牌桶算法可以限制平均速率和应对突然激增的流量,还可以动态调整生成令牌的速率。不过,如果令牌产生速率和桶的容量设置不合理,可能会出现问题比如大量的请求被丢弃、系统过载。

了解Netty当中的时间轮吗

TimeWheel 算法稍微有点抽象,是一种实现延迟队列的巧妙且高效的算法,被应用在 Netty,Zookeeper,Kafka 等各种框架中。下边主要实践 Netty 的延时队列讲一下时间轮是什么原理。

image-20240123152340227
image-20240123152340227

wheel :时间轮,图中的圆盘可以看作是钟表的刻度。比如一圈 round 长度为 24 秒,刻度数为 8,那么每一个刻度表示 3 秒。那么时间精度就是 3 秒。时间长度/刻度数值越大,精度越大。

当添加一个定时、延时任务 A,假如会延迟 25 秒后才会执行,可时间轮一圈 round 的长度才 24 秒,那么此时会根据时间轮长度和刻度得到一个圈数 round 和对应的指针位置 index,也是就任务 A 会绕一圈指向 0 格子上,此时时间轮会记录该任务的 round 和 index 信息。当 round=0,index=0 ,指针指向 0 格子任务 A 并不会执行,因为 round=0 不满足要求。

所以每一个格子代表的是一些时间,比如 1 秒和 25 秒都会指向 0 格子上,而任务则放在每个格子对应的链表中,这点和HashMap的数据有些类似。

  • Netty时间轮的主体是一个循环链表,每个链表项维护一个slot的数据结构,slot中用优先队列保存延时任务。
  • 时间轮内部维护一个指针,指针按照设定的速率不断地向前移动,每次移动一个槽位。
  • 当用户添加一个定时任务时,计算任务触发的具体时间,并将任务放入对应的槽位的优先级队列中。
  • 每次指针移动时,都会检查当前指向的槽位,执行该槽位中存放的所有定时任务。由于使用优先级队列,任务按照触发时间的先后顺序进行执行。
  • 如果一个任务在执行前被取消,可以直接从对应槽位的优先级队列中移除。

Netty 构建延时队列主要用 HashedWheelTimer,HashedWheelTimer 底层数据结构依然是使用 DelayedQueue,只是采用时间轮的算法来实现。

如何使用socket实现netty的线程模型

可以用一下流程通过socket实现netty的主从reactor模型

  • 服务端启动,会创建 MainReactor 线程池,在 MainReactor 中创建 NIO 事件选择器,并注册 OP_ACCEPT 事件,然后在指定端口监听客户端的连接请求。
  • 客户端向服务端建立连接,服务端 OP_ACCEPT 对应的事件处理器被执行,创建 NioSocketChannel 对象,并按照负载均衡机制将其转发到 SubReactor 线程池中的某一个线程上,注册 OP_READ 事件。
  • 客户端向服务端发送具体请求,服务端 OP_READ 对应的事件处理器被执行,它会从网络中读取数据,然后解码、转发到业务线程池执行具体的业务逻辑,最后将返回结果返回到客户端。