天天看点

Netty——EventLoop和线程模型

线程模型概述

基本的线程池化模式可以描述为:

  • 从池的空闲线程列表中选择一个 Thread,并且指派它去运行一个已提交的任务(一个Runnable 的实现);
  • 当任务完成时,将该 Thread 返回给该列表,使其可被重用。
    Netty——EventLoop和线程模型

虽然池化和重用线程相对于简单地为每个任务都创建和销毁线程是一种进步,但是它并不能 消除由上下文切换所带来的开销,其将随着线程数量的增加很快变得明显,并且在高负载下愈演 愈烈。此外,仅仅由于应用程序的整体复杂性或者并发需求,在项目的生命周期内也可能会出现 其他和线程相关的问题。

EventLop接口

运行任务来处理在连接的生命周期内发生的事件是任何网络框架的基本功能。与之相应的编程上的构造通常被称为事件循环——一个 Netty 使用了 interface io.netty.channel. EventLoop 来适配的术语。

下面的代码说明了事件循环的基本思想,其中每个任务都是一个 Runnable 的实例:

//阻塞,直到有事件已经就绪可被运行
while(!terminated) {
	List<Runnable> readyEvents = blockUntilEventsReady();
	for(Funnable ev : readyEvents){
		ev.run();//循环遍历,并处理所有的事件
	}
}
           

Netty 的 EventLoop 是协同设计的一部分,它采用了两个基本的 API:并发和网络编程。 首先,

io.netty.util.concurrent

包构建在 JDK 的

java.util.concurrent

包上,用 来提供线程执行器。其次,

io.netty.channel

包中的类,为了与 Channel 的事件进行交互, 扩展了这些接口/类。

Netty——EventLoop和线程模型

在这个模型中,一个 EventLoop 将由一个永远都不会改变的 Thread 驱动,同时任务 (Runnable 或者 Callable)可以直接提交给 EventLoop 实现,以立即执行或者调度执行。

根据配置和可用核心的不同,可能会创建多个 EventLoop 实例用以优化资源的使用,并且单个 EventLoop 可能会被指派用于服务多个 Channel。

需要注意的是,Netty的EventLoop在继承了ScheduledExecutorService的同时,只定 义了一个方法,parent()。这个方法,如下面的代码片断所示,用于返回到当前EventLoop实现的实例所属的EventLoopGroup的引用。

public interface EventLoop extends OrderedEventExecutor, EventLoopGroup {
    @Override
    EventLoopGroup parent();
}
           

Netty4中的I/O和事件处理

由 I/O 操作触发的事件将流经安装了一个或者多个 ChannelHandler 的 ChannelPipeline。传播这些事件的方法调用可以随后被 Channel- Handler 所拦截,并且可以按需地处理事件。

事件的性质通常决定了它将被如何处理;它可能将数据从网络栈中传递到你的应用程序中, 或者进行逆向操作,或者 执行一些截然不同的操作。但是事件的处理逻辑必须足够的通用和灵活, 以处理所有可能的用例。因此,在Netty 4 中,

所有的I/O操作和事件都由已经被分配给了 EventLoop的那个Thread来处理

Netty3中的I/O操作

在以前的版本中所使用的线程模型

只保证了入站(之前称为上游)事件会在所谓的 I/O 线程 (对应于 Netty 4 中的 EventLoop)中执行

。所有的出站(下游)事件都由调用线程处理,其可能是 I/O 线程也可能是别的线程。开始看起来这似乎是个好主意,但是已经被发现是有问题的, 因为需要在 ChannelHandler 中对出站事件进行仔细的同步。简而言之,不可能保证多个线程 不会在同一时刻尝试访问出站事件。例如,如果你通过在不同的线程中调用 Channel.write()方法,针对同一个 Channel 同时触发出站的事件,就会发生这种情况。 当出站事件触发了入站事件时,将会导致另一个负面影响。当 Channel.write()方法导

致异常时,需要生成并触发一个 exceptionCaught 事件。但是在 Netty 3 的模型中,由于这是 一个入站事件,需要在调用线程中执行代码,然后将事件移交给 I/O 线程去执行,然而这将带来 额外的上下文切换。

Netty 4 中所采用的线程模型,通过在同一个线程中处理某个给定的 EventLoop 中所产生的 所有事件,解决了这个问题。这提供了一个更加简单的执行体系架构,并且消除了在多个 ChannelHandler 中进行同步的需要(除了任何可能需要在多个 Channel 中共享的)。

任务调度

JDK的任务调度API:

在 Java 5 之前,任务调度是建立在

java.util.Timer

类之上的,其使用了一个后台 Thread, 并且具有与标准线程相同的限制。随后,JDK 提供了 java.util.concurrent 包,它定义了

interface ScheduledExecutorService

Netty——EventLoop和线程模型
//创建一个具有10个线程的线程池
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);

        ScheduledFuture<?> future = executor.schedule(
                new Runnable() {//创建一个Runnable,以供调度后执行
                    @Override
                    public void run() {
                        System.out.println("60 seconds later");//该任务要打印的信息
                    }
                }, 60, TimeUnit.SECONDS);//导读任务在从现在开始的60秒之后执行
        //一旦调度任务执行完成,就关闭
        executor.shutdown();
           

虽然 ScheduledExecutorService API 是直截了当的,但是在高负载下它将带来性能上 的负担。

使用EventLoop调度任务:

ScheduledExecutorService 的实现具有局限性,例如,

事实上作为线程池管理的一部分,将会有额外的线程创建。

如果有大量任务被紧凑地调度,那么这将成为一个瓶颈。Netty 通 过 Channel 的 EventLoop 实现任务调度解决了这一问题:

//使用EventLoop调度任务
Channel ch = ...;
        ScheduledFuture<?> scheduledFuture = ch.eventLoop().schedule(
                new Runnable() {
                    @Override
                    public void run() {
                        System.out.println("60 seconds later");
                    }
                }, 60, TimeUnit.SECONDS);
           

经过 60 秒之后,Runnable 实例将由分配给 Channel 的 EventLoop 执行。如果要调度任务以每隔 60 秒执行一次,请使用 scheduleAtFixedRate()方法:

ScheduledFuture<?> scheduledFuture = ch.eventLoop().scheduleAtFixedRate(
                new Runnable() {
                    @Override
                    public void run() {
                        System.out.println("60 seconds later");
                    }
                }, 60, 60, TimeUnit.SECONDS);//60秒之后,并且每60秒执行一次
           

要想取消或者检查(被调度任务的)执行状态,可以使用每个异步操作所返回的 ScheduledFuture:

ScheduledFuture<?> future = ch.eventLoop().scheduleAtFixedRate(...);
boolean mayInterruptIfRunning = false;
future.cancel(mayInterruptIfRunning);
           

线程管理

Netty线程模型的卓越性能取决于对于当前执行的Thread的身份的确定,也就是说,确定它是否是分配给当前Channel以及它的EventLoop的那一个线程。

如果(当前)调用线程正是支撑 EventLoop 的线程,那么所提交的代码块将会被(直接)执行。否则,EventLoop 将调度该任务以便稍后执行,并将它放入到内部队列中。当 EventLoop 下次处理它的事件时,它会执行队列中的那些任务/事件。这也就解释了任何的 Thread 是如何 与 Channel 直接交互而无需在 ChannelHandler 中进行额外同步的。

注意,每个 EventLoop 都有它自已的任务队列,独立于任何其他的 EventLoop。

Netty——EventLoop和线程模型

我们之前已经阐明了不要阻塞当前 I/O 线程的重要性。我们再以另一种方式重申一次:

“永远不要将一个长时间运行的任务放入到执行队列中,因为它将阻塞需要在同一线程上执行的任何 其他任务。”

如果必须要进行阻塞调用或者执行长时间运行的任务,我们建议使用一个专门的 EventExecutor。

除了这种受限的场景,如同传输所采用的不同的事件处理实现一样,所使用的线程模型也可以强烈地影响到排队的任务对整体系统性能的影响。

EventLoop线程的分配

服务于 Channel 的 I/O 和事件的 EventLoop 包含在 EventLoopGroup 中。根据不同的传输实现,EventLoop 的创建和分配方式也不同。

1. 异步传输:

异步传输实现只使用了少量的 EventLoop(以及和它们相关联的 Thread),而且在当前的线程模型中,它们可能会被多个 Channel 所共享。这使得可以

通过尽可能少量的 Thread 来支撑大量的 Channel,而不是每个 Channel 分配一个 Thread。

下图显示了一个 EventLoopGroup,它具有 3 个固定大小的 EventLoop(每个 EventLoop都由一个 Thread 支撑)。在创建 EventLoopGroup 时就直接分配了 EventLoop(以及支撑它们 的 Thread),以确保在需要时它们是可用的。

Netty——EventLoop和线程模型

EventLoopGroup 负责为每个新创建的 Channel 分配一个 EventLoop。在当前实现中, 使用顺序循环(round-robin)的方式进行分配以获取一个均衡的分布,并且相同的 EventLoop 可能会被分配给多个 Channel。(这一点在将来的版本中可能会改变。)

一旦一个 Channel 被分配给一个 EventLoop,它将在它的整个生命周期中都使用这个 EventLoop(以及相关联的 Thread)。请牢记这一点,因为它可以使你从担忧你的 ChannelHandler 实现中的线程安全和同步问题中解脱出来。

另外,需要注意的是,

EventLoop 的分配方式对 ThreadLocal 的使用的影响

。因为一个 EventLoop 通常会被用于支撑多个 Channel,所以对于所有相关联的 Channel 来说, ThreadLocal 都将是一样的。这使得它对于实现状态追踪等功能来说是个糟糕的选择。然而, 在一些无状态的上下文中,它仍然可以被用于在多个 Channel 之间共享一些重度的或者代价昂 贵的对象,甚至是事件。

2. 阻塞传输:

用于像 OIO(旧的阻塞 I/O)这样的其他传输的设计略有不同:

Netty——EventLoop和线程模型

这里每一个 Channel 都将被分配给一个 EventLoop(以及它的 Thread)。

但是,正如同之前一样,得到的保证是每个 Channel 的 I/O 事件都将只会被一个 Thread (用于支撑该 Channel 的 EventLoop 的那个 Thread)处理。这也是另一个 Netty 设计一致性的例子,它(这种设计上的一致性)对 Netty 的可靠性和易用性做出了巨大贡献。