当前位置: 首页 > 图灵资讯 > 技术篇> 浅析 Jetty 中的线程优化思路

浅析 Jetty 中的线程优化思路

来源:图灵教育
时间:2023-06-26 15:47:10

作者:vivo 网络服务器团队- Wang Ke

本文介绍了 Jetty 中 ManagedSelector 和 ExecutionStrategy 通过与原生的设计实现 select 调用对比揭示 Jetty 线程优化思路。Jetty 设计了自适应的线程执行策略(EatWhatYouKill),在没有线程饥饿的情况下,尽量使用同一线程进行检测 I/O 事件和处理 I/O 充分利用事件 CPU 缓存和减少线程切换的成本。这种优化理念对于这种优化理念来说有很多 I/O 性能优化在操作场景中具有一定的参考意义。

一、什么是 Jetty

Jetty 跟 Tomcat 一样是一种 Web 容器的总体结构设计如下:

Jetty 一般由一系列组成 Connector、一系列 Handler 和一个 由ThreadPol组成。

浅析 Jetty 中的线程优化思路_Jetty

Connector 也就是 Jetty 与比较的连接器组件相比 Tomcat 的连接器,Jetty 连接器在设计上有自己的特点。

Jetty 的 Connector 支持 NIO 通信模型,NIO 模型中的主角是 Selector,Jetty 在 Java 原生 Selector 在此基础上包装自己的 Selector:ManagedSelector。

二、Jetty 中的 Selector 交互2.1 传统的 Selector 实现

常规的 NIO 编程思路是将军 I/O 不同的线程分别处理事件的检测和请求处理。

具体流程如下:

  1. 启动一个线程;
  2. 在死循环中不断调用 select 方法,检测 Channel 的 I/O 状态;
  3. 一旦 I/O 当事件到达时,把它放在一边 I/O 事件和一些数据被包装成 Runnable;
  4. 将 Runnable 在新的线程中进行处理。

有两个线程可以在这个过程中工作:一个是 I/O 一个是事件检测线程 I/O 事件处理线程。

这两个线程是“生产者”和“消费者”之间的关系。

这种设计的好处:

用不同的线程处理这两个工作的好处是它们不互相干扰和阻塞。

这种设计的缺陷:

当 Selector 在检测读取就绪事件时,数据已被复制到内核缓存,同时 CPU 这些数据也存在于缓存中。

此时,当应用程序读取这些数据时,如果用另一个线程读取,这个读取线程很可能使用另一个数据 CPU 核,而不是之前检测数据就绪的检测数据 CPU 核。

这样 CPU 缓存中的数据无法使用,线程切换也需要费用。

2.2 Jetty 中的 ManagedSelector 实现

Jetty 的 Connector 将 I/O 同一行程处理事件的生产和消费。

如果线程在执行过程中不堵塞,操作系统将使用相同的线程 CPU 核来执行这两个任务,可以充分利用 CPU 缓存还可以减少线程上下文切换的费用。

ManagedSelector 本质上是一个 Selector,负责 I/O 事件的检测和分发。

使用方便,Jetty 在 Java 原生 Selector 其成员变量如下:

public class ManagedSelector extends ContainerLifeCycle implements Dumpable{    // 原子变量,显示当前的ManagedSelector是否已经启动    private final AtomicBoolean _started = new AtomicBoolean(false);         // 表示是否阻塞select调用    private boolean _selecting = false;         // 管理器的引用,SelectorManager管理多个Managedselector生命周期    private final SelectorManager _selectorManager;         // Managedselectorid    private final int _id;         // 关键的执行策略,它决定了生产者和消费者是否处理同一行程    private final ExecutionStrategy _strategy;         // Java原生Selectortor    private Selector _selector;         // “Selector更新任务”队列    private Deque<SelectorUpdate> _updates = new ArrayDeque<>();    private Deque<SelectorUpdate> _updateable = new ArrayDeque<>();         ...}

2.2.1 SelectorUpdate 接口

为什么需要一个“Selector更新任务”队列?

对于 Selector 对于用户,我们是对的 Selector 操作无非是将军 Channel 注册到 Selector 或者告诉 Selector 我对什么 I/O 对事件感兴趣。

其实这些操作都是对的 Selector 更新状态,Jetty 抽象这些操作 SelectorUpdate 接口。

/** * A selector update to be done when the selector has been woken. */public interface SelectorUpdate{    void update(Selector selector);}

这意味着不能直接操作 ManagedSelector 中的 Selector,而是需要向 ManagedSelector 提交任务类。

这一类需要实现 SelectorUpdate 接口的 update 方法,在 update 对Managedselector进行定义 做的操作。

比如 Connector 中的 Endpoint 组件对读取就绪事件感兴趣。

它就向 ManagedSelector 提交内部任务类 ManagedSelector.SelectorUpdate:

_selector.submit(_updateKeyAction);

这个 _updateKeyAction 就是一个 SelectorUpdate 实例,它的 update 实现方法如下:

private final ManagedSelector.SelectorUpdate _updateKeyAction = new ManagedSelector.SelectorUpdate(){    @Override    public void update(Selector selector){        // 这里的updatekey实际上是在调用Selectionkey.interestOps(OP_READ);        updateKey();    }};

在 update 在方法中,调用 SelectionKey 类的 interestOps 方法,输入的参数是 OP_READ,这意味着我对此 Channel 对阅读就绪事件感兴趣。

2.2.2 Selectable 接口

上面有了 update 谁来执行这些方法? update 呢,答案是 ManagedSelector 自己。

它在一个死循环中提取这些 SelectorUpdate 逐一执行任务。

I/O 当事件到达时,ManagedSelector 通过任务类接口(Selectable 接口)确定该事件由哪个函数处理。

public interface Selectable{    // Channel的I/O事件就绪后,Managedselector会调用的回调函数    Runnable onSelected();     // 所有事件处理完毕后,Managedselector将调整回调函数    void updateKey();}

Selectable 接口的 onSelected() 方法返回一个 Runnable,这个 Runnable 就是 I/O 事件就绪时的相应处理逻辑。

ManagedSelector 检测到某个 Channel 上的 I/O 事件就绪时,ManagedSelector 调用这个 Channel 绑定的类别 onSelected 拿到一个方法 Runnable。

然后把 Runnable 将其扔给线程池进行执行。

三、Jetty 线程优化思路3.1 Jetty 中的 ExecutionStrategy 实现

前面介绍了 ManagedSelector 使用交互:

  1. 如何注册 Channel 以及 I/O 事件
  2. 提供什么样的处理类别? I/O 事件

那么 ManagedSelector 如何统一管理和维护用户注册? Channel 集合,答案是 ExecutionStrategy 接口。

该接口委托内部接口生产具体任务 Producer,而在自己的 produce 在方法中实现具体的执行逻辑。

这个 Runnable 任务可以由当前线程执行,也可以在新线程中执行。

public interface ExecutionStrategy{    // HTTP2中只使用的方法暂时被忽略    public void dispatch();     // 实现具体的执行策略,当前线程可以在任务生产后执行,新线程也可以执行    public void produce();         // 任务的生产委托给Producer内部接口    public interface Producer    {        // 生产Runnnable(任务)        Runnable produce();    }}

实现 Produce 一旦任务生产出接口生产任务,ExecutionStrategy 将负责执行此任务。

private class SelectorProducer implements ExecutionStrategy.Producer{    private Set<SelectionKey> _keys = Collections.emptySet();    private Iterator<SelectionKey> _cursor = Collections.emptyIterator();     @Override    public Runnable produce(){        while (true)        {            // 若Channel集合中有I/O事件就绪,调用上述Selectable接口获取Runnable,直接返回ExecutionStrategy处理            Runnable task = processSelected();            if (task != null)                return task;                        // 如果没有I/O事件,做一些杂务,看看客户是否提交了更新Selector的任务,即上述Selectorupdate任务。            processUpdates();            updateKeys();            // 继续执行select方法,检测I/O就绪事件            if (!select())                return null;        }    } }

SelectorProducer 是 ManagedSelector 的内部类。

SelectorProducer 实现了 ExecutionStrategy 中的 Producer 接口中的 produce 方法,需要方向 ExecutionStrategy 返回一个 Runnable。

在 produce 方法中 SelectorProducer 主要做三件事:

  1. 如果 Channel 集合中有 I/O 事件就绪,调用前面提到的。 Selectable 接口获取 Runnable,直接返回给 ExecutionStrategy 处理。
  2. 如果没有 I/O 事件准备就绪时,做一些家务,看客户是否提交了更新 Selector 上述事件注册任务,即上述任务 SelectorUpdate 任务类。
  3. 继续做完杂活 select 方法,侦测 I/O 就绪事件。
3.2 Jetty 线程执行策略3.2.1 ProduceConsume(PC) 线程执行策略

任务生产者依次生产和执行任务,对应 NIO 通信模型是用线程来检测和处理一个 ManagedSelector 上的所有的 I/O 事件。

后面的 I/O 等待前面的事件 I/O 事件处理后,效率明显较低。

浅析 Jetty 中的线程优化思路_Jetty_02

图中,绿色代表生产任务,蓝色代表执行任务,下同。

3.2.2 ProduceExecuteConsume(PEC) 线程执行策略

开启新线程执行任务的任务生产者是典型的 I/O 用不同的线程处理事件的检测和处理。

缺点是不能使用 CPU 缓存,线程切换成本高。

浅析 Jetty 中的线程优化思路_Jetty_03

图中,棕色代表线程切换,下同。

3.2.3 ExecuteProduceConsume(EPC) 线程执行策略

任务生产者自己操作任务,这样可能会建立一个新的线程来继续生产和执行任务。

它的优点是可以利用 CPU 缓存,但如果处理的话,潜在问题是 I/O 事件的业务代码执行时间过长,会导致线程堵塞和线程饥饿。

浅析 Jetty 中的线程优化思路_EatWhatYouKill_04

3.2.4 EatWhatYouKill(EWYK) 改进线程执行策略

这是 Jetty 对 ExecuteProduceConsume 在线程池线程充足的情况下,策略的改进相当于 ExecuteProduceConsume;

当系统忙线程不够时,切换成 ProduceExecuteConsume 策略。

这样做的原因是:

ExecuteProduceConsume 在同一线程中执行 I/O 它使用的线程来自事件的生产和消费 Jetty 对于全局线程池,这些线程可能会被业务代码堵塞。如果堵塞太多,全局线程池中的线程自然不够。最糟糕的情况是连接 I/O 事件的检测没有线程可用,会导致 Connector 拒绝浏览器请求。

于是 Jetty 做一个优化:

在低线程的情况下执行 ProduceExecuteConsume 策略,I/O 检测采用专门的线程处理, I/O 将事件处理扔给线程池,实际上是在线程池的队列中慢慢处理。

四、总结

本文基于 Jetty-9 介绍了 ManagedSelector 和 ExecutionStrategy 介绍了设计实现 PC、PEC、EPC 从三个线程执行策略的差异来看 Jetty 从线程执行策略的改进操作中可以看出,Jetty 优先考虑线程执行策略 EPC 使生产和消费任务能够在同一线程上运行,从而充分利用热缓存,避免调度延迟。

这也为我们的性能优化提供了一些想法:

  1. 在保证不发生线程饥饿的情况下,可以充分利用同一线程的生产和消费 CPU 缓存,减少线程切换的成本。
  2. 根据实际情况选择最合适的执行策略,结合多个子策略也可以扬长避短,达到1+1>2的效果。

参考文档:

  1. Class EatWhatYouKill
  2. Eat What You Kill
  3. Thread Starvation with Eat What You Kill