Seastar Tutorial 简明教程

Seastar 是一个C++库,用于在现代多核机器上构建高性能服务器端应用。它采用无共享架构,利用future和continuations进行异步编程,解决了传统同步模型和事件驱动模型的复杂性和非阻塞问题。Seastar的核心特性包括协作式微任务调度、内存分片、基于未来的API、零拷贝网络栈和存储API。本文详细介绍了Seastar的异步编程概念、线程与内存管理、future、coroutines、异常处理、生命周期管理等多个方面。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

这是对于Seastar官方tutorial的部分翻译,同时也结合了自己的思考。

Introduction

Seastar是一个用于在现代多核机器上编写高性能的复杂的服务端应用的C++库。

Seastar是一个完整的异步编程框架,使用futures和continuations两个概念来抽象对于I/O事件的处理。

因为现代处理器核心共享数据会带来很多惩罚,所以Seastor想要避免多个核心共享内存,而用消息传递机制取代之。

Since modern multi-core and multi-socket machines have steep penalties for sharing data between cores (atomic instructions, cache line bouncing and memory fences), Seastar programs use the share-nothing programming model, i.e., the available memory is divided between the cores, each core works on data in its own part of memory, and communication between cores happens via explicit message passing (which itself happens using the SMP’s shared memory hardware, of course).

Asynchronous programming

最开始的服务器都是同步模型,为每一条连接分配一个进程(线程),然后在线程内以同步方式编写代码,但是很快遇到了C10K问题。

随后异步模型——事件驱动模型开始得到流行,one thread per CPU,每个线程运行一个loop,不断地使用poll(epoll)获取读写事件并处理。

但是事件驱动模型有两个问题:

  1. Complexity.很难编写复杂的异步服务器。
  2. Non-blocking.很难确保线程对事件的处理是非阻塞的,如果系统没有提供相应的非阻塞调用,那么就只能开辟额外的线程来避免CPU空闲,这又引起上下文切换的开销。

另外,在追求极致性能的时候,编程框架无法避免的要考虑两个问题:

  1. Modern Machines.现代机器有许多核心和深层内存层次(从L1缓存到NUMA),不可扩展的编程实践(比如加锁)会对多核性能造成灾难性的影响;共享内存和无锁同步原语是可用的,但是戏剧性地它们比只让数据存在于一个核心的缓存上要更慢,并且也阻止了应用程序向更多核扩展。
  2. Programming Language.我们需要能给程序员以完全的控制权、零运行时开销的语言。

Seastar就是一个为了解决上述问题的异步服务器应用编程框架,它是完全单线程的(对每个核心来说是单线程),可以很容易地扩展到多核并且共享内存带来的开销被最小化了,并且基于C++14。

Seastar

Seastar使用一些概念来实现极致性能:

  • Cooperative micro-task scheduler: 每个核心上运行一个协作式任务调度器,每个任务都非常轻量——只处理I/O操作的结果或者提交I/O操作。
  • Share-nothing SMP architecture: 每个核心都独立于其他核心,内存、数据结构、CPU时间都不共享,核心之间的通信通过消息传递完成。一个Seastar核心被称做一个shard。
  • Future based APIs: 这允许你提交一个I/O操作并且链式添加在I/O操作完成后要执行的任务。很容易去并行执行多个I/O操作。
  • Share-nothing TCP stack: Seastar也提供一个内置的高性能TCP/IP协议栈,它构建在任务调度器和无共享架构的基础之上,提供零拷贝功能。
  • DMA-based storage APIs: Seastar提供零拷贝存储的API,允许你使用DMA传输数据到存储设备上或者反过来。

Threads and memory

Seastar threads

Seastar程序在每个CPU上运行一个线程,每个线程运行自己的事件循环,它也被称作engine。Seastar程序默认会使用所有可用的核心,在每个核心上都开启一个线程。可以打印seastar::smp::count来查看被启动的线程数。

每个engine都被绑定在一个不同的硬件线程上(为了CPU亲和性),且app的初始化函数只在一个线程上运行。

可以传递命令行参数-cn来制定要运行的线程数,但是这个数字不能超过实际的硬件线程数。

Seastar memory

Seastar程序会把内存分片,每个线程预分配一大块内存,然后只使用自己的那部分内存进行分配工作。

默认情况下,Seastar程序预分配所有的机器内存(除了操作系统保留的部分),可以使用--reserve-memory选项指定要为操作系统保留的内存,或者使用-m选项指定Seastar程序要使用的内存,当然所用内存不能超过物理内存大小。

Introducing futures and continuations

future和continuation的存在使得编写大型的、复杂的异步程序更容易,编写出来的代码通常可读性好、更容易理解。

future表示一个计算的结果但是可能尚未就绪,一个future通常被一个异步函数返回,这个函数会确保future最终就绪。

continuation是一个当future就绪时被运行的回调,使用then()方法可以把一个continuation附加在一个future上。

下面的程序将在输出Sleeping…一秒后输出Done.并退出:

using namespace seastar;
int main(int argc, char **argv) {
   
    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::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"; });
}

一个future<T>类型的then中的函数体将接受一个T类型的参数:

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";
    });
}

Ready futures

当调用then时,对应future的值可能已经就绪了,那么then()将不会把continuation链式追加到future对象中,而是直接执行。

但是这种优化也有限制,then()的实现会统计这种立即执行的continuation的次数,如果达到上限(目前是256次)将会把控制返回给event loop。否则有可能造成event loop乃至其他future的continuations的饥饿现象。

make_ready_future<>将返回一个已就绪的future对象。

Coroutines

需要C++20支持。

Continuations

Capturing state in continuations

C++11中的lambda对象可以作为then()参数,在future就绪后被调用,而lambda是可以捕获参数的,所以如果continuation不能立即执行,那么lambda所捕获的参数要被保存,这需要拷贝数据并会产生运行时开销,但是这是不可避免的(总比让当前线程阻塞然后让参数保存在栈中,并且切换到别的线程要更快)。

按值捕获参数没有问题,但是如果按引用捕获就可能引起引用失效等严重bug,比如下面这个反例:

seastar::future<int> incr(int i) {
   
    using namespace std::chrono_literals;
    // Oops, the "&" below is wrong:
    return seastar::sleep(10ms).then([&i] {
    return i + 1; });
}

一个解决方法是使用do_with()习语,它可以确保一个对象的生命期长于对应continuation的生命期,这使得按引用捕获成为可能,并且非常方便。

Seastar应用中常常使用按移动捕获,即广义捕获,下面是一个例子:

int do_something(std::unique_ptr<T> obj) {
   
     // do some computation based on the contents of obj, let's say the result is 17
     return 17;
     // at this point, obj goes out of scope so the compiler delete()s it.  
}
seastar::future<int> slow_do_something(std::unique_ptr<T> obj) {
   
    using namespace std::chrono_literals;
    return seastar::sleep(10ms).then([obj = std::move(obj)] () mutable {
   
        return do_something(std::move(obj));
    });
}

注意要使用两次std::move并且声明为mutable(否则无法移动第二次——只读对象不可移动)。

Evaluation order considerations(C++14 only)

在C++17之前,针对以下调用方法:

return do_something(obj).then([obj = std::move(obj)] () mutable {
   
        return do_something_else(std::move(obj));
});

有可能先计算obj = std::move(obj),再调用do_something(obj),就导致了use–after-move问题。

解决方法就是把do_somethingthen分开写,不要写在一条语句中。

Handling exceptions

continuation执行中抛出的异常会被系统捕获并存储到对应的future对象中,此时future对象变为就绪态(但它不持有一个值,而是持有一个异常)。

对一个持有异常的future调用的then()方法将会被忽略,异常会传递给后面的continuation,就像普通函数中的异常抛出事件一样:

// 普通函数发生异常后的执行流
line1();
line2(); // throws!
line3(); // skipped

// 有异常的future的执行流
retur line1().then([] {
   
    return line2()
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值