Please enable Javascript to view the contents

《Envoy Proxy 内幕》- 事件驱动与线程设计

 ·  ☕ 5 分钟

cover-mock-1024

本文摘录自我的书《Envoy Proxy 内幕》 最近的一些更新,包括:事件驱动框架线程模型 。这些算是 Envoy Proxy 的基础核心了。大家都认为 Envoy 是一个 Proxy 。主要实现定制逻辑的请求转发。这点没错。但与拥有高负载低延迟要求的其它中间件一样。设计上必须考虑负载的调度和流控。良好的调度设计必须平衡吞吐、响应时间、资源消耗(footprint) 。本文主要讲事件、调度、多线程协同相关话题。

事件驱动框架

设计

大家都认为 Envoy 是一个 Proxy 。主要实现定制逻辑的请求转发。这点没错。但与拥有高负载低延迟的其它中间件一样。设计上必须考虑负载的调度和流控。良好的调度设计必须平衡吞吐、响应时间、资源消耗(footprint) 。

图: 事件驱动框架设计

图:事件驱动框架设计
用 Draw.io 打开

  1. Dispatcher 线程事件循环。Dispatcher Thread 等待事件(epoll wait) 并在等待超时或事件发生后处理事件。

  2. 有以下事件唤醒 epool wait:

    • 收到线程间 post callback 消息。主要用于 Thread Local Storage(TLS) 的数据更新。如 Cluster/Stats 信息更新

      • Dispatcher 为线程间事件
    • timer timeout 事件

    • file/socket/inotify 事件

    • internal active event。内部其它线程,或者本 dispatcher 线程,程序显式调用函数,触发事件

  3. 处理事件

一次事件处理的 loop 过程,包含上面三步。三步完成合称为一个 event loop , 有时,也叫 event loop iteration

实现

上面主要在 kernel syscall 层面上介绍事件处理的底层过程。下面介绍在 Envoy 代码层面,如何抽象和封装事件。

Envoy 使用了 libevent 这个 C 编写的事件 library。还在其上作了 C++ OOP 方面的封装。

图 - Envoy 事件的抽象封装模型

图: 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 loopSchedulableCallbackImpl 封装这种可调度的任务。应用场景有: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 、健康状态……
  • 各种监控统计指标

线程概述

image-20240506232521005

图 : 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 。

image-20240506233017636

Source: Envoy threading model - Matt Klein

Figure : Thread Local Storage (TLS) system

image-20240506233250458

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

图: ThreadLocal Classes
用 Draw.io 打开

上图可以简述如下:

  1. main 线程 初始化 ThreadLocal::InstanceImpl 以及每个 Dispatcher 注册到 ThreadLocal::InstanceImpl
  2. main 线程 通知所有 worker 线程创建本地的 ThreadLocalClusterManagerImpl
  3. main 线程感知到一个 Cluster 被删除时,通知各个 worker 线程的 ThreadLocalClusterManagerImpl 删除这个 Cluster
  4. worker 线程上的 TCPProxy 尝试连接一个 OnDemand Cluster(未知的 cluster) 时,获取线程本地的 ThreadLocalClusterManagerImpl
分享

Mark Zhu
作者
Mark Zhu
An old developer