Netty源码分析--Reactor线程模型解析(二)

前言

上篇文章介绍了Netty线程模型的类继承关系和线程组的实例化过程,相信读过上篇文章的读者对Netty的线程模型应该有了一个大致的印象。本篇文章将介绍线程组是如何启动、轮询并处理IO事件和任务的。

NioEventLoop的启动入口

在前面服务端启动章节中,我们画了一个非常长的流程图,在流程图中,注册服务端Channel到Selector的过程中会经过以下逻辑。

less 复制代码
AbstractUnsafe

public final void register(EventLoop eventLoop, final ChannelPromise promise) {

    AbstractChannel.this.eventLoop = eventLoop;
   if (eventLoop.inEventLoop()) {
        register0(promise);
    } else {
        eventLoop.execute(new Runnable() {
            @Override
        public void run() {
                register0(promise);
            }
        });
    }

我们来看一下inEventLoop方法

arduino 复制代码
AbstractEventExecutor.java

@Override
public boolean inEventLoop() {
    return inEventLoop(Thread.currentThread());
}
@Override
public boolean inEventLoop(Thread thread) {
    return thread == this.thread;
}

可以看到,在Netty调用bind过程中,当前线程是main方法对应的主线程,tihs.thread是NioEventLoop中的变量,此时并未赋值,因此会return false。

另外,我们在Netty的源码中,会在很多地方看到inEventLoop这样的判断,这个方法的本质含义就是判断当前线程是否是Netty的Reactor线程,也就是NioEventLoop对应的线程实体。后面我们会看到,创建一个线程之后,会将这个线程实体保存到thread这个成员变量中。

由于方法返回false,因此会走到eventLoop.execute的逻辑中。通过上一章分析的类图关系,最终会调用到SingleThreadEventExecutor类的doStartThread方法,我们看一下这个方法的核心代码。

csharp 复制代码
executor.execute(new Runnable() {
    @Override
    public void run() {
        thread = Thread.currentThread();
        SingleThreadEventExecutor.this.run();

在执行doStartThread的时候,会调用内部成员变量executor的execute方法,而根据我们在上一章的分析,executor就是ThreadPerTaskExecutor,这个对象的作用就是每次执行runnable的时候,都会先创建一个线程再执行。

在这个runnable中,通过一个成员变量thread来保存ThreadPerTaskExecutor创建出来的线程,这个线程就是我们在上一章中分析的FastThreadLocalThread。至此,我们终于知道,一个NioEventLoop是如何与一个线程实体绑定的:NioEventLoop通过ThreadPerTaskExecutor创建一个FastThreadLocalThread,然后通过一个成员变量来指向这个线程。 NioEventLoop保存完线程的引用之后,随即调用run方法。这个run方法就是Netty Reactor的核心所在。

NioEventLoop的执行流程

读到这里,netty启动流程中boss线程组是如何创建第一个NioEventLoop以及NioEventLoop是如何与实体线程绑定的就已经清楚了,接下来分析一下netty是如何处理线程的Reactor的循环的。后面还会分析一下当新链接接入时,worker对应的NioEventLoop是如何创建对应线程和启动的。

我们来看一下NioEventLoop的run方法

scss 复制代码
@Override
protected void run() {
    for (;;) {
            select(wakenUp.getAndSet(false));
            if (wakenUp.get()) {
                selector.wakeup();}
            processSelectedKeys();
            runAllTasks();
    }
}

为了便于理解,这里只保留核心代码。可以看到,run方法通过for循环,不断执行以下三个步骤:

  • 执行一次时间轮询
  • 处理产生IO事件的channel
  • 执行任务
执行一次事件轮询

wakenUp是NioEventLoop中AtomicBoolean类型的变量,用来控制是否应该唤醒正在阻塞的select操作。我们来看一下select方法

ini 复制代码
    private void select(boolean oldWakenUp) throws IOException {
        Selector selector = this.selector;
        try {
            int selectCnt = 0;
            long currentTimeNanos = System.nanoTime();
            long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos);
            for (;;) {
            // 定时任务截止时间中断
                long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
                if (timeoutMillis <= 0) {
                    if (selectCnt == 0) {
                        selector.selectNow();
                        selectCnt = 1;
                    }
                    break;
                }
            // 有新任务加入中断
                if (hasTasks() && wakenUp.compareAndSet(false, true)) {
                    selector.selectNow();
                    selectCnt = 1;
                    break;
                }
           // 阻塞式select操作
                int selectedKeys = selector.select(timeoutMillis);
                selectCnt ++;

                if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) {
                    break;
                }
                if (Thread.interrupted()) {
                    selectCnt = 1;
                    break;
                }
            // 规避JDK的NIO Bug
                long time = System.nanoTime();
                if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
                    // timeoutMillis elapsed without anything selected.
                    selectCnt = 1;
                } else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
                        selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
                    // The selector returned prematurely many times in a row.
                    // Rebuild the selector to work around the problem.
                    logger.warn(
                            "Selector.select() returned prematurely {} times in a row; rebuilding Selector {}.",
                            selectCnt, selector);

                    rebuildSelector();
                    selector = this.selector;

                    // Select again to populate selectedKeys.
                    selector.selectNow();
                    selectCnt = 1;
                    break;
                }

                currentTimeNanos = time;
            }
        } catch (CancelledKeyException e) {
    }
}

可以看到,这个方法大致分为四个步骤

  1. 定时任务截止时间中断
  2. 有新任务加入中断
  3. 阻塞式select操作
  4. 规避JDK的NIO Bug
  • 定时任务截止时间中断

    通过peek()方法取出定时任务队列中头部元素(按执行时间排序)的截止时间,在for循环中判断如果其距截止时间小于0.5ms,则会跳出循环。并在循环结束前至少执行一次selectNow方法。

    这里说一下selectNow方法。这个方法调用的是WindowsSelectorImpl这个类的poll方法,而poll方法最终会调用操作系统层面的poll方法。如果读者对操作系统或者I/O多路复用不了解,可以自行搜索学习select、poll、epoll函数。而这里的selectNow方法会设置超时时间为0,即表示立即查看是否有就绪的channel,不阻塞等待。

  • 有新任务加入中断

    Netty为了保证任务队列里的任务能够及时执行,在进行阻塞select操作的时候会判断任务队列是否为空。如果不为空,就执行一次非阻塞select操作,跳出循环;否则,继续执行下面的操作。

  • 阻塞式select操作

    执行到这一步,说明Netty任务队列里的队列为空,并且所有定时任务的延迟时间还未到(大于0.5ms)。于是,进行一次阻塞select操作,截止到第一个定时任务的截止时间或者被外部任务唤醒,在外部线程添加任务的时候,会调用wakeup方法来唤醒selector.select(timeoutMillis)。

    阻塞select操作结束之后,Netty又做了一系列状态判断来决定是否中断本次轮询。

  • 规避JDK的NIO Bug

    select操作的最后一步,就是解决JDK的NIO Bug。该Bug会导致Selector一直空轮询,最终导致CPU 100%,NIO Server不可用。

    netty会判断每次阻塞select的轮询时间是否大于等于超时时间,如果不大于则有可能触发了JDK空轮询Bug。当空轮询的次数超过一定阈值(默认是512)的时候,就开始重建Selector。重建Selector的方法也很简单,创建一个新的Selector,将之前注册到老的Selector上的Channel重新转移到新的Selector上。感兴趣的读者可以自己去看看。

处理产生IO事件的channel
scss 复制代码
NioEventLoop
private void processSelectedKeys() {
    if (selectedKeys != null) {
        processSelectedKeysOptimized(selectedKeys.flip());
    } else {
        processSelectedKeysPlain(selector.selectedKeys());
    }
}

处理IO事件,Netty有两种选择,从名字上看,一种是处理优化过的SelectedKeys,一种是正常处理。 这里被优化处理过的selectedKeys是SelectedSelectionKeySet类的对象。这个对象在openSelector()方法被调用时就通过反射将sun.nio.ch.SelectorImpl对象中的两个成员变量publicKeys、publicSelectedKeys绑定。这两个成员变量是两个HashSet

在SelectedSelectionKeySet中,netty使用数组来添加元素,每次添加时,将SelectionKey塞到该数组的尾部;更新该数组的逻辑长度+1;如果该数组的逻辑长度等于数组的物理长度,就将该数组扩容。待程序运行一段时间后,等数组的长度足够长,每次在轮询到NIO事件的时候,Netty只需要O (1)的时间复杂度就能将SelectionKey塞到set中去,而JDK底层使用的HashSet put的时间复杂度最少是O (1),最差是O (n),使用数组替换掉HashSet还有一个好处是遍历的时候非常高效。

接着来看processSelectedKeysOptimized()方法

scss 复制代码
private void processSelectedKeysOptimized(SelectionKey[] selectedKeys) {
    for (int i = 0;; i ++) {
        final SelectionKey k = selectedKeys[i];
        if (k == null) {
            break;
        }
        // null out entry in the array to allow to have it GC'ed once the Channel close
        // See https://github.com/netty/netty/issues/2363
        selectedKeys[i] = null;

        final Object a = k.attachment();

        if (a instanceof AbstractNioChannel) {
            processSelectedKey(k, (AbstractNioChannel) a);
        } else {
            @SuppressWarnings("unchecked")
            NioTask<SelectableChannel> task = (NioTask<SelectableChannel>) a;
            processSelectedKey(k, task);
        }

        if (needsToSelectAgain) {
            // null out entries in the array to allow to have it GC'ed once the Channel close
            // See https://github.com/netty/netty/issues/2363
            for (;;) {
                i++;
                if (selectedKeys[i] == null) {
                    break;
                }
                selectedKeys[i] = null;
            }

            selectAgain();
            // Need to flip the optimized selectedKeys to get the right reference to the array
            // and reset the index to -1 which will then set to 0 on the for loop
            // to start over again.
            //
            // See https://github.com/netty/netty/issues/1523
            selectedKeys = this.selectedKeys.flip();
            i = -1;
        }
    }
}
  • 首先从selectedKeys中取出元素并将其设为null。

    这里提一下为什么要设置为null;我们知道数组对其内元素的引用是强引用,如果在这里不设置为null,那么即使SelectionKey无效了(如channel断开),GC也无法回收这一块内存。单个的SelectionKey可能不大,但是别忘了在Selector调用register方法的时候(启动流程),是将AbstractNioChannel作为attachment挂上去的。attachment可能很大,这样一来,这些对象就一直存活,造成JVM无法回收,就会导致内存泄漏。

  • 接着取出attachment并判断是否为AbstractNioChannel;

    取出SelectionKey对应的AbstractNioChannel进行后续操作。

    在Netty的Channel中,有两大类型的Channel,一个是NioServerSocketChannel,由boss NioEventLoopGroup负责处理;一个是NioSocketChannel,由worker NioEventLoop负责处理,所以:

    (1)对于boss NioEventLoop来说,轮询到的是连接事件,后续通过NioServerSocketChannel的Pipeline将连接交给一个worker NioEventLoop处理;

    (2)对于worker NioEventLoop来说,轮询到的是读写事件,后续通过NioSocketChannel的Pipeline将读取到的数据传递给每个ChannelHandler来处理。

  • 最后判断是否需要在进行一次轮询。

    Channel从Selector上移除的时候,调用cancel方法将key取消,并且在被取消的key到达CLEANUP_INTERVAL的时候,设置needsToSelectAgain为true,CLEANUP_INTERVAL默认值为256。也就是说,对于每个NioEventLoop而言,每隔256个Channel从Selector上移除的时候,就标记needsToSelectAgain为true。

    needsToSelectAgain时,首先,将SelectedKeys的内部数组全部清空,方便JVM垃圾回收,然后调用selectAgain重新填装SelectionKeys数组。

csharp 复制代码
private void selectAgain() {
    needsToSelectAgain = false;
    try {
        selector.selectNow();
    } catch (Throwable t) {
        logger.warn("Failed to update SelectionKeys.", t);
    }
}
添加任务

在分析netty执行任务之前,先来看一下netty是如何添加任务的

netty添加任务大致有三种场景

  • 用户自定义普通任务。
less 复制代码
ctx.channel().eventLoop().execute(new Runnable() {
    @Override
    public void run() {
        //...
    }
});

execute方法如下

java 复制代码
SingleThreadEventExecutor.java

@Override
public void execute(Runnable task) {
    boolean inEventLoop = inEventLoop();
    if (inEventLoop) {
        addTask(task);
    } else {
        addTask(task);
    }
}

可以看到,不管是外部线程还是Reactor线程,execute方法都会调用addTask方法。

scss 复制代码
SingleThreadEventExecutor.java

protected void addTask(Runnable task) {
    // ...
if (!offerTask(task)) {
        reject(task);
    }
}

final boolean offerTask(Runnable task) {
    // ...
    return taskQueue.offer(task);
}

跟到offerTask方法,这个Task就落地了,Netty内部使用一个taskQueue将Task保存起来。在前面的章节已经分析过这个taskQueue,它其实是一个MPSC Queue(一般通过CAS实现),每一个NioEventLoop都与它一一对应。

Netty使用MPSC Queue,方便将外部线程的异步任务聚集,在Reactor线程内部用单线程来批量执行以提升性能。

  • 非当前Reactor线程调用Channel的各类方法
    channel.write(...)在业务线程里,根据用户的标识,找到对应的Channel,然后调用Channel的write类方法向该用户推送消息。最终会调用到以下方法
arduino 复制代码
AbstractChannelHandlerContext.java

private void write(Object msg, boolean flush, ChannelPromise promise) {
    // ...
    EventExecutor executor = next.executor();
    if (executor.inEventLoop()) {
        if (flush) {
            next.invokeWriteAndFlush(m, promise);
        } else {
            next.invokeWrite(m, promise);
        }
    } else {
        AbstractWriteTask task;
        if (flush) {
            task = WriteAndFlushTask.newInstance(next, m, promise);
        }  else {
            task = WriteTask.newInstance(next, m, promise);
        }
        safeExecute(executor, task, promise, m);
    }
}
arduino 复制代码
private static void safeExecute(EventExecutor executor, Runnable runnable, 

ChannelPromise promise, Object msg) {
    // ...
    executor.execute(runnable);
    // ...
}

最终通过safeExecute方法,走到场景一的逻辑。需要注意的是,这种场景下的任务的发起线程是用户线程,可能会有多个;此时MPSC队列就很适用。

  • 用户自定义定时任务
    这段代码的作用是60s后执行某个任务。
less 复制代码
AbstractScheduledEventExecutor.java

ctx.channel().eventLoop().schedule(new Runnable() {
    @Override
  public void run() {
    }
}, 60, TimeUnit.SECONDS);

netty会将用户自定义任务包装成一个netty内部任务,在通过一个优先级队列将任务按照顺序丢到队列中。并通过以下代码保证不会产生并发问题

less 复制代码
AbstractScheduledEventExecutor.java

<V> ScheduledFuture<V> schedule(final ScheduledFutureTask<V> task) {
    // 如果是Reactor线程,则直接往优先级队列中添加任务
    if (inEventLoop()) {
        scheduledTaskQueue().add(task);
    } else {
        // 如果是外部线程,则将添加定时任务这个逻辑进一步封装,退回到场景二
        execute(new Runnable() {
            @Override
            public void run() {
                scheduledTaskQueue().add(task);
            }
        });
    }
    return task;
}

Queue<ScheduledFutureTask<?>> scheduledTaskQueue() {
   if (scheduledTaskQueue == null) {
       scheduledTaskQueue = new PriorityQueue<ScheduledFutureTask<?>>();
   }
   return scheduledTaskQueue
}

如果是在外部线程调用schedule方法,Netty会将添加定时任务这个逻辑封装成一个普通的task,这个task的任务是一个添加"添加定时任务"的任务,而不是添加定时任务,所以其实就退回到第二种场景。

这里在说一下定时任务的排序和执行模式 每个定时任务(ScheduledFutureTask封装的)都有唯一id和执行时间,优先级队列按照执行时间顺序排序,如果执行时间相同,则按照id排序,先入队的任务先执行。

通过periodNanos确定执行模式,periodNanos为0表示一次性任务,大于0表示以固定频率执行任务,小于0表示以固定间隔执行任务(依赖上一次任务完成);

执行任务

这里面会有两个分支,通过ioRatio协调IO操作和任务的执行

scss 复制代码
NioEventLoop.java

if (ioRatio == 100) {
    try {
        processSelectedKeys();
    } finally {
        // Ensure we always run tasks.
        runAllTasks();
    }
} else {
    final long ioStartTime = System.nanoTime();
    try {
        processSelectedKeys();
    } finally {
        // Ensure we always run tasks.
        final long ioTime = System.nanoTime() - ioStartTime;
        runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
    }
}

直接来分析runAllTasks(long timeoutNanos)方法

ini 复制代码
SingleThreadEventExecutor.java

fetchFromScheduledTaskQueue();
Runnable task = pollTask();
if (task == null) {
    afterRunningAllTasks();
    return false;
}

final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
long runTasks = 0;
long lastExecutionTime;
for (;;) {
    (task);
    runTasks ++;
    // Check timeout every 64 tasks because nanoTime() is relatively expensive.
    if ((runTasks & 0x3F) == 0) {
        lastExecutionTime = ScheduledFutureTask.nanoTime();
        if (lastExecutionTime >= deadline) {
            break;
        }
    }

    task = pollTask();
    if (task == null) {
        lastExecutionTime = ScheduledFutureTask.nanoTime();
        break;
    }
}

afterRunningAllTasks();
this.lastExecutionTime = lastExecutionTime;
return true;
  • 通过 fetchFromScheduledTaskQueue方法将优先级队列中(通过peek方法)快要到期的任务转移到MPSC队列中。
  • 计算本轮任务执行的截止时间。到了这一步,所有截止时间已经到达的定时任务均被填充到普通任务队列。接下来,Netty会计算一下本轮任务最多可以执行到什么时候。
  • 通过safeExecute方法执行任务并每隔64次判断一次是否执行超过截止时间(对于大量小任务场景,不必每次判断,提高性能)。
  • 判断本轮任务是否全部执行完成。

总结

1.NioEventLoop在执行过程中不断检测是否有事件发生,如果有事件发生就处理,处理完事件之后再处理外部线程提交过来的异步任务。

2.在检测是否有事件发生的时候,为了保证异步任务的及时处理,只要有任务要处理,就立即停止事件检测,随即处理任务。

3.外部线程异步执行的任务分为两种:定时任务和普通任务,分别落地到MpscQueue和PriorityQueue,而PriorityQueue中的任务最终都会填充到MpscQueue中处理。

4.Netty每隔64个任务检查一次是否该退出任务循环。

相关推荐
失散133 天前
分布式专题——35 Netty的使用和常用组件辨析
java·分布式·架构·netty
hanxiaozhang20184 天前
Netty面试重点-1
网络·网络协议·面试·netty
mumu1307梦15 天前
SpringAI 实战:解决 Netty 超时问题,优化 OpenAiApi 配置
java·spring boot·netty·超时·timeout·openapi·springai
若水不如远方23 天前
Netty的四种零拷贝机制:深入原理与实战指南
java·netty
9527出列1 个月前
Netty源码分析--Reactor线程模型解析(一)
netty
不知名的前端专家1 个月前
uniapp原生插件 TCP Socket 使用文档
网络·tcp/ip·uni-app·netty
她似晚风般温柔7891 个月前
SpringBoot3 + Netty + Vue3 实现消息推送(最新)
java·websocket·spring·vue·netty
广东数字化转型1 个月前
LengthFieldBasedFrameDecoder 详细用法
netty·粘包·拆包
探索java2 个月前
Netty Channel详解:从原理到实践
java·后端·netty