Seastar 教程(一)

时间:2021-08-29 16:38:37

介绍

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

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

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

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

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

异步编程

用于网络协议的服务器,例如经典的 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 进出存储设备。

本教程面向已经熟悉 C++ 语言的开发人员,将介绍如何使用 Seastar 创建新应用程序。

入门

最简单的 Seastar 程序是这样的:

#include <seastar/core/app-template.hh>
#include <seastar/core/reactor.hh>
#include <iostream> int main(int argc, char** argv) {
seastar::app_template app;
app.run(argc, argv, [] {
std::cout << "Hello world\n";
return seastar::make_ready_future<>();
});
}

正如我们在本例中所做的那样,每个 Seastar 程序都必须定义并运行一个app_template对象。该对象在一个或多个 CPU 上启动主事件循环(Seastar引擎),然后运行给定函数 —— 在本例中是一个未命名的函数,一个lambda —— 一次。

return make_ready_future<>();导致事件循环和整个应用程序在打印"Hello World"消息后立即退出。在更典型的 Seastar 应用程序中,我们希望事件循环保持活动状态并处理传入的数据包(例如),直到显式退出。此类应用程序将返回一个确定何时退出应用程序的未来。我们将在下面介绍future以及如何使用它们。在任何情况下,都不应使用常规 C exit(),因为它会阻止 Seastar 或应用程序进行适当的清理。

如本例所示,所有 Seastar 函数和类型都位于 "seastar" 命名空间中。用户可以每次都输入这个命名空间前缀,或者使用"using seastar::app_template"甚至" using namespace seastar"之类的快捷方式来避免输入这个前缀。我们通常建议显式地使用命名空间前缀seastar和std,并将在下面的所有示例中遵循这种风格。

要编译这个程序,首先要确保你已经下载、编译和安装了 Seastar,然后把上面的程序放在你想要的源文件中,我们把这个文件叫做getting-started.cc.

Linux 的pkg-config是一种轻松确定使用各种库(例如 Seastar)所需的编译和链接参数的方法。例如,如果 Seastar 已在该目录$SEASTAR中构建但未安装,则可以使用以下命令对getting-started.cc进行编译:

c++ getting-started.cc `pkg-config --cflags --libs --static $SEASTAR/build/release/seastar.pc`

之所以需要"--static",是因为目前 Seastar 是作为静态库构建的,所以我们需要告诉pkg-config在链接命令中包含它的依赖项(而如果 Seastar 是一个共享库,它可能会引入它自己的依赖项)。

如果安装了 Seastar,命令pkg-config行会更短:

c++ getting-started.cc `pkg-config --cflags --libs --static seastar`

或者,可以使用 CMake 轻松构建 Seastar 程序。鉴于以下CMakeLists.txt

cmake_minimum_required (VERSION 3.5)

project (SeastarExample)

find_package (Seastar REQUIRED)

add_executable (example
getting-started.cc) target_link_libraries (example
PRIVATE Seastar::seastar)

您可以使用以下命令编译示例:

$ mkdir build
$ cd build
$ cmake ..
$ make

该程序现在按预期运行:

$ ./example
Hello world
$

线程和内存

Seastar 线程

如简介中所述,基于 Seastar 的程序在每个 CPU 上运行一个线程。这些线程中的每一个都运行自己的事件循环,在 Seastar 命名法中称为引擎。默认情况下,Seastar 应用程序将接管所有可用内核,每个内核启动一个线程。我们可以通过以下程序看到这一点,打印seastar::smp::count启动线程的数量:

#include <seastar/core/app-template.hh>
#include <seastar/core/reactor.hh>
#include <iostream> int main(int argc, char** argv) {
seastar::app_template app;
app.run(argc, argv, [] {
std::cout << seastar::smp::count << "\n";
return seastar::make_ready_future<>();
});
}

在具有 4 个硬件线程(两个内核,并启用超线程)的机器上,Seastar 将默认启动 4 个引擎线程:

$ ./a.out
4

这 4 个引擎线程中的每一个都将被固定(la taskset(1))到不同的硬件线程。请注意,如上所述,应用程序的初始化函数仅在一个线程上运行,因此我们只看到输出"4"一次。在本教程的后面,我们将看到如何使用所有线程。

用户可以传递命令行参数-c来告诉 Seastar 启动的线程数少于可用的硬件线程数。例如,要仅在 2 个线程上启动 Seastar,用户可以执行以下操作:

$ ./a.out -c2
2

假设机器有两个内核,每个内核各有两个超线程,当机器按照上面的示例进行配置只请求两个线程时,Seastar 确保每个线程都固定到不同的内核,并且我们不会让两个线程作为超线程竞争相同的核心(当然,这会损害性能)。

我们不能启动比硬件线程数更多的线程,因为这样做会非常低效。尝试设置更大的值会导致错误:

$ ./a.out -c5
Could not initialize seastar: std::runtime_error (insufficient processing units)

该错误是app.run抛出的异常,被 seastar 自己捕获并转化为非零退出代码。请注意,以这种方式捕获异常不会捕获应用程序实际异步代码中抛出的异常。我们将在本教程后面讨论这些。

Seastar 内存

正如介绍中所解释的,Seastar 应用程序对它们的内存进行分片。每个线程都预先分配了一大块内存(在它运行的同一个 NUMA 节点上),并且只使用该内存进行分配(例如malloc()new)。

默认情况下,机器的整个内存除了为操作系统保留的特定保留(默认为最大 1.5G 或总内存的 7%)以这种方式预分配给应用程序。可以通过使用--reserve-memory选项更改为操作系统保留的数量(Seastar 不使用)或通过使用-m选项显式指定给予 Seastar 应用程序的内存量来更改此默认值。此内存量可以以字节为单位,也可以使用单位“k”、“M”、“G”或“T”。这些单位使用二的幂值:“M”是mebibyte,2^20 (=1,048,576) 字节,而不是megabyte(10^6 或 1,000,000 字节)。

尝试为 Seastar 提供比物理内存更多的内存会立即失败:

$ ./a.out -m10T
Couldn't start application: std::runtime_error (insufficient physical memory)

介绍 futures 和 continuations

我们现在将介绍的 Futures 和 continuations 是 Seastar 中异步编程的构建块。它们的优势在于可以轻松地将它们组合成一个大型、复杂的异步程序,同时保持代码的可读性和可理解性。

future是可能尚不可用的计算的结果。示例包括:

  • 我们从网络中读取的数据缓冲区
  • 计时器到期
  • 磁盘写入完成
  • 需要来自一个或多个其他future的值的计算结果。

future<int>变量包含一个最终可用的int —— 此时可能已经可用,或者可能还不可用。available()方法测试一个值是否已经可用,get() 方法获取该值。类型future<>表示最终将完成但不返回任何值。

future通常由异步函数返回,该函数返回future并安排最终解决该future。因为异步函数承诺最终解决它们返回的future,所以异步函数有时被称为“承诺”;但是我们将避免使用这个术语,因为它往往会比它所解释的更容易混淆。

一个简单的异步函数示例是 Seastar 的函数 sleep()

future<> sleep(std::chrono::duration<Rep, Period> dur);

此函数安排一个计时器,以便在给定的持续时间过去时返回的future变得可用(没有关联的值)。

continuation 是在未来可用时运行的回调(通常是 lambda)。使用该方法将延续附加到未来then()。这是一个简单的例子:

#include <seastar/core/app-template.hh>
#include <seastar/core/sleep.hh>
#include <iostream> int main(int argc, char** argv) {
seastar::app_template app;
app.run(argc, argv, [] {
std::cout << "Sleeping... " << std::flush;
using namespace std::chrono_literals;
return seastar::sleep(1s).then([] {
std::cout << "Done.\n";
});
});
}

在这个例子中,我们看到我们从seastar::sleep(1s)获得一个future,并附加一个打印“Done.”信息的continuationfuture将在 1 秒后变为可用,此时继续执行。运行这个程序,我们确实立即看到消息“Sleeping...”,一秒钟后看到消息“Done.”出现并且程序退出。

then()的返回值本身就是一个future,它对于一个接一个地链接多个延续很有用,我们将在下面解释。但是这里我们只注意我们从app.run的函数return这个future ,这样程序只有在sleep和它的continuation 都完成后才会退出。

为了避免在本教程的每个代码示例中重复样板“app_engine”部分,让我们创建一个简单的 main(),我们将使用它来编译以下示例。这个 main 只是调用 function future<> f(),进行适当的异常处理,并在f解决返回的 future 时退出:

#include <seastar/core/app-template.hh>
#include <seastar/util/log.hh>
#include <iostream>
#include <stdexcept> extern seastar::future<> f(); int main(int argc, char** argv) {
seastar::app_template app;
try {
app.run(argc, argv, f);
} catch(...) {
std::cerr << "Couldn't start application: "
<< std::current_exception() << "\n";
return 1;
}
return 0;
}

与这个main.cc一起编译,上面的 sleep() 示例代码变为:

#include <seastar/core/sleep.hh>
#include <iostream> seastar::future<> f() {
std::cout << "Sleeping... " << std::flush;
using namespace std::chrono_literals;
return seastar::sleep(1s).then([] {
std::cout << "Done.\n";
});
}

到目前为止,这个例子并不是很有趣——没有并行性,同样的事情也可以通过普通的阻塞 POSIX 来实现sleep()。当我们并行启动多个sleep()期货并为每个期货附加不同的延续时,事情变得更加有趣。futurescontinuation使并行性变得非常容易和自然:

#include <seastar/core/sleep.hh>
#include <iostream> seastar::future<> f() {
std::cout << "Sleeping... " << std::flush;
using namespace std::chrono_literals;
seastar::sleep(200ms).then([] { std::cout << "200ms " << std::flush; });
seastar::sleep(100ms).then([] { std::cout << "100ms " << std::flush; });
return seastar::sleep(1s).then([] { std::cout << "Done.\n"; });
}

每个sleep()then()调用立即返回:sleep()只是启动请求的计时器,并then()设置在计时器到期时调用的函数。所以所有三行都立即发生并且 f 返回。只有这样,事件循环才开始等待三个未完成的future就绪,当每个都就绪时,附加到它的continuation运行。上述程序的输出当然是:

$ ./a.out
Sleeping... 100ms 200ms Done.

sleep()返回future<>,这意味着它将在将来完成,但一旦完成,就不会返回任何值。更有趣的future确实指定了稍后将可用的任何类型(或多个值)的值。在下面的示例中,我们有一个返回future<int> 的函数,以及一个在该值可用时运行的 continuation。请注意continuation如何将未来的值作为参数:

#include <seastar/core/sleep.hh>
#include <iostream> seastar::future<int> slow() {
using namespace std::chrono_literals;
return seastar::sleep(100ms).then([] { return 3; });
} seastar::future<> f() {
return slow().then([] (int val) {
std::cout << "Got " << val << "\n";
});
}

函数slow()值得更详细的解释。像往常一样,此函数立即返回future<int>,并且不等待 sleep 完成,并且代码中的代码f()可以将continuation链接到此future 的完成。slow()返回的future本身就是一个future链:一旦sleep的future就绪,它就会就绪,然后返回值3。我们将在下面更详细地解释then()如何返回future,以及这如何允许链接future

这个例子开始展示期货编程模型的便利性,它允许程序员巧妙地封装复杂的异步操作。slow()可能涉及需要多个步骤的复杂异步操作,但它的用户可以像简单地使用sleep()一样轻松地使用它,并且 Seastar 的引擎负责在正确时间运行其future已就绪的continuation

就绪的future

then()被调用以将continuation链接到它时future值可能已经准备好。这个重要的案例已经过优化,通常会立即运行延续,而不是注册到稍后在事件循环的下一次迭代中运行。

这种优化通常会进行,但有时会避免: then()的实现持有这样一个立即运行的continuation的计数器,并且在立即运行许多continuation而不返回事件循环(当前限制为 256)之后,下一个continuation会被推迟到事件循环。这很重要,因为在某些情况下(例如后面讨论的未来循环),我们会发现每个准备好的continuation都会产生一个新的continuation,如果没有这个限制,我们可能会饿死事件循环。重要的是不要让事件循环饿死,因为这会饿死那些尚未准备好但已经准备好的futurecontinuation,也会饿死由事件循环完成的重要的轮询(例如,检查网卡上是否有新活动)。

make_ready_future<>可用于返回已经准备好的future。以下示例与前一个示例相同,除了承诺函数fast()返回一个已经准备好的future,而不是像上一个示例那样在一秒钟内准备好。好消息是future的消费者并不关心,并且在两种情况下都以相同的方式使用future

#include <seastar/core/future.hh>
#include <iostream> seastar::future<int> fast() {
return seastar::make_ready_future<int>(3);
} seastar::future<> f() {
return fast().then([] (int val) {
std::cout << "Got " << val << "\n";
});
}

[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是一种隐式的的全局竞争。