Seastar介绍

github: seastar

我们在本文档中介绍的Seastar是一个 C++ 库,用于在现代多核机器上编写高效的复杂服务器应用程序。

传统上,用于编写服务器应用程序的编程语言库和框架分为两个不同的阵营:专注于效率的阵营和专注于复杂性的阵营。一些框架非常高效,但只允许构建简单的应用程序(例如,DPDK 允许单独处理数据包的应用程序),而其他框架允许构建极其复杂的应用程序,但以牺牲运行时效率为代价。Seastar 是我们两全其美的尝试:创建一个允许构建高度复杂的服务器应用程序并实现最佳性能的库。

Seastar 的灵感和第一个用例是 Scylla,它是对 Apache Cassandra 的重写。Cassandra 是一个非常复杂的应用程序,然而,借助 Seastar,我们能够以高达 10 倍的吞吐量增加以及显着降低和更一致的延迟重新实现它。

Seastar 提供了一个完整的异步编程框架,它使用两个概念——futurescontinuations——来统一表示和处理各种类型的异步事件,包括网络 I/O、磁盘 I/O 以及其他事件的复杂组合。

由于现代多核和多插槽机器在内核之间共享数据(原子指令、缓存行弹跳[1]和内存栅栏)有严重的惩罚,Seastar 程序使用无共享编程模型,即,可用内存在内核之间分配,每个核心都在其自己的内存部分中处理数据,并且核心之间的通信通过显式消息传递实现(当然,自己的通信使用 SMP 的共享内存硬件实现)。

  • [1] 缓存行弹跳(cache line bouncing):为了以较低的成本大幅提高性能,现代CPU都有cache。CPU cache已经发展到了三级缓存结构,基本上现在买的个人电脑都是L3结构。其中L1和L2cache为每个核独有,L3则所有核共享。为了保证所有的核看到正确的内存数据,一个核在写入自己的L1 cache后,CPU会执行Cache一致性算法把对应的cache line(一般是64字节)同步到其他核。这个过程并不很快,是微秒级的,相比之下写入L1 cache只需要若干纳秒。当很多线程在频繁修改某个字段时,这个字段所在的cacheline被不停地同步到不同的核上,就像在核间弹来弹去,这个现象就叫做cache bouncing。由于实现cache一致性往往有硬件锁,cache bouncing是一种隐式的的全局竞争。

异步编程

用于网络协议的服务器,例如经典的 HTTP(Web)或 SMTP(电子邮件)服务器,天生需要处理并行性。会存在多个客户端并行地发送请求,我们没办法保证在开始处理下一个请求之前完成前一个请求的处理。一个请求可能而且经常确实需要阻塞,一个完整的 TCP 窗口(即慢速连接)、磁盘 I/O,甚至是维持非活动连接的客户端。但是服务器也还是要处理其他连接。

经典网络服务器(如 Inetd、Apache Httpd 和 Sendmail)采用的处理这种并行连接的最直接方法是每个连接使用单独的操作系统进程。这种技术的性能的提高经过了多年的发展:起初,每个新连接都产生一个新进程来处理;后来,保留了一个事先生成的进程池,并将每个新连接分配给该池中的一个未使用的进程;最后,进程被线程取代。然而,所有这些实现背后的共同想法是,在每个时刻,每个进程都只处理一个连接。因此,服务器代码可以自由使用阻塞系统调用,例如读取或写入连接,或从磁盘读取,如果此进程阻塞,

对每个连接使用一个进程(或线程)的服务器进行编程称为同步编程,因为代码是线性编写的,并且一行代码在前一行完成后开始运行。例如,代码可能从套接字读取请求,解析请求,然后从磁盘中读取文件并将其写回套接字。这样的代码很容易编写,几乎就像传统的非并行程序一样。事实上,甚至可以运行一个外部的非并行程序来处理每个请求——例如 Apache HTTPd 如何运行”CGI”程序,这是动态网页生成的第一个实现。

注意:虽然同步服务器应用程序是以线性、非并行的方式编写的,但在幕后,内核有助于确保一切并行发生,并且机器的资源——CPU、磁盘和网络——得到充分利用。除了进程并行(我们有多个进程并行处理多个连接)之外,内核甚至可以并行处理一个单独的连接的工作——例如处理一个未完成的磁盘请求(例如,从磁盘文件读取)与处理并行网络连接(发送缓冲但尚未发送的数据,并缓冲新接收的数据,直到应用程序准备好读取它)。

但是同步的、每个连接的进程、服务器编程并非没有缺点和成本。慢慢地但肯定地,服务器开发人员意识到启动一个新进程很慢,上下文切换很慢,并且每个进程都有很大的开销——最明显的是它的堆栈大小。服务器和内核开发人员努力减轻这些开销:他们从进程切换到线程,从创建新线程到线程池,他们降低了每个线程的默认堆栈大小,并增加了虚拟内存大小以允许更多部分使用的堆栈。但是,采用同步设计的服务器的性能仍不能令人满意,并且随着并发连接数量的增加,扩展性也很差。1999 年,Dan Kigel 普及了”C10K 问题”,需要单台服务器高效处理 10k 个并发的连接——它们大多数很慢甚至是不活跃的。

在接下来的十年中流行的解决方案是放弃舒适但低效的同步服务器设计,转而使用一种新型的服务器设计——异步或事件驱动的服务器。事件驱动服务器只有一个线程,或者更准确地说,每个 CPU 一个线程。这个单线程运行一个紧密的循环,在每次迭代中,检查、使用poll()(或更有效的epoll) 用于许多打开文件描述符(例如套接字)上的新事件。例如,一个事件可以是一个套接字变得可读(新数据已经从远程端到达)或变得可写(我们可以在这个连接上发送更多数据)。应用程序通过执行一些非阻塞操作、修改一个或多个文件描述符以及保持其对该连接状态的了解来处理此事件。

然而,异步服务器应用程序的编写者面临并且今天仍然面临两个重大挑战:

  • 复杂性:编写一个简单的异步服务器很简单。但是编写一个复杂的异步服务器是出了名的困难。单个连接的处理,不再是一个简单易读的函数调用,现在涉及大量的小回调函数,以及一个复杂的状态机来记住每个事件发生时需要调用哪个函数。
  • 非阻塞:每个核心只有一个线程对于服务器应用程序的性能很重要,因为上下文切换很慢。但是,如果我们每个核心只有一个线程,则事件处理函数绝不能阻塞,否则核心将保持空闲状态。但是一些现有的编程语言和框架让服务器作者别无选择,只能使用阻塞函数,因此是多线程。例如,Cassandra被编写为异步服务器应用程序;但是由于磁盘 I/O 是用mmap文件实现的,在访问时会不可控地阻塞整个线程,因此它们被迫在每个 CPU 上运行多个线程。

此外,当需要尽可能好的性能时,服务器应用程序及其编程框架别无选择,只能考虑以下因素:

  • 现代机器:现代机器与 10 年前的机器大不相同。它们有许多内核和深内存层次结构(从 L1 缓存到 NUMA),这会奖励某些编程实践并惩罚其他实践:不可扩展的编程实践(例如获取锁)可能会破坏多核的性能;共享内存和无锁同步原语虽然可以使用(即原子操作和memory-ordering fences),但比仅涉及单个内核缓存中的数据的操作要慢得多,并且还会阻止应用程序扩展到多个内核。
  • 编程语言: Java、Javascript 和类似的”现代”语言等高级语言很方便,但每种语言都有自己的一组假设,这些假设与上面列出的要求相冲突。这些旨在可移植的语言也使程序员对关键代码的性能的控制更少。为了真正获得最佳性能,我们需要一种编程语言,它可以让程序员完全控制、零运行时开销,另一方面——复杂的编译时代码生成和优化。

Seastar 是一个用于编写异步服务器应用程序的框架,旨在解决上述所有四个挑战: 它是一个用于编写涉及网络和磁盘 I/O的复杂异步应用程序的框架。该框架的快速路径完全是单线程的(每个内核),可扩展到多个内核,并最大限度地减少内核之间昂贵的内存共享的使用。它是一个 C++14 库,为用户提供复杂的编译时功能和对性能的完全控制,而没有运行时开销。

Seastar

Seastar 是一个事件驱动的框架,允许您以相对简单的方式(一旦理解)编写非阻塞、异步代码。它的 API 基于future。Seastar 利用以下概念实现极致性能:

  • 协作式微任务调度器:每个核心都运行一个协作式任务调度器,而不是运行线程。每个任务通常都是非常轻量级的——只在处理最后一个 I/O 操作的结果并提交一个新操作的时候运行。
  • Share-nothing SMP 架构:每个核心独立于 SMP 系统中的其他核心运行。内存、数据结构和 CPU 时间不共享;相反,内核间通信使用显式消息传递。Seastar 核心通常称为分片。TODO:更多在这里https://github.com/scylladb/seastar/wiki/SMP
  • 基于 Future 的 API:futures 允许您提交 I/O 操作并在 I/O 操作完成时链接要执行的任务。并行运行多个 I/O 操作很容易——例如,为了响应来自 TCP 连接的请求,您可以发出多个磁盘 I/O 请求,向同一系统上的其他内核发送消息,或发送请求到集群中的其他节点,等待部分或全部结果完成,聚合结果并发送响应。
  • Share-nothing TCP 栈:Seastar 可以使用主机操作系统的 TCP 栈,它还提供了自己的高性能 TCP/IP 栈,构建在任务调度器和 share-nothing 架构之上。堆栈在两个方向上都提供零拷贝:您可以直接从 TCP 堆栈的缓冲区处理数据,并将您自己的数据结构的内容作为消息的一部分发送而不会产生拷贝。
  • 基于 DMA 的存储 API:与网络堆栈一样,Seastar 提供零拷贝存储 API,允许您将数据 DMA 进出存储设备。