本文摘录自我的书《Envoy Proxy 内幕》 最近的一些更新,包括:事件驱动框架 与 线程模型 。这些算是 Envoy Proxy 的基础核心了。大家都认为 Envoy 是一个 Proxy 。主要实现定制逻辑的请求转发。这点没错。但与拥有高负载低延迟要求的其它中间件一样。设计上必须考虑负载的调度和流控。良好的调度设计必须平衡吞吐、响应时间、资源消耗(footprint) 。本文主要讲事件、调度、多线程协同相关话题。
事件驱动框架
设计
大家都认为 Envoy 是一个 Proxy 。主要实现定制逻辑的请求转发。这点没错。但与拥有高负载低延迟的其它中间件一样。设计上必须考虑负载的调度和流控。良好的调度设计必须平衡吞吐、响应时间、资源消耗(footprint) 。
图:事件驱动框架设计
用 Draw.io 打开
-
Dispatcher 线程事件循环。
Dispatcher Thread
等待事件(epoll wait) 并在等待超时或事件发生后处理事件。 -
有以下事件唤醒 epool wait:
-
收到线程间 post callback 消息。主要用于 Thread Local Storage(TLS) 的数据更新。如 Cluster/Stats 信息更新
- Dispatcher 为线程间事件
-
timer timeout 事件
-
file/socket/inotify 事件
-
internal active event。内部其它线程,或者本 dispatcher 线程,程序显式调用函数,触发事件
-
-
处理事件
一次事件处理的 loop 过程,包含上面三步。三步完成合称为一个 event loop
, 有时,也叫 event loop iteration
。
实现
上面主要在 kernel syscall 层面上介绍事件处理的底层过程。下面介绍在 Envoy 代码层面,如何抽象和封装事件。
Envoy 使用了 libevent 这个 C 编写的事件 library。还在其上作了 C++ OOP 方面的封装。
图: Envoy 事件的抽象封装模型
用 Draw.io 打开
如何快速在一个重度(甚至过度)使用 OOP 封装和 OOP Design Pattern 的项目中读懂核心流程逻辑,而不是在源码海洋中无方向地漂流? 答案是:找到主线。 对于 Envoy 的事件处理,主线当然是 libevent
的对象:
libevent::event_base
libevent::event
如果你对 libevent 还不了解,可以看看本书的 libevent 核心思想
一节。
libevent::event
封装到ImplBase
对象中。libevent::event_base
包含在LibeventScheduler
<-DispatcherImpl
<-WorkerImpl
<-ThreadImplPosix
下
然后,不同类型的 libevent::event
,又封装到不同的 ImplBase
子类中:
TimerImpl
- 基于定时的功能都会使用它。如连接超时,闲置超时等等SchedulableCallbackImpl
- 设计上,在高负载时,Envoy 需要平衡事件处理的响应时间和吞吐量。为平衡每次event loop
的工作量及避免一次event loop
处理太久而影响其它未处理事件的响应时效。有的内部发起的、或定时发起的处理过程,可以选择在当前event loop
的最后一个完成,也可以 “延后” 到下一个event loop
。SchedulableCallbackImpl
封装这种可调度的任务。应用场景有:thead callback post / 请求重试等等FileEventImpl
- file / socket 事件
其它信息上图已经比较详细,不再多言了。
线程模型
如果给你一个开源中间件,要你分析其实现,那么,你会从什么地方入手?回答可能是:
- 源码模块
- 抽象概念与设计模式
- 线程
对于现代开源中间件,我觉得线程/进程模型几乎是最重要的。因为现代中间件基本都使用了多进程或多线程以充分利用硬件资源。无论封装抽象得再好,设计模式应用得再优雅,程序终究要以线程的方式在 cpu 上面跑。而多线程是如何按职能划分的,线程之间如何同步通讯,这些东西才是难点和重点。
简单来说,Envoy 使用了 non-blocking + Event Driven + Multi-Worker-Thread 的线程设计模式。在软件设计史上,类似的设计模式的名称有很多,如:
本节内容假设读者已经了解过 Envoy 的事件驱动模型。如果未有,可以阅读本书的 {doc}
/arch/event-driven/event-driven
。
本节内容参考了:Envoy threading model - Matt Klein
与 Node.JS 的单线程不同,Envoy 为了充分利用多 Core CPU 的优势,支持多个 Worker Thread 各自跑自己独立的 event loop。而这样的设计是有代价的,因为多个 worker thread / main thread 之间其实不是完全独立的,他们需要共享一些数据,如:
- Upstream Cluster 的 endpoints 、健康状态……
- 各种监控统计指标
线程概述
图 : Threading overview
Source: Envoy threading model - Matt Klein
Envoy 使用几种不同类型的线程,如上图所示。下面选择主要的说明:
-
main:该线程负责服务器启动和关闭、所有 xDS API 处理(包括 DNS、健康检查和通用cluster management)、runtime、stat flushing、admin 和一般进程管理(signals、热重启等)。该线程上发生的一切都是异步和 “非阻塞 “的。一般来说,main 线程负责协调所有不需要大量 CPU 来完成的关键功能。这样,大部分管理代码就可以像单线程一样编写。
-
worker: 默认情况下,Envoy 会为系统中的每个硬件线程生成一个工作线程。(这可通过 –concurrency 选项控制)。每个 worker 线程运行一个 “非阻塞 “事件循环,负责监听每个 listener、接受新连接、为连接实例化一个 filter 栈,并在连接生命周期内处理所有 IO。这样,大部分连接处理代码就可以像单线程代码一样编写。
Thread Local
由于 Envoy 将 main 线程职责与 worker 线程职责分开,因此需要在 main 线程上完成复杂处理,然后以高度并发的方式提供给每个 worker 线程。本节将从高层介绍 Envoy 的线程本地存储 Thread Local Storage (TLS) 系统。后面我将介绍如何使用该系统处理 cluster management 。
Source: Envoy threading model - Matt Klein
Figure : Thread Local Storage (TLS) system
Source: Envoy threading model - Matt Klein
Figure : Cluster manager threading
共享的数据,如果都是加锁写读访问,并发度一定会下降。于是 Envoy 作者在分析数据同步更新的实时一致性要求不高的条件下,参考了 Linux kernel 的 read-copy-update (RCU) 设计模式,实现了一套 Thread Local 的数据同步机制。在底层实现上,是基于 C++11 的 thread_local
功能,和 libevent 的 libevent::event_active(&raw_event_, EV_TIMEOUT, 0)
去实现。
下图在 Envoy threading model - Matt Klein 基础上,尝试以 Cluster Manager 为例,说明 Envoy 在源码实现层面,是如何使用 Thread Local 机制实现 thread 之间共享数据的。
图: ThreadLocal Classes
用 Draw.io 打开
上图可以简述如下:
- main 线程 初始化
ThreadLocal::InstanceImpl
以及每个Dispatcher
注册到ThreadLocal::InstanceImpl
- main 线程 通知所有 worker 线程创建本地的
ThreadLocalClusterManagerImpl
- main 线程感知到一个 Cluster 被删除时,通知各个 worker 线程的
ThreadLocalClusterManagerImpl
删除这个 Cluster - worker 线程上的
TCPProxy
尝试连接一个OnDemand Cluster(未知的 cluster)
时,获取线程本地的ThreadLocalClusterManagerImpl