Envoy为什么能战胜Ngnix——线程模型分析篇

来自:高可用架构(微信号:ArchNotes),作者:mattklein

导读:随着Service Mesh在最近一年的流行,Envoy 作为其中很关键的组件,也开始被广大技术人员熟悉。作者是Envoy的开发者之一,本文详细说明了Envoy的线程模型,对于理解Envoy如何工作非常有帮助。内容较为深入,建议细细品读。


关于Envoy的基础技术文档目前相当少。为了改善这一点,我正在计划做一系列关于Envoy各个子系统的文章。 这是第一篇文章,请让我知道你的想法以及你希望涵盖的其他主题。最常见的问题之一是对Envoy使用的线程模型进行描述。

本文将介绍Envoy如何将连接映射到线程,以及Envoy内部使用的线程本地存储(TLS)系统,正是因为该系统的存在才可以保证Envoy以高度并行的方式运行并且保证高性能。

线程概述


图1:线程概述


Envoy使用三种不同类型的线程,如图1所示。


  • Main:此线程可以启动和关闭服务器。负责所有xDS API处理(包括DNS , 运行状况检查和常规集群管理 ), 运行时 ,统计刷新,管理和一般进程管理(信号, 热启动等)。 在这个线程上发生的一切都是异步的和“非阻塞的”。通常,主线程负责所有不需要消耗大量CPU就可以完成的关键功能。 这可以保证大多数管理代码都是以单线程运行的。

  • Worker:默认情况下,Envoy为系统中的每个硬件线程生成一个工作线程。(可以通过--concurrency选项控制)。 每个Worker线程是一个“非阻塞”事件循环,负责监听每个侦听器,接受新连接,为每个连接实例化过滤器栈,以及处理所有连接生命周期内IO事件。 这也允许大多数连接处理代码以近似单线程运行。

  • 文件刷新器:Envoy写入的每个文件(主要是访问日志)都有一个独立的刷新线程。 这是因为即使用O_NONBLOCK写入文件系统有时也会阻塞。 当工作线程需要写入文件时,数据实际上被移入内存缓冲区,最终通过文件刷新线程刷新至磁盘。 这是一个共享内存区域,理论上说所有Worker都可以在同一个锁上阻塞,因为他们可能会同时尝试写内存缓冲区。 这一部分内容将在后面进一步讨论。


连接处理


如上所述,所有工作线程都会在没有任何分片的情况下监听所有侦听器。内核将接收的socket分派给工作线程。 现代内核一般都很擅长干这个; 内核使用诸如IO优先级提升之类的功能来尝试提高线程处理能力而非使用其他线程处理,这些线程也在同一个套接字上侦听,并且对每个连接来说不需要使用自旋锁来处理。

一旦Worker接受了连接, 连接就永远不会离开那个Worker。所有进一步的处理都在Worker线程内完成,其中包括转发。 这就意味着:


  • Envoy中的所有连接池都和Worker线程绑定。 尽管HTTP/2连接池一次只与每个上游主机建立一个连接,但如果有四个Worker,则每个上游主机在稳定状态下将有四个HTTP/2连接。

  • Envoy以这种方式工作的原因是将所有连接都在单个Worker线程中处理,这样几乎所有代码都可以在无锁的情况下编写,就像它是单线程一样。 这种设计使得大多数代码更容易编写,并且可以非常好地扩展到几乎无限数量的Worker。

  • 主要的问题是,从内存和连接池效率的角度来看,调整--concurrency选项实际上非常重要。 拥有太多的Worker将浪费内存,创建更多空闲连接,并导致连接池命中率降低。 在Lyft,作为边车运行的Envoy并发度很低,性能大致与他们旁边的服务相匹配。 但是我们以最大并发度运行边缘节点Envoy。


非阻塞意味着什么


到目前为止,在讨论主线程和Woker线程如何操作时,已经多次使用术语“非阻塞”。 所有代码都是在假设没有任何阻塞的情况下编写的。 然而,这并不完全正确。 Envoy确实使用了一些进程范围的锁:


  • 如前所述,如果正在写入访问日志,则所有Worker在访问日志缓冲区之前都会获取相同的锁。虽然锁保持时间应该非常短,但是也可能会在高并发性和高吞吐量时发生争用。

  • Envoy采用了一个非常复杂的系统来处理线程本地的统计数据。我会有后续文章讨论这个话题。 这里会简要提一下,作为线程本地统计处理的一部分,有时需要获取对“stat store”的锁。这种锁不应该高度争用。

  • 主线程需要定期与所有Worker线程同步数据。 这是通过从主线程“发布”到Worker线程(有时从Worker线程返回到主线程)来完成的。 发布需要获取锁,将发布的消息放入队列中以便后续操作。 这些锁永远不应该高度争用,但它们仍然是阻塞的。

  • 当Envoy输出日志到标准错误时,它会获得进程范围的锁。 一般来说,Envoy本地日志性能也不好,所以我们没有特意考虑提升改善锁性能。

  • 还有一些其他随机锁,但它们都不在性能关键路径中,永远不应该争用。


线程本地存储


由于Envoy将主线程职责与Worker线程职责分开,因此需要在主线程上完成复杂的处理,然后以高度并发的方式让每个Worker线程处理。 本节将介绍Envoy线程本地存储(TLS)系统。 在下一节中,我将描述如何使用它来处理集群管理。

图2:线程本地存储(TLS)系统

如已经描述的那样,主线程基本上处理Envoy中的所有管理/控制面功能。(控制面在主线程似乎有点多,但在考虑到Worker做的工作时,似乎也是合适的)。 主线程执行某些操作是一种常见模式,然后通过Worker线程获取结果,并且Worker线程不需要在每次访问时获取锁。

Envoy的TLS系统的工作原理如下:


  • 在主线程上运行的代码可以分配进程范围的TLS槽。 这是一个允许O(1)访问的向量索引。

  • 主线程可以将任意数据置入其槽中。 完成此操作后,数据将作为循环事件发布到每个Worker中。

  • Worker可以从其TLS槽读取,并将检索那里可用的任何线程本地数据。


虽然非常简单,但这是一个非常强大的范例,与RCU锁的概念非常相似。(实质上,Worker线程在工作时从不会看到TLS槽中的数据发生任何变化。变化只发生在工作事件之间的静止期)。

Envoy以两种不同的方式使用它:


  • 在没有任何锁的情况下,每个Worker存储不同的数据

  • 将共享指针存储到每个Worker的全局只读数据。因此每个Worker在工作时都无法操作数据的引用计数。 只有当所有Worker都停顿并加载新的共享数据时,旧数据才会被销毁。 这与RCU相同。


群集更新线程


在本节中,我将描述TLS如何用于集群管理。

群集管理包括xDS API处理和/或DNS以及运行状况检查。

图3:集群管理器线程

图3显示了以下组件和步骤的总体流程:


  1. 集群管理器是Envoy的内部组件,用于管理所有已知的上游集群,CDS API,SDS/EDS API,DNS和运行状况检查。 它负责创建上游集群的最终一致视图,其中包括已发现的主机以及运行状况。

  2. 运行状况检查器执行活动运行状况检查,并将运行状况更改报告给集群管理器。

  3. 执行CDS/SDS/EDS/DNS以确定群集成员资格。 状态更改将报告回集群管理器。

  4. 每个工作线程都在不断运行事件循环。

  5. 当集群管理器确定集群的状态已更改时,它会创建集群状态的只读快照 ,并将其发布到每个Worker线程。

  6. 在下一个静止期间,工作线程将更新分配的TLS槽中的快照。

  7. 在需要确定要负载均衡主机的IO事件期间,负载均衡器将在TLS插槽中查询主机信息。 执行此操作不需要获取锁。 (另请注意,TLS还可以在更新时触发事件,以便负载均衡器和其他组件可以重新计算缓存,数据结构等。这超出了本文的范围,但在代码中不同位置使用)。


通过前面描述的过程,Envoy能够在处理请求的时候不需要任何锁(除了之前描述的那些)。 除了TLS代码之外,大多数代码都不设计线程相关操作,可以编写为单线程程序。 除了达到出色的性能之外,这使得大多数代码更容易编写。


其他使用TLS的子系统


TLS和RCU在Envoy内广泛使用。 其他一些例子包括:


  • 运行时(特征标识)覆盖查找:当前特征标识覆盖映射是在主线程上计算的。然后使用RCU语义向每个工作人员提供只读快照。

  • 路由表交换:对于RDS提供的路由表,路由表在主线程上实例化。然后使用RCU语义为每个工作程序提供只读快照。 

  • HTTP日期header缓存:事实证明,对每个请求都计算HTTP日期header(当每个核执行~25K + RPS时)开销很大。 Envoy大约每半秒计算一次日期header,并通过TLS和RCU将其提供给每个Worker。


还有其他例子,但前面的例子应该已经说明了TLS在Envoy内部如何广泛使用。


已知的性能陷阱


虽然Envoy整体表现相当不错,但是当它以非常高的并发性和吞吐量使用时,还是有一些需要注意的地方:


  • 正如本文中已经描述的那样,当前所有Worker在写入访问日志的内存缓冲区时都会获得锁。 在高并发性和高吞吐量的情况下,当写入最终文件时,将需要对每个Worker的访问日志进行批处理。 作为优化,每个Worker线程可以有自己的访问日志。

  • 尽管统计信息已经优化,但在非常高的并发性和吞吐量下,个别统计信息可能存在原子争用。 对此的解决方案是使用Worker计数器,定期同步到中央计数器。 这将在后续文章中讨论。

  • 如果Envoy用在少量连接占用大量资源的情况下,现有的体系结构将无法正常工作。这是因为无法保证连接在Worker之间均匀分布。 这可以通过实现Worker连接负载均衡来解决,其中Worker能够将连接转发给另一个Worker进行处理。


结论


Envoy的线程模型旨在支持简单编程范式和大规模并行,但如果调整不当可能会浪费内存和连接。该模型允许Envoy在非常高的Worker数量和吞吐量下有良好表现。

正如我在Twitter上提到的那样,该设计也适合在DPDK之类的用户空间网络堆栈上运行,这可能让商用服务器可以达到每秒钟几百万请求处理速度。 看看未来几年能做到什么样也是非常有趣的。

最后一点:我多次被问到为什么我们为Envoy选择C++。 原因是它仍然是唯一广泛部署的生产级语言,在该语言中可以构建本文所述的体系结构。 C++当然不适合所有项目,甚至许多项目,但对于某些用例,它仍然是完成工作的唯一工具。

代码链接

本文中讨论的一些接口和头文件的链接:

https://github.com/lyft/envoy/blob/master/include/envoy/thread_local/thread_local.h

https://github.com/lyft/envoy/blob/master/source/common/thread_local/thread_local_impl.h

https://github.com/lyft/envoy/blob/master/include/envoy/upstream/cluster_manager.h

https://github.com/lyft/envoy/blob/master/source/common/upstream/cluster_manager_impl.h


更多 Envoy 介绍:

https://www.envoyproxy.io/

推荐↓↓↓
Web开发
上一篇:移动端图片格式调研 下一篇:账单类类型业务接入支付平台