面试拾遗:Epoll 之下的线程模型 / Envoy 快速之谜

bgfwp.png

多核之下的 Epoll

对于 Epoll 来说,我们为了利用多核能力,其实有两种办法

  • 负载均衡 accept()
  • 负载均衡 read()

负载均衡 accept()

对于高频建立短连接的,我们要快速处理 accept()

水平触发情况下一种无效的唤醒: Level triggered

  1. Kernel: 接收到请求
  2. Kernel: 唤醒通知两个线程
  3. Thread A: 完成 epoll_wait().
  4. Thread B: 完成 epoll_wait().
  5. Thread A: 执行 accept() 成功
  6. Thread B: 执行 accept() 失败,对于 B 的唤醒无效

边缘触发情况下一种无效的唤醒: Edge triggered

  1. Kernel: 接收到请求,唤醒A/B,这里假设是 A
  2. Thread A: 完成 epoll_wait().
  3. Thread A: 执行 accept() 成功
  4. Kernel: 接收到请求
  5. Kernel: 唤醒B来接收(A在处理)
  6. Thread A: 执行了 accept() 成功
  7. Thread B: 执行了 accept() 失败了,被无效唤醒

当上述的线程从 2个变成 1000 个,我们就可能唤醒很多线程而无效。对于 Linux 4.5 增加了 EPOLLEXCLUSIVE 标记,只会唤醒一个 epoll_wait() 状态的线程。

Netty

对于 Netty 来说, accept() 的工作是放在 Bossgroup 中执行的,因此,如果我们需要处理大量高频的请求,我们应该尽可能的将 bossGroup 多分配一些,对于 Netty 来说是无法禁止使用 childGroup 的。

Server构造器
1
2
3
4
5
6
7
8
public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup) {
super.group(parentGroup);
if (this.childGroup != null) {
throw new IllegalStateException("childGroup set already");
}
this.childGroup = ObjectUtil.checkNotNull(childGroup, "childGroup");
return this;
}

因此我们势必会涉及到 BossGroup -> WorkerGroup 的开销。

负载均衡 read()

对于裸用 Epoll 来说,我们会出现以下2个问题,都会出现两个不同的进程处理同一个 Socket 的数据

LT 模式下

  1. Kernel: 接收到 2047 字节数据
  2. Kernel: 唤醒A处理
  3. Thread A: 完成 epoll_wait()
  4. Kernel: 接收到 2 字节
  5. Kernel: 唤醒 B 处理
  6. Thread A: 执行 read(2048) 获得 2048 字节数据
  7. Thread B: 执行 read(2048) 获得 1 字节数据

ET 模式下

  1. Kernel: 接收到 2048 字节数据
  2. Kernel: 唤醒A处理
  3. Thread A: 完成 epoll_wait()
  4. Thread A: 执行 read(2048) 获得 2048 字节数据
  5. Kernel: 接收到 1 字节数据
  6. Kernel: 唤醒B处理
  7. Thread B: 完成 epoll_wait()
  8. Thread B: 执行 read(2048) 获得 1 字节数据
  9. Thread A: 重试 read(2048), 什么都没获得

针对上述的情况, Linux 加入了 EPOLLONESHOT 只允许一个线程注册唯一的回调事件。

epolloneshot
1
2
3
4
5
6
7
8
9
10
11
void Eepoll::ResetOneShot(intepollfd,SOCKET fd,bool bOne){
epoll_eventevent;
event.data.fd= fd;
event.events= EPOLLIN | EPOLLET ;
if(bOne){
event.events |=EPOLLONESHOT;
}
if(-1 == epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&event)){
perror("resetoneshotepoll_ctl error!");
}
}

Netty

这种一般服务于 大量长时间连接的,需要及时处理接收到的请求,这种情况下需要单 Boss 而需要多个 Worker

bz6Nu.png

Workgroup default value

Workgroup 的默认值是 CPU Core2 倍,昨天也被问了这个事情,查了一下 Google 也没有找到适合的答案,我估计这是应该是一个实验数据,在大部分的情况下这个 2 应该可以发挥最大的作用。(有空测试一下)

Why Envoy So Fast

这方面的资料比较少,并且作者在阅读 Envoy 源码的时候的确也没发现什么特别之处,从设计上尝试回答一下这个问题

Envoy Thread Model

Envoy 的线程模式是对等设计,在HTTP转发模式下,因为每一个线程和下游的服务[另外一个Envoy]连接的时候会创建一个 HTTP/2 的连接[基于 Host:Port 区分],这些连接被缓存在一个 连接池内,此时可以利用 Http2 多路复用的能力节约很多时间。

Each worker thread maintains its own connection pools for each cluster, so if an Envoy has two threads and a cluster with both HTTP/1 and HTTP/2 support, there will be at least 4 connection pools.

连接池功效,Nginx也有,这块我觉得互有胜负吧,应该算是很常见的优化手段。

除此之外,non-blocking 是最核心的部分。对于一个热更新系统,我们可以很自然的想到我们需要共享一部分的数据在不同的线程之间,常见的是使用 ReadWriterLockEnvoy 用了一种特别的设计办法 Thread Local Storage (TLS) system

b2MF6.png

类似于 RCU 的机制,所有的 Worker 仅仅进行读取的操作。

Envoy Filter

Envoy Filter 是动态生成的,这和大部分的传统 Filter 不太一样,对于大多数的实现,比如 Spring WebMVCFilter 对于所有的请求都是相同的,我们可以在函数的入口判断是否需要处理,或者是直接调用下一个 FilterEnvoyFilter 构成的 FilterChain 是由配置生成的,因此比如我们只需要 2 步操作的话,我们在 生成的FilterChain 中也只有 2Filter,减少了很多的函数调用栈的成本。

负债均衡 Accept

对于 Envoy 来说, Run as Sidecar 针对同 Pod 的请求多半是高频的短连接 HTTP,这种模式下,我们 负债均衡 Accept 是最佳的选择,因此 Envoy 在设计的时候,就是使用负债均衡 Accept 的设计。

参考