Netty学习系列3—解析netty架构与多线程运用

Netty架构剖析

Netty线程模型

Reactor单线程模型

所有的I/O操作都在同一个NIO线程上完成
在这里插入图片描述

Reactor多线程模型

区别是:有一组NIO线程来处理I/O操作。
特点:

  1. 有专门一个NIO线程–Acceptor线程用于监听服务端,接受客户端TCP连接请求。
  2. 网络I/O操作由一个NIO线程池负责。
  3. 一个NIO线程可以同时处理N条链路,但一个链路只能对一个NIO线程。
主从Reactor多线程模型

特点是:
服务端用于接受客户端连接的不再是一个单独的NIO线程,而是一个独立的NIO线程池。
Acceptor接收到客户端TCP连接请求并处理完成后,将新创建的SocketChannel注册到I/O线程池(sub reactor线程池)的某个I/O线程上,由它负责SocketChannel的读写和编解码工作。

Acceptor线程池仅仅用于客户端的登录,握手和安全认证,一旦链路建立成功,就将链路注册到后端subReactor线程池的I/O线程上,由I/O线程负责后续的I/O操作。

Netty线程模型

它是多变灵活的,会取决于用户的启动参数配置,可以同时支持上面三种线程模型。

过程 △△△

服务端启动的时候,创建两个NioEventLoopGroup,实际是两个独立的Reactor线程池。(Boss)一个用于接受客户端的TCP连接,(Worker)另一个用于处理I/O相关的操作,或者执行Task等,

Boss线程池职责:

  1. 接受客户端的TCP连接,初始化Channel参数。
  2. 将链路注册到状态下变更事件通知给ChannelPipeline。

Worker线程池职责:
3. 异步读取通信对端的数据报,发送读事件到ChannelPipeline。
4. 异步发送消息到通信对端, 调用ChannelPipelineV的消息发送接口。
5. 执行系统调用Task。
6. 执行定时任务Task,例如链路空闲状态检测定时任务。

1. Reactor单线程: 所有的I/O操作都在同一个NIO线程上面完成,

线程职责:
作为服务端, 接收客户端的TCP连接。
作为客户端, 向服务端发起TCP连接。
读取通信对端的请求或者应答消息。
向通信对端发送消息请求或则应答消息。

由于Reactor模式使用的是异步非阻塞I/O, 所有的I/O操作都不会导致阻塞, 理论上一个线程可以独立处理所有I/O相关的操作。从架构层面看, 一个NIO线程确实可以完成起承担的职责。

比如可以拿一个Acceptor接收客户端的TCP连接请求消息, 链路建立成功之后, 通过Dispatch将对应的ByteBuffer派发到指定的Handler上进行消息解码。用户Handler可以通过NIO线程将消息发送给客户端。

小容量的场景可以用单线程, 但不适应高负载、 大并发的应用因为:
无法支撑成百上千的链路。
负载过重, 处理速度变慢, 连接超时, 重发, 进而加重, 导致大量消息积压和处理超时 成为系统的性能瓶颈。
影响可靠性, 一旦NIO线程意外运行, 或进入死循环, 导致整个系统通信模块不可用, 节点故障。

2. Reactor多线程

模式特点:

  1. 专门的NIO线程: Acceptor 用于监听服务端, 接收客户端的TCP连接请求。
  2. 网络I/O操作: 读写等由一个NIO线程池负责, 线程池可以采用标准的JDK线程池实现, 它包含一个任务队列和N个可用的线程, 由这些NIO线程负责消息的读取解码编码和发送。
  3. 1个NIO线程可以同时处理N条链路, 但是一个链路只对应一个NIO线程, 以防发生并发操作问题
    对于绝大多数场景下, Reactor多线程模型都可以满足性能需求。

特殊应用场景下, 一个NIO线程负责监听和处理所有的客户端连接可能会存在性能问题。例如,百万客户端并发连接, 或者服务端需要对客户端的握手消息进行安全认证, 认证本身非常损耗性能。所以出现主从。

3. 主从Reactor

模式特点:
服务端用于接收客户端连接的不再是一个单独的客户端线程, 而是一个独立的NIO线程池.

Acceptor 接收到客户端TCP连接请求处理完成后(可能包括介入认证等), 将新创建的SocketChannel注册到I/O线程池(sub reactor线程池) 的某个I/O线程上, 由它负责SocketChannel的读写和辩解码工作. Acceptor线程池只用于客户端的登录、握手和安全认证, 一旦链路建立成功, 就将链路注册到后段subReactor线程池的I/O线程上, 由I/O线程负责后续的I/O操作.

利用主从NIO线程模型, 可以解决1个服务端舰艇线程无法处理所有客户端连接的性能不足问题. 因此, Netty官方推荐使用该线程模型.

Netty单线程
EventLoopGroup reactorGroup = new NioEventLoopGroup(1);
try{
  ServerBootstrap b = new ServerBootstrap();
  b.group(reactorGroup, reactorGroup).channel(NioServerSocketChannel.class)
}
Netty多线程
EventLoopGroup acceptorGroup = new NioEventLoopGroup(1);
EventLoopGroup IOGroup = new NioEventLoopGroup();
try{
  ServerBootstrap b = new ServerBootstrap();
  b.group(acceptorGroup, IOGroup).channel(NioServerSocketChannel.class)
}
Netty主从
EventLoopGroup acceptorGroup = new NioEventLoopGroup();
EventLoopGroup IOGroup = new NioEventLoopGroup();
try{
  ServerBootstrap b = new ServerBootstrap();
  b.group(acceptorGroup, IOGroup).channel(NioServerSocketChannel.class)
}

Reactor通信调度层

该层的主要职责就是监听网络读写和连接操作. 负责将网络层的数据读取到内存缓冲区中, 然后出发各种网络事件, 然后触发各种网络事件, 例如连接创建、连接激活、 读事件、写事件等, 将这些事件触发到PipeLine中, 由PipeLine管理的职责链进行后续处理。

由一系列辅助类完成:

包括reactor线程NioEventLoop及其父类,
NioSocketChannel/NioServerSocketChannel以及其父类
ByteBuffer以及由其衍生出来的各种Buffer
Unsafe以及其衍生出的各种内部类等

职责链 ChannelPipeline

负责事件在职责链中的有序传播,同时负责动态的编排。可以选择监听和处理自己关心的事件,它可以拦截处理和向后/向前传播事件。
不同应用的Handler节点的功能也不同,通常情况下,往往会开发编解码Handler,可以将外部的协议消息转换成内部的POJO对象,这样上层业务则只需要关心处理业务逻辑即可,不需要感知底层的协议差异和线程模型差异,实现了架构层面的分层隔离。

关键架构质量属性

高性能

Netty高性能的具体实现:

  1. 采用异步非阻塞的I/O类库, 基于Reactor模式实现, 解决了传统同步阻塞I/O模式下一个服务端无法平滑地处理线性增长的客户端的问题
  2. TCP接收和发送缓冲区使用直接内存代替堆内存, 避免了内存复制, 提升I/O读取和写入的性能.
  3. 支持通过内存池的方式循环利用ByteBuf, 避免了频繁创建和小会ByteBuf带来的性能损耗
  4. 可配置的I/O线程数, TCP参数等, 为不同的用户场景提供定制化的调优参数, 满足不同的性能场景
  5. 采用环形数组缓冲区实现无锁化并发编程, 代替传统的线程安全容器或者锁
  6. 合理地使用线程安全容器、原子类等, 提升系统的并发处理能力
  7. 关键资源的处理使用单线程串行化的方式, 避免多线程并发访问带来的锁竞争和额外的CPU资源消耗问题
  8. 通过引用计数器及时地申请释放不再被引用的对象, 细粒度的内存管理降低了GC的频率, 减少了频繁GC带来的时延增大和CPU损耗
可靠性
  1. 链路有效性检测
  1. 链路由长连接建立, 不需要每次创建, 可以一直保持。
  2. 相应产生的问题, 比如链路空闲问题,也被心跳机制所解决. 为了支持心跳, Netty提供了两种链路空闲检测机制。
  1. 读空闲超时机制: 当连续周期T没有消息可读时, 触发超时Handler用户可以基于读空闲超时发送心跳消息, 进行链路检测; 如果连续N个周期仍然没有读取到心跳消息, 可以主动关闭链路。
  2. 写空闲超时机制: 当连续周期T没有消息要发送时, 触发超时Handler, 用户可以基于写空闲超时发送心跳信息, 进行链路检测; 如果连续N个周期。
  1. 内存保护机制
    Netty提供多种机制对内存进行保护, 包括以下几个方面:

通过对象引用计数器对Netty的ByteBuf等内置对象进行细粒度的内存申请和释放, 对非法的对象引用进行检测和保护
通过内存池来重用ByteBuf节省内存
可设置的内存容量上限, 包括ByteBuf、线程池线程数等

  1. 优雅停机
    指的是当系统退出时, JVM通过注册的ShutdownHook拦截到退出的信号量, 然后执行退出操作, 释放相关模块的资源占用, 将缓冲区的消息处理完成或者清空, 将待刷新的数据持久化道磁盘或者数据库中, 等到资源回收和缓冲区消息处理完成之后, 再退出.
可定制性
  1. 责任链模式: ChannelPipeline 基于责任链模式开发, 便于业务逻辑的拦截、 定制和扩展
  2. 基于接口的开发: 关键的类库都提供了接口或者抽象类, 如果Netty自身的实现无法满足用户的需求, 可以由用户自定义实现相关接口
  3. 提供了大量工厂类, 通过重载这些工厂类可以按需创建出用户实现的对象
  4. 提供了大量的系统参数供用户按需设置, 增强系统的场景定制性
可拓展性

基于Netty的基础NIO框架, 可以方便地进行应用层协议定制, 例如HTTP协议栈、Thrift, FTP协议栈, 这些都不需要修改Netty源码, 直接基于Netty的二进制类库进行协议的拓展和定制

Java多线程编程在Netty中的应用

对共享的可变数据进行正确的同步

synchronized保证同一时刻, 只有一个线程可以执行某一个方法或者代码块. 同步的作用不仅仅是互斥,它的另一个作用就是共享可变性, 当某个线程修改了可变数据并释放锁后, 其他线程可以获取被修改变量的最新值. 如果没有正确的同步, 这种修改对其他线程是不可见的.

由于 ServerBootStrap 是被外部使用者创建和使用的, 我们无法保证它的方法和成员变量不被并发访问. 因此作为成员变量的options必需进行正确的同步.

正确使用锁

  1. wait 方法用来让线程等待某种条件,它必须在同步块内部被调用, 这个同步块通常会锁定当前对象实例.
synchronized(this)
{
  while(condition)
    Object.wait;
}
  1. 始终使用 while 循环来调用 wait 方法, 永远不要在循环之外调用wait 方法. 原因是尽管并不满足被唤醒条件, 但是由于其他线程调用 notifyAll() 方法会导致被阻塞线程意外唤醒, 此时执行条件并不满足, 它将破坏被锁保护的约定关系, 导致约束失效, 引起意想不到的结果

  2. 唤醒线程, notify vs notifyAll?
    保守起见是调用notifyAll 唤醒所有等待的线程.

volatile的正确使用

  1. 线程可见性: 当一个线程修改了被volatile修饰的变量后, 无论是加锁, 其他线程都可以立即看到最新的修改
  2. 禁止指令重排序优化
    volatile不能代替传统锁, 答案是不能。
    volatile仅仅解决了可见性的问题, 但是它并不能保证互斥性, 多个线程并发修某个变量时, 依旧会产生多线程问题.

应用场景来说, volatile最适合使用的是一个线程写, 其他线程读的场合. 如果有多个线程并发操, 仍然需要使用锁或者是线程安全的容器或是源自变量来代替.

CAS指令和原子类

线程安全类的应用
JUC 可以分为4类:

  1. 线程池 Executor Framework 以及定时任务相关的类库, 包括Timer等
  2. 并发集合, 包括List、 Queue、Map 和 Set等
  3. 新的同步器, 如ReadWriteLock
  4. 新的原子包装类, 如 AtomicInteger
    建议通过使用线程池, task(Runnable/callable), 原子类和线程完全容器来代替传统的同步锁, wait 和 notify, 以提升并发访问的性能, 降低多线程编程的难度

NioEventLoop是I/O线程, 负责网络读写操作, 同时也执行一些非I/O的任务. 例如时间通知, 定时任务执行等. 需要一个任务队列来缓存这些Task.

NioEventLoop是 ConcurrentLinkedQueue. JDK的线程安全容器底层采用了CAS, volatile 和 ReadWriteLock实现, 相比起同步锁, 采用了更轻量, 细粒度的锁, 因此,性能会更高. 合理地应用这些线程安全容器.能提升多线程并发访问的性能, 还能降低开发难度.

Netty对线程池的应用

  1. 定义一个标准的线程池用于执行任务
    private final Executor executor;

  2. 赋值并且进行初始化操作

this.addTaskWakesUp = addTaskWakesUp;
this.executor = executor;
taskQueue = newTaskQueue();
  1. 执行任务代码
public void execute(Runnable task){
  if(task == null){
    throw new NullPointerException("task");
  }
  boolean inEventLoop = inEventLoop();
  if(inEventLoop){
    // adding taskto eventloop
    addTask(task);
    
  }
  else{
    // start new thread and adding taskto eventloop
    startThread();
    addTask(task);
    
    if (isShutdown() && removeTask(task)){
      reject();
    }
  }
}
  1. startThread(), singleThreadEventExecutor 启动新的线程
private void startThread(){
  synchronized (stateLock){
    if(state == ST_NOT_STARTED){
      state = ST_STAETED;
      delayedTaskQueue.add(new ScheduleFutureTask<void>(this, delayedTaskQueue,Executors, <Void> callable(new PurgeTask(), null),
ScheduledFutureTask.deadlineNanos(SCCHEDULE_PURGE_INTERVAL),SCHEDULE_PURGE_INTERVAL                                                       ));
      doStartThread();
    }
  }
}
  1. 按照I/O任务比例执行任务 Task
if(selectedKeys != null){
  	processSelectedKeysOptimized(selectedKeys.flip());
}
else{
  processSelectedKeysPlain(selector.selectedKeys());
}
final long ioTime = System.nanoTime() - ioStartTime;
final int ioRatio = this.ioRatio;
runAllTasks(ioTime * (100-ioRatio)/ioRatio);
  1. 循环从任务队列中获取任务Task并执行
protected boolean runAllTasks(long timeoutNanos){
  fetchFromDelayedQueue();
  Runnable task = pollTask();
  if(task == null){
    return false;
  }
  final long deadline = ScheduledFutureTask.nanoTime()+timeoutNanos;
  long runTasks= 0;
  long lastExecutionTime;
  for(;;){
    try{
      // run tasks
      task.run();
    }
    catch (Throwable t){
      logger.warn("A task raised an exception",t);
    }
  }
}

读写锁的应用

应用场景:

  1. 主要用于读多写少的场景, 用来替代传统的同步锁, 以提升并发访问性能
  2. 读写锁是可重入, 可降级的, 一个线程获取读写锁后, 可以继续递归获取; 从写锁可以降级为读锁, 以便快速释放锁资源
  3. ReentrantReadWriteLock 支持获取锁的公平策略, 在某些特殊的应用场景下, 可以提升并发访问的性能, 同时兼顾线程等待公平性
private void fetchExpiredTimeouts(List<HashedWheelTimeout> expiredTimeouts, long deadline){
    lock.writeLock().lock();
 		try{
      fetchExpiredTimeouts(expiredTimeouts, wheel[(int)(tick & mask)].iterator(), deadline);
    }
  	finally{
      tick++;
      lock.writeLock().unlock();
    }
  }
)
  1. 读写锁支持非阻塞的尝试获取锁 , 如果获取失败, 直接返回false, 而不是同步阻塞. 这个功能在一些场景下非常有用. 例如多个线程同步读写某个资源, 当发生异常或者需要释放资源的时候, 由哪个线程释放是个难题. 因为某些资源不能重复释放或者重复执行, 这样, 可以通过tryLock方法尝试获取锁, 如果拿不到, 说明已经被其他线程占用, 直接退出即可
  2. 获取锁之后一定要释放锁, 否则会发生锁溢出异常. 通常的做法是通过finally块释放锁, 如果是 tryLock, 获取锁成功才需要释放锁
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值