作者:vivo 网络服务器团队- Wang Ke
本文介绍了 Jetty 中 ManagedSelector 和 ExecutionStrategy 通过与原生的设计实现 select 调用对比揭示 Jetty 线程优化思路。Jetty 设计了自适应的线程执行策略(EatWhatYouKill),在没有线程饥饿的情况下,尽量使用同一线程进行检测 I/O 事件和处理 I/O 充分利用事件 CPU 缓存和减少线程切换的成本。这种优化理念对于这种优化理念来说有很多 I/O 性能优化在操作场景中具有一定的参考意义。
一、什么是 JettyJetty 跟 Tomcat 一样是一种 Web 容器的总体结构设计如下:
Jetty 一般由一系列组成 Connector、一系列 Handler 和一个 由ThreadPol组成。
Connector 也就是 Jetty 与比较的连接器组件相比 Tomcat 的连接器,Jetty 连接器在设计上有自己的特点。
Jetty 的 Connector 支持 NIO 通信模型,NIO 模型中的主角是 Selector,Jetty 在 Java 原生 Selector 在此基础上包装自己的 Selector:ManagedSelector。
二、Jetty 中的 Selector 交互2.1 传统的 Selector 实现常规的 NIO 编程思路是将军 I/O 不同的线程分别处理事件的检测和请求处理。
具体流程如下:
- 启动一个线程;
- 在死循环中不断调用 select 方法,检测 Channel 的 I/O 状态;
- 一旦 I/O 当事件到达时,把它放在一边 I/O 事件和一些数据被包装成 Runnable;
- 将 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 使用交互:
- 如何注册 Channel 以及 I/O 事件
- 提供什么样的处理类别? 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 主要做三件事:
- 如果 Channel 集合中有 I/O 事件就绪,调用前面提到的。 Selectable 接口获取 Runnable,直接返回给 ExecutionStrategy 处理。
- 如果没有 I/O 事件准备就绪时,做一些家务,看客户是否提交了更新 Selector 上述事件注册任务,即上述任务 SelectorUpdate 任务类。
- 继续做完杂活 select 方法,侦测 I/O 就绪事件。
任务生产者依次生产和执行任务,对应 NIO 通信模型是用线程来检测和处理一个 ManagedSelector 上的所有的 I/O 事件。
后面的 I/O 等待前面的事件 I/O 事件处理后,效率明显较低。
图中,绿色代表生产任务,蓝色代表执行任务,下同。
3.2.2 ProduceExecuteConsume(PEC) 线程执行策略开启新线程执行任务的任务生产者是典型的 I/O 用不同的线程处理事件的检测和处理。
缺点是不能使用 CPU 缓存,线程切换成本高。
图中,棕色代表线程切换,下同。
3.2.3 ExecuteProduceConsume(EPC) 线程执行策略任务生产者自己操作任务,这样可能会建立一个新的线程来继续生产和执行任务。
它的优点是可以利用 CPU 缓存,但如果处理的话,潜在问题是 I/O 事件的业务代码执行时间过长,会导致线程堵塞和线程饥饿。
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 使生产和消费任务能够在同一线程上运行,从而充分利用热缓存,避免调度延迟。
这也为我们的性能优化提供了一些想法:
- 在保证不发生线程饥饿的情况下,可以充分利用同一线程的生产和消费 CPU 缓存,减少线程切换的成本。
- 根据实际情况选择最合适的执行策略,结合多个子策略也可以扬长避短,达到1+1>2的效果。
参考文档:
- Class EatWhatYouKill
- Eat What You Kill
- Thread Starvation with Eat What You Kill