当 C++ 遇上 SPDK。

这两天在学习 SPDK。对于存储软件的开发者来说,它是很好的基础设施。但是这种把回调函数和 context 作为参数,传给异步调用的模式让我有一朝返回解放前的感觉。联想到 Rust 和 Python 语言中的 async/await 语法,再加上两年 seastar 的开发者加入的 coroutine 支持,作为 C++ 程序员不得不重新审视一下,我们是不是也能用新的语法,把异步的 SPDK C++ 程序写得更赏心悦目,易于维护呢?Seastar 作为 C++ 异步编程框架中不可忽视的一员,同时提供了 future/promise 和 C++20 的异步编程模型,如果加上 SPDK 肯定会如虎添翼,成为一个更好的平台。

Seasetar 中的 DPDK

先看看 Seastar 是怎么集成 DPDK 的吧。

smp::get_options_description() 里面,为 DPDK 的 --huge-dir 注册了 "hugepages" 的命令行选项。

smp::configure() 里面,CPU 核的设置 allocation 经过几次转换,还是作为命令行,传给了 rte_eal_init():

  1. dpdk::eal::init()

    1. rte_eal_init()

    2. 在每个 RTE 核上运行之前交给 create_thread() 的 lambda。这个 lambda 暂且叫做 reactor_run 吧。

其中,reactor_run 负责初始化 reactor 线程,和执行调度到的任务:

  1. 设置线程名字

  2. 分配自己的 hugepage

  3. 分配 io queue

  4. 设置 smp message queue

  5. reactor::do_run()

    1. 注册所有的 poller。请注意,poller 在各自的构造函数里面,新建一个 task。它们用 task 来把自己加到 reactor._pollers 里面去。poller 可以用来定期等待消息,并处理消息。比如:

      • smp_poller 用来接收其他 reactor 发来的消息

      • aio 或者 epoll 等到的消息

      • reactor::signals 检查 POSIX signal,并调用 signal handler

      • 低精度的 timer

    2. 成批执行 task。Seastar 允许开发者把一组任务 一起执行

    3. 轮询所有的 poller

    4. 根据是否有遗存的工作决定是否进入休眠模式

SPDK

初始化

这里通过分析 SPDK 的初始化过程,关注它的设置,以及调度方式,希望更好地设计 Seastar 和 SPDK 沟通的方式,比如如何初始化,如何和 SPDK 传递消息。SPDK 关心的设置是 DPDK rte_eal_init() 的超集,除了 DPDK 的相关设置,它还有很多 SPDK 特有的设置 spdk_env_opts ,比如

  • no_pci

  • num_pci_addr

每个 SPDK app 都需要执行 spdk_app_start()

  1. app_setup_env(spdk_app_opts)

    1. spdk_env_init(spdk_env_opts)

      1. rte_eal_init(argc, argv): 参数是根据 spdk_env_opts 构造的。

      2. PCI 相关的初始化

  2. spdk_reactors_init()

    1. spdk_mempool_create(): 分配内存池

    2. 为每个核初始化 reactor,设置下面的设施

      • event ring buffer

      • event fd

  3. 新建一个 app_thread,并把 bootstrap_fn 调度到该 thread 上执行

    1. bootstrap_fn()

      1. 解析给出的 json 文件,里面包含一系列子系统的配置

      2. 初始化 RPC 服务

      3. 连接 RPC 服务,挨个加载子系统

  4. spdk_reactors_start(): 在每个 reactor 上执行 reactor_run

reactor_run

reactor_run 中:

  1. 批量地处理 reactor→events

  2. 调用所有 spdk_thread 的 poller

    1. 批量处理 thread→messages

    2. 依次调用 thread→active_pollers

    3. 依次调用 thread→timed_pollers

请注意,spdk 会利用 poller 实现定时器和定期执行执行操作的功能。后者把 reactor 作为 worker thread,执行非阻塞的常规任务。比如 vdev_workervdev_mgmt_worker。这个用法和 Seastar 的 reactor::io_queue_submission_pollfn 相似。但是 Seastar 目前没有把注册 poller 的功能作为公开的 API 提供出来。如果把这个 poll 的任务定义成 task,在退出之前再次调度它自己,那么这种实现可能会降低 Seastar 任务调度的性能。因为在这个 poller 注销之前,它重复地新建和销毁任务,并把任务加入和移出 reactor 的任务列表。这会浪费很多 CPU cycle。

Seastar 框架下 SPDK 的线程

这里结合 Seastar 框架,通过对比两者的线程模型。进一步探索一些可能的实现方式,我们可能会需要回答下面的问题,然后分别解决。

  • 如何管理多个用户层面的任务

  • 如何发起一个异步调用

  • 如何知道一个异步调用完成了

  • 如何传递消息

    • 不同 core 是直接如何通信的。

    • 不同任务之间是直接如何通信的。

每个 core 都有自己的 MPSC (multiple producer single consumer) 消息队列,用于接收发给自己的消息。和 Seastar smp 调用对应的逻辑对应着看,可以发现

  • spdk_event_call() 等价于 seastar::smp::submit_to()

  • event_queue_run_batch() 等价于 smp::poll_queues()

前面解释 reactor_run 的逻辑的时候提到一个概念叫做 spdk_thread。它是 SPDK 中的用户线程。不同的 spdk_thread 之间通过接受方线程的消息队列来互相通信。用户线程消息队列的类型和 core 的消息队列类型和大小相同。spdk_thread_send_msg() 是用来往特定线程发送消息的。值得注意的是,SPDK 内部很多地方都使用了 spdk_thread,比如 bdev 模块就把 spdk_bdev_io 和一个 spdk_thread 相对应,实现 IO 的序列化。所以我们如果要让 Seastar 能更好的支持 SPDK 的话,就必须实现这个机制。

对于 SPDK 来说,spdk_thread 是一个工作协程,用来承载不同的业务。很多时候被用来并序列化并执行各种操作,它属于一个特定的 core。不过它可以根据调度算法动态地迁移到另一个 core。作为运行在所有 core 上的调度器,这个服务可以在 seastar::sharded<> 的框架下实现。不过这个调度器和 Seastar 的原生调度算法还有一些区别:

  • seastar::sharded<> 既可以在单个 core 上启动,也可以同时在所有 core 上一起 启动。

  • spdk_thread 可以根据调度算法动态迁移。spdk_thread 一般来说属于 一个 core 的,但是根据它的 cpumask,一个 spdk_thread 可以 根据 CPU 的负载 迁移到 cpumask 包含的的任意一个 core。这一点 Seastar 尚无支持。

  • 因为 spdk_thread 自己有消息队列、poller 等基础设施,我们可以把它视为一个逻辑的 reactor。这个特性在 Seastar 目前还没有与之对应的实现。

  • 每个 core 都维护着一组 spdk_thread。SPDK 甚至用 thread local storage 跟踪 其中一个。这个很像进程中的一组线程。spdk_get_thread() 返回的就是被跟踪的 那个 spdk_thread。目前 Seastar 的 reactor 并没有对应的概念,但是我们可以用 一个 seastar::sharded<> 服务来保存对应 core 上的所有 spdk_thread

  • 允许动态地注册和注销 poller。SPDK 中有两种 poller。一种是系统级的,负责 保证 SPDK 事件系统和 reactor 的基本运作。另一种是用户级的,它允许实现具体功能 的模块自己定期轮询业务相关的事件。这些用户级的 poller 就是注册在前面提到的 spdk_get_thread() 返回的线程中的。参见 spdk_poller_register()spdk_poller_unregister() 的实现。如果继续沿着刚才的思路往前,我们可以把 一组 spdk_thread 保存在,比如说,seastar::sharded<spdk::ThreadGroup> 里面, 让 spdk::ThreadGroup 来为它管理的 spdk_thread 服务。它会用 reactor::poller::simple() 来注册自己的 do_complete() 函数,后者遍历 所有的 spdk_thread 的 poller。也允许应用程序在任意时刻为指定的 spdk_thread 添加 poller。这个做法和 virtio 中 vring<> 的实现相同。

  • 同时支持中断模式和轮询模式。这是 SPDK 最近加入的一个新特性,甚至允许应用的 poller 工作在可定制的中断模式。

节能、提高 CPU 的使用率和负载均衡,这些作为一个总体的设计目标,SPDK 做得相对比较深入。它根据线程的统计数据,比如说闲忙的时间比 (spdk_thread_stats),来决定如何调度,Seastar 仅在 reactor 的实现里面通过调用 pure_check_for_work() 来判断 CPU 当下是否有工作要做,如果没有的话,就进入浅层的睡眠模式。笔者认为,这也许不仅仅是工程量多少的问题。也可能是因为 Seastar 对自身的定位,它提供了基础的异步编程模型,异步调用,以及基本的 IO 调度,但是它并不希望干涉用户业务在不同 shard 上的分布,而是把这个问题留给应用的开发者。

要在 Seastar 的框架下实现 spdk_thread 的这些高级特性是完全有可能的:

  • 根据负载动态调度工作协程:不仅仅 spdk_thread 需要统计自己的关于调度的统计 信息,每个 spdk::ThreadGroup 也需要统计各自的 idle_tscbusy_tsc。 并提供接口供调度器查询,作为负载均衡的依据,然后在 shard 间调度任务。

  • 和 SPDK 的 reactor 类似,spdk::ThreadGroup 也要保存一个 "leader" thread, 后者负责常规的 poller 注册和注销工作。

  • spdk::ThreadGroup 启动的时候需要向 reactor 注册自己的总 poller,负责调用非 定时的 poller。

  • 在新注册 poller 的时候,需要按照 poller 是否有周期区别处理。

    • 如果 poller 指定了周期,那么需要新建 seastar::timer,并在 spdk::ThreadGroup 中维护一个 map,方便在运行的时候根据 spdk_poller* 找到 seastar::timer 暂停 或者注销。

    • 如果是没有周期的 poller,那么直接加入当前 spdk::ThreadGroup 的 leader thread。 让后者的 poller 来调用新注册的 poller。这种分层的设计也方便管理对象的生命周期和统计 运行时指标。

在 SPDK 里面,要发起一个异步调用最典型的方式,类似下面的代码:

rc = spdk_bdev_write(hello_context->bdev_desc,
                     hello_context->bdev_io_channel,
                     hello_context->buff,
                     0, length,
                     write_complete, hello_context);

这段代码摘自 examples/bdev/hello_world/hello_bdev.c。这里以 bdev 的 NVMe 后端为例:

  1. hello_context→bdev_io_channel 的 cache 或者 bdev 的内存池分配一个 spdk_bdev_io

  2. 用给定的参数设置这个 spdk_bdev_io,这样这个 I/O 就知道需要写的数据位置,长度,甚至 回调函数的函数指针和参数也保存在这个 I/O 里面了。

  3. nvme_qpair 的提交列表的末尾添加新的 I/O。

  4. 通过修改提交队列末尾的 door bell,告诉 nvme_qpair,提交列表里多了一个新的 I/O。

那么我们怎么知道 NVMe 设备完成了这个写操作呢?下面的函数处理指定的 queue pair 上所有完成了的 I/O 请求。

int32_t spdk_nvme_qpair_process_completions(struct spdk_nvme_qpair *qpair,
                                            uint32_t max_completions);

这个做法很像 io_getevents(),都是从完成列表收割完成了的 I/O 请求。这个过程很像播种和收割。提交请求就是播种,检查完成了的请求就像是收割。让作物成熟的魔法师就是轮询模式的驱动 (polling mode driver)。

既然 SPDK 用 spdk_thread 实现用户协程,那么协程之间要协作的话,该怎么做呢?就是前面提到的"发送消息"。消息保存在大小为 65535 的一个 ring buffer 里面。顺便提一下,其实 Seastar 也有类似的数据结构,称为 seastar::circular_buffer_fixed_capacity。如果有必要的话,我们甚至可以把 SPDK 的 event 和 thread 子系统完全换成 Seastar 的实现。

SPDK 的 then()

回调函数是 C 语言实现异步编程一个很简单直接的方式,但是它似乎和 Seastar 的 future<> 格格不入。SPDK 和 DPDK 一脉相承,有着深层的血缘关系,我们是不是可以照着 seastar::net::qp<> 实现 SPDK 支持呢?看上去这种基于成对的 submission 和 completion queue 的抽象也适用于很多 SPDK 的场景。先比较一下基于流的操作和基于块的操作有什么异同:

bdev

net::qp

发送

读写指令

发给对方的包

接收

设备状态

对方发来的包

等待

特定写指令的完成

发送的进度

等待

特定读指令返回的数据

下一个接收的报文

因为 bdev 需要跟踪特定请求的状态而不是一个 进度,所以我们无法使用 seastar::stream 定义 bdev 的读写接口。更好的榜样应该是 seastar::file。每个 posix_file_impl 都有一个 _io_queue 的引用,同一 devid 的所有 _io_queue 指向 reactor 统一维护的同一个 queue。这些 queue 用 devid 来索引。SPDK 作为专业的底层设施自然也有对应的设计。需要理解的是 io_sinkio_requestio_completion 这些组件是如何互相协作的。

还有个问题,SPDK 是一个有丰富接口的工具集,它有多个模块。每个模块都有自己的一组回调函数。光 bdev 就有 11 种回调函数:

typedef void (*spdk_bdev_remove_cb_t)(void *remove_ctx);
typedef void (*spdk_bdev_event_cb_t)(enum spdk_bdev_event_type type,
                                     struct spdk_bdev *bdev,
                                     void *event_ctx);
typedef void (*spdk_bdev_io_completion_cb)(struct spdk_bdev_io *bdev_io,
                                           bool success,
                                           void *cb_arg);
typedef void (*spdk_bdev_wait_for_examine_cb)(void *arg);
typedef void (*spdk_bdev_init_cb)(void *cb_arg, int rc);
typedef void (*spdk_bdev_fini_cb)(void *cb_arg);
typedef void (*spdk_bdev_get_device_stat_cb)(struct spdk_bdev *bdev,
                                             struct spdk_bdev_io_stat *stat,
                                             void *cb_arg, int rc);
typedef void (*spdk_bdev_io_timeout_cb)(void *cb_arg, struct spdk_bdev_io *bdev_io);
typedef void (*spdk_bdev_io_wait_cb)(void *cb_arg);
typedef void (*spdk_bdev_histogram_status_cb)(void *cb_arg, int status);
typedef void (*spdk_bdev_histogram_data_cb)(void *cb_arg, int status,
                                            struct spdk_histogram_data *histogram);

不过其中常用的可能只有:

typedef void (*spdk_bdev_io_completion_cb)(struct spdk_bdev_io *bdev_io,
                                           bool success,
                                           void *cb_arg);
typedef void (*spdk_bdev_get_device_stat_cb)(struct spdk_bdev *bdev,
                                             struct spdk_bdev_io_stat *stat,
                                             void *cb_arg, int rc);

前者用来处理一个完成了的 I/O,后者用来获取块设备的统计信息。回到刚才提到的 spdk_bdev_write()。对应的 Seastar 风格的一个 bdev 定义可能像这样:

class bdev {
  explicit bdev(const char* name);
  ~bdev();
  future<> write(uint64_t pos, const void* buffer, size_t len);
  future<> read(uint64_t pos, void* buffer, size_t len);
  future<io_state> stat();
};

这个接口和 seastar::file 对应,忽略了 io channel 这些 SPDK 独有的机制。问题是

  • 是否需要使用 SPDK 的回调函数实现异步调用呢?

  • 是的话,如何实现?

  • 不是的话,又怎么处理?

对于第一个问题,笔者认为,如果没有必要,还是应当尽量使用 SPDK 的方法,而不是自己开发一套机制替代它,这样的好处显而易见:因为 SPDK 的公开方法相对稳定,这样能减少跟踪上游带来的维护成本,把对 SPDK 的改动减少到最小,同时也增加了这个改动进入 SPDK 和 Seastar 上游的机会。但是新的问题出现了:

  • 这个回调函数是什么?

    • 我们可以把回调函数定义成为一个 bdev 的静态成员函数,便于访问它的私有成员。

    • 回调函数应该能调用 _pr.set_value(res)。其中,_pr 是和返回的 future<> 对应的 promise<>

  • 回调函数的参数呢?这个参数至少要让我们能定位到 _pr。在 AIO 后端的实现里面, 当它在 poller 里面收集到完成了的事件之后,依次调用事件对应的 completion→complete_with() 函数。下面是从 Seastar 摘录的相关代码:

r = io_pgetevents(_polling_io.io_context, 1, batch_size, batch, tsp, active_sigmask);
for (unsigned i = 0; i != unsigned(r); ++i) {
  auto& event = batch[i];
  auto* desc = reinterpret_cast<kernel_completion*>(uintptr_t(event.data));
  desc->complete_with(event.res);
}

io_completion 则会调用 io_completion::complete(res)。后者由 io_completion 的子类各自实现。以 io_desc_read_write 为例,它从 io_completion 继承,并负责与 fair_queue 沟通,也保存了 _pr。在 io_desc_read_write::complete() 里,

_pr.set_value(res);
delete this;

如果不使用回调函数的话,我们其实也需要模仿现有 Seastar 中对 aio 的支持,自己实现一个基于队列的轮询机制。我们以写文件为例,看看 Seastar 的 AIO 后端的实现吧。在 posix_file_impl::do_write_dma() 中,它调用 engine().submit_to_write()

  1. io_queue::queue_request()

    1. 构造一个 unique_ptr<queued_io_request> 对象

    2. queued_io_request::_fq_entry 加入 io_queue::fair_queue 队列。通过这个 _fq_entry 是可以找到包含它的 queued_io_request 对象,并顺藤摸瓜,找到 kernel_completion

    3. 返回 queued_req→get_future()

然后开始了接力比赛,接力棒就是 I/O 请求:

  1. 第一棒:把 I/O 请求从 io queue 取出,经由按照它们所属类型的权重分配的公平队列, 加入 io_sink::pending_io

Diagram
  1. 第二棒:从 io_sink::pending_io 取出 I/O 请求,把这些请求加入 AIO 的 io_context 队列,换句话说,就是把请求加入 submission queue。

Diagram
  1. 第三棒: 使用 io_pgetevents() 系统调用,读取 completion queue 里面的异步 I/O 事件。

Diagram

事实上,Seastar 的 I/O 子系统用了 5 个 poller

Diagram

请注意,这五个 poller 的执行顺序影响着请求的延迟。因为后面一个 poller 的输入可能就是前一个 poller 的输出。这样同时也有助于减小内存子系统的压力,因为请求在 queue 里面积压的数量和时间越长,就意味着有越多的内存不可用。而这些内存有相当部分是按照下面存储介质的块对齐的,可能会有更多的内部碎片。所以尽早地释放它们,也更有利于提高系统的性能。这里有两个 reap_kernel_completions_pollfn 是希望一个 poller 能及早地释放 I/O queue 里面的 I/O 占用的内存空间;而让另一个 poller 能处理那些立即返回的 I/O 请求。

如果 Seastar 使用 SPDK 作为其存储栈,可能也需要对应的 poller:

  1. smp_pollfn: 处理其他 reactor 发来的 I/O。它们可能也会访问当前 core 负责的 bdev。

  2. reap_spdk_completions_pollfn: 尽早地处理完成了的 I/O 请求, 减轻内存子系统的压力,也减小延迟。

  3. io_queue_submission_pollfn: 按照不同优先级把 I/O 入列

  4. spdk_submit_work_pollfn: 把 I/O 从队列里面取出,提交给 SPDK

  5. reap_spdk_completions_pollfn: 调用 spdk_thread_poll() 收集完成了的请求。

当然也可以从简处理

  1. 不用 smp_pollfn。即不支持跨 shard 发送 IO 请求,每个 shard 都用自己的 io channel。

  2. 不用第一个 reap_spdk_completions_pollfn。因为我们觉得这是个优化,以后慢慢加。

  3. 不用 io_queue_submission_pollfn,因为 SPDK bdev 层有自己基于 token bucket 的 QoS。

  4. 不用 spdk_submit_work_pollfn,既然不用 Seastar 的 fair queue,那么也不用从 io_queue 里捞 I/O 请求了。

  5. 只保留 reap_spdk_completions_pollfn。把一切都交给 SPDK。

现在我们应该能回答刚才的问题了:

回调函数的参数呢?

只要能把 I/O 请求包装成某种类似 io_completion 的类型,让它

  1. 能跟踪当初调用异步操作时,返回的 promise<> 以及

  2. 能包含在回调函数的参数 cb_arg 中,以便在 I/O 完成的时候, 通知对应的 _pr ,并且更新必要的统计信息。

就可以了。这里有两个思路:

  1. spdk_bdev_io 包含 SPDK 版的 io_completion。在回调函数里 通过 spdk_bdev_io 引用对应的 io_completion。但是 spdk_bdev_io 更多的是作为 SPDK 开放给模块的实现者的接口,而非给应用开发者的接口。 注意到 bdev.h 中,不管是读还是写操作,I/O 的接口基本只有两类

    • void *bufuint64_t offsetuint64_t nbytes

    • iovec iov[]uint64_t offsetuint64_t nbytes

上层应用在发送请求的时候是没有机会接触到 spdk_bdev_io 的,更遑论在它后面的 driver_ctx 中夹带"私货"了。况且 driver_ctx 的本意是让 bdev 的下层驱动加入自己 context,并不是提供给上层应用的。这条路走不通。

  1. 在发送 I/O 请求的时候单独构造 SPDK 版的 io_completion,把它 作为 cb_arg 交给 SPDK。在回调函数里还原 io_completion, 再如前所述,做相应的处理。

SPDK 在 Seastar 中的形态

这里希望讨论 SPDK 在 Seastar 框架中的角色,以及呈现的接口是什么样子的。

另外一个 reactor?

前面关于 poller 的讨论引出了一个问题,即

我们能重用 Seastar 的这几个 poller 吗?

这个问题在一定程度上等价于:

我们需要实现一个基于 SPDK 的 Seastar reactor 吗?

在阅读 Seastar reactor 实现的时候,可能会注意到, reactor_backend_selector 就是用来根据 --reactor-backend 命令行选项来选择使用的 reactor 后端的。这种类似插件的框架允许我们可以实现一个新的后端。虽然我们能够在 SPDK 的框架下

  • 加入 poller,并使用非阻塞的调用

  • 使用 aio 读写普通的文件

  • 使用 sock 模块

把上面这些功能组合起来,足以实现一个功能完备的 reactor_backend。但是也可以保留 Seastar 的 reactor,像 DPDK 那样另外再注册 spdk::ThreadGroup 的 poller。牵涉面小,而且工作量也少些。对于两者的集成这可能是更稳妥的第一步。也许这也是 SPDK 支持在 Seastar 中更合适的定位—​即提供块设备的访问,而非作为通用的基础设施提供文件系统的访问。这两者有共性,但是也有一些不一样的地方。比如说文件系统可以用 open_directory()list_directory() 来枚举一个目录下的所有文件,更进一步,块设备的枚举方式根据块设备的类型各自不同。SPDK 提供 spdk_nvme_probe() 来列举所有的 NVMe 设备,用 spdk_bdev_first()spdk_bdev_next() 来找出所有的块设备。另外,为了提高并发,SPDK 引入了 io channel 的概念,它也很难直接映射到 Seastar 基于文件系统的 IO 体系里面。所以比较好的办法还是先把 SPDK 在 Seastar 下实现成相对独立的模块,而不是试图把它实现成为一种和 AIO 和 epoll 并列的通用异步后端。另外,在初期最大程度保留 SPDK 的基础设施,最小侵入的实现可能是比较稳妥的途径。

典型的用例

我们用假想中的 Seastar + SPDK 重写 examples/bdev/hello_world 试试看

namespace bpo = boost::program_options;

seastar::logger spdk_logger("spdk_demo");

int main(int ac, char** av) {
    seastar::app_template seastar_app;
    seastar_app.add_positional_options({
        { "bdev", bpo::value<std::string>(), "bdev", 1 },
    });
    spdk::app spdk_app;
    return seastar_app.run(ac, av, [&] {
        auto bdev_name = seastar_app.configuration()["bdev"].as<std::string>();
        return spdk_app.run(seastar_app.configuration(), [bdev_name] {
            auto dev = spdk::block_device::open(bdev_name);
            uint32_t block_size = dev.block_size();
            size_t buf_align = dev.memory_dma_alignment();
            auto buf = spdk::dma_zmalloc(block_size, buf_align);
            return dev.write(0, buf.get(), buf.size()).then([&] {
                memset(buf.get_write(), 0xff, buf.size());
                return dev.read(0, buf.get_write(), buf.size());
            }).then([&buf] {
                temporary_buffer<char> good{buf.size()};
                memset(good.get_write(), 0, good.size());
                if (int where = memcmp(good.get(), buf.get(), buf.size());
                    where != 0) {
                    spdk_logger.error("buf mismatches at {}!", where);
                } else {
                    spdk_logger.info("buf matches!");
                }
            }).finally([buf = std::move(buf)] { });
        }).handle_exception_type([&] (std::system_error& e) {
            spdk_logger.error("error while writing/reading {}", e.what());
        });
    });
}

其中,spdk::app::run() 会初始化 SPDK app 的运行时。比如说

  1. 调用 rte_eal_init()

  2. 启动 SPDK 的工作协程调度器

  3. 启动 RPC 服务

  4. 加载各个子系统

它还会负责 SPDK 的清理工作。

spdk::bdev 将会是一个 seastar::sharded<> 服务。spdk::do_with_bdev() 则是 spdk 提供的一个 helper,它负责初始化 bdev 实例,在合适的时机调用 bdev::start()bdev::stop(),把根据第一个参数初始化完成好的 bdev 实例传给自己的另外一个参数,由后者使用 bdev。虽然这里以 bdev 模块为例,将来 Seastar 和 SPDK 的集成并不会局限于 bdev 模块。