Netty架构剖析
Netty线程模型
Reactor单线程模型
所有的I/O操作都在同一个NIO线程上完成
Reactor多线程模型
区别是:有一组NIO线程来处理I/O操作。
特点:
- 有专门一个NIO线程–Acceptor线程用于监听服务端,接受客户端TCP连接请求。
- 网络I/O操作由一个NIO线程池负责。
- 一个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线程池职责:
- 接受客户端的TCP连接,初始化Channel参数。
- 将链路注册到状态下变更事件通知给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多线程
模式特点:
- 专门的NIO线程: Acceptor 用于监听服务端, 接收客户端的TCP连接请求。
- 网络I/O操作: 读写等由一个NIO线程池负责, 线程池可以采用标准的JDK线程池实现, 它包含一个任务队列和N个可用的线程, 由这些NIO线程负责消息的读取解码编码和发送。
- 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高性能的具体实现:
- 采用异步非阻塞的I/O类库, 基于Reactor模式实现, 解决了传统同步阻塞I/O模式下一个服务端无法平滑地处理线性增长的客户端的问题
- TCP接收和发送缓冲区使用直接内存代替堆内存, 避免了内存复制, 提升I/O读取和写入的性能.
- 支持通过内存池的方式循环利用ByteBuf, 避免了频繁创建和小会ByteBuf带来的性能损耗
- 可配置的I/O线程数, TCP参数等, 为不同的用户场景提供定制化的调优参数, 满足不同的性能场景
- 采用环形数组缓冲区实现无锁化并发编程, 代替传统的线程安全容器或者锁
- 合理地使用线程安全容器、原子类等, 提升系统的并发处理能力
- 关键资源的处理使用单线程串行化的方式, 避免多线程并发访问带来的锁竞争和额外的CPU资源消耗问题
- 通过引用计数器及时地申请释放不再被引用的对象, 细粒度的内存管理降低了GC的频率, 减少了频繁GC带来的时延增大和CPU损耗
可靠性
- 链路有效性检测
- 链路由长连接建立, 不需要每次创建, 可以一直保持。
- 相应产生的问题, 比如链路空闲问题,也被心跳机制所解决. 为了支持心跳, Netty提供了两种链路空闲检测机制。
- 读空闲超时机制: 当连续周期T没有消息可读时, 触发超时Handler用户可以基于读空闲超时发送心跳消息, 进行链路检测; 如果连续N个周期仍然没有读取到心跳消息, 可以主动关闭链路。
- 写空闲超时机制: 当连续周期T没有消息要发送时, 触发超时Handler, 用户可以基于写空闲超时发送心跳信息, 进行链路检测; 如果连续N个周期。
- 内存保护机制
Netty提供多种机制对内存进行保护, 包括以下几个方面:
通过对象引用计数器对Netty的ByteBuf等内置对象进行细粒度的内存申请和释放, 对非法的对象引用进行检测和保护
通过内存池来重用ByteBuf节省内存
可设置的内存容量上限, 包括ByteBuf、线程池线程数等
- 优雅停机
指的是当系统退出时, JVM通过注册的ShutdownHook拦截到退出的信号量, 然后执行退出操作, 释放相关模块的资源占用, 将缓冲区的消息处理完成或者清空, 将待刷新的数据持久化道磁盘或者数据库中, 等到资源回收和缓冲区消息处理完成之后, 再退出.
可定制性
- 责任链模式: ChannelPipeline 基于责任链模式开发, 便于业务逻辑的拦截、 定制和扩展
- 基于接口的开发: 关键的类库都提供了接口或者抽象类, 如果Netty自身的实现无法满足用户的需求, 可以由用户自定义实现相关接口
- 提供了大量工厂类, 通过重载这些工厂类可以按需创建出用户实现的对象
- 提供了大量的系统参数供用户按需设置, 增强系统的场景定制性
可拓展性
基于Netty的基础NIO框架, 可以方便地进行应用层协议定制, 例如HTTP协议栈、Thrift, FTP协议栈, 这些都不需要修改Netty源码, 直接基于Netty的二进制类库进行协议的拓展和定制
Java多线程编程在Netty中的应用
对共享的可变数据进行正确的同步
synchronized保证同一时刻, 只有一个线程可以执行某一个方法或者代码块. 同步的作用不仅仅是互斥,它的另一个作用就是共享可变性, 当某个线程修改了可变数据并释放锁后, 其他线程可以获取被修改变量的最新值. 如果没有正确的同步, 这种修改对其他线程是不可见的.
由于 ServerBootStrap 是被外部使用者创建和使用的, 我们无法保证它的方法和成员变量不被并发访问. 因此作为成员变量的options必需进行正确的同步.
正确使用锁
- wait 方法用来让线程等待某种条件,它必须在同步块内部被调用, 这个同步块通常会锁定当前对象实例.
synchronized(this)
{
while(condition)
Object.wait;
}
-
始终使用 while 循环来调用 wait 方法, 永远不要在循环之外调用wait 方法. 原因是尽管并不满足被唤醒条件, 但是由于其他线程调用 notifyAll() 方法会导致被阻塞线程意外唤醒, 此时执行条件并不满足, 它将破坏被锁保护的约定关系, 导致约束失效, 引起意想不到的结果
-
唤醒线程, notify vs notifyAll?
保守起见是调用notifyAll 唤醒所有等待的线程.
volatile的正确使用
- 线程可见性: 当一个线程修改了被volatile修饰的变量后, 无论是加锁, 其他线程都可以立即看到最新的修改
- 禁止指令重排序优化
volatile不能代替传统锁, 答案是不能。
volatile仅仅解决了可见性的问题, 但是它并不能保证互斥性, 多个线程并发修某个变量时, 依旧会产生多线程问题.
应用场景来说, volatile最适合使用的是一个线程写, 其他线程读的场合. 如果有多个线程并发操, 仍然需要使用锁或者是线程安全的容器或是源自变量来代替.
CAS指令和原子类
线程安全类的应用
JUC 可以分为4类:
- 线程池 Executor Framework 以及定时任务相关的类库, 包括Timer等
- 并发集合, 包括List、 Queue、Map 和 Set等
- 新的同步器, 如ReadWriteLock
- 新的原子包装类, 如 AtomicInteger
建议通过使用线程池, task(Runnable/callable), 原子类和线程完全容器来代替传统的同步锁, wait 和 notify, 以提升并发访问的性能, 降低多线程编程的难度
NioEventLoop是I/O线程, 负责网络读写操作, 同时也执行一些非I/O的任务. 例如时间通知, 定时任务执行等. 需要一个任务队列来缓存这些Task.
NioEventLoop是 ConcurrentLinkedQueue. JDK的线程安全容器底层采用了CAS, volatile 和 ReadWriteLock实现, 相比起同步锁, 采用了更轻量, 细粒度的锁, 因此,性能会更高. 合理地应用这些线程安全容器.能提升多线程并发访问的性能, 还能降低开发难度.
Netty对线程池的应用
-
定义一个标准的线程池用于执行任务
private final Executor executor;
-
赋值并且进行初始化操作
this.addTaskWakesUp = addTaskWakesUp;
this.executor = executor;
taskQueue = newTaskQueue();
- 执行任务代码
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();
}
}
}
- 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();
}
}
}
- 按照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);
- 循环从任务队列中获取任务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);
}
}
}
读写锁的应用
应用场景:
- 主要用于读多写少的场景, 用来替代传统的同步锁, 以提升并发访问性能
- 读写锁是可重入, 可降级的, 一个线程获取读写锁后, 可以继续递归获取; 从写锁可以降级为读锁, 以便快速释放锁资源
- 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();
}
}
)
- 读写锁支持非阻塞的尝试获取锁 , 如果获取失败, 直接返回false, 而不是同步阻塞. 这个功能在一些场景下非常有用. 例如多个线程同步读写某个资源, 当发生异常或者需要释放资源的时候, 由哪个线程释放是个难题. 因为某些资源不能重复释放或者重复执行, 这样, 可以通过tryLock方法尝试获取锁, 如果拿不到, 说明已经被其他线程占用, 直接退出即可
- 获取锁之后一定要释放锁, 否则会发生锁溢出异常. 通常的做法是通过finally块释放锁, 如果是 tryLock, 获取锁成功才需要释放锁