spdk探秘-----基本框架及bdev范例分析
Spdk框架介绍
存储性能开发工具包(SPDK)提供了一组工具和库,用于编写高性能,可伸缩的用户模式存储应用程序。它通过使用一些关键技术实现了高性能:
(1)、将所有必需的驱动程序移动到用户空间,这样可以避免系统调用并启用应用程序的零拷贝访问。
(2)、轮询硬件用于完成而不是依赖中断,这降低了总延迟和延迟差异。
(3)、避免I / O路径中的加锁方式来进行线程间的通信,而是依赖于消息传递。
SPDK的基石是用户空间,轮询模式,异步,无锁NVMe驱动程序。这提供了从用户空间应用程序直接到SSD的零拷贝,高度并行访问。
SPDK提供了一个完整的块堆栈作为用户空间库,它执行许多与操作系统中的块堆栈相同的操作。这包括统一不同存储设备之间的接口,排队以处理诸如内存不足或I / O挂起以及逻辑卷管理等情况。最后,SPDK提供基于这些组件构建的NVMe-oF,iSCSI和vhost服务器,这些服务器能够通过网络或其他进程提供磁盘。NVMe-oF和iSCSI的标准Linux内核启动器与这些目标以及带有vhost的QEMU互操作。
SPDK的应用框架可以分为以下几部分:(1) 对CPU core和线程的管理;(2) 线程间的高效通信;(3) I/O的的处理模型以及数据路径(data path)的无锁化机制。
CPU core和线程的管理
SPDK一大宗旨是使用最少的CPU核和线程来完成最多的任务。为此,SPDK在初始化程序时(目前调用spdk_app_start函数)限定使用绑定CPU的哪些核,可以在配置文件或命名行中配置,例如在命令行中使用-c 0x5是指使用core0 和core2来启动程序。通过CPU核绑定函数的亲和性可以限制住CPU的使用,并且在每个核上运行一个thread,该thread在SPDK中被称为Reactor (如Figure 1所示)。目前SPDK的环境库 (ENV) 缺省仍旧使用了DPDK的EAL库来进行管理。总而言之,Reactor thread执行一个函数 (_spdk_reactor_run), 该函数的主体包含一个while (1) {} 功能的函数,直到Reactor的state被改变,例如受到 (spdk_app_stop 的调用)。为了高效,上述循环中也会有一些相应的机制让出CPU资源 (诸如sleep)。这样的机制大多时候会导致CPU使用100%的情况,这点和DPDK比较类似。
换言之,假设一个使用SPDK编程框架的应用运用了两个CPU core,那么每个core上就会启动一个Reactor thread。如此一来,用户怎么执行自己的函数呢?为了解决该问题,SPDK提供了一个Poller的机制,即用户定义函数的分装。SPDK提供的Poller分为两种:(1) 基于定时器的Poller;(2) 非定时器的Poller。SPDK的Reactor thread对应的数据结构(struct spdk_reactor) 有相应的列表来维护Poller的机制。例如,一个链表维护定时器的Poller,一个链表维护非定时器的Poller,并且提供Poller的注册和销毁函数。在Reactor的while循环中,它会不停的check这些Poller的状态,进行相应的调用,用户的函数也因此可以进行相应的调用。由于单个CPU上只有一个Reactor thread,所以同一个Reactor thread 中不需要一些锁的机制来保护资源。当然,位于不同CPU的core上的thread还是需要通信必要。为了解决该问题,SPDK封装了线程间异步传递消息 (Async Messaging Passing) 的方式。
线程间的高效通信
SPDK放弃使用传统的加锁方式来进行线程间的通信,因为这种方案比较低效。为了使同一个thread只执行自己所管理的资源,SPDK提供了Event (事件调用) 机制。该机制的本质是每个Reactor对应的数据结构 (struct spdk_reactor) 维护了一个Event事件的ring (环)。这个环是多生产者和单消费者 (MPSC: Multiple producer Single Consumer) 的模型,即每个Reactor thread可以接收来自任何其他Reactor thread (包括当前的Reactor Thread) 的事件消息进行处理。目前SPDK中Event ring的缺省实现依赖于DPDK的机制,应该有线性锁的机制,但是相较于线程间采用锁的机制进行同步要高效得多。
毫无疑问,Event ring处理的同时也在进行Reactor的函数 (_spdk_reactor_run) 处理。每个Event事件的数据结构 (struct spdk_event) 其实包括了需要执行的函数、加上相应的参数以及要执行的core。简单而言,一个Reactor A 向另外一个Reactor B通信,其实就是需要Reactor B执行函数F(X) (X是相应的参数)。基于上述机制,SPDK就实现了一套比较高效的线程间通信机制。具体例子可以参照SPDK NVMe-oF target内部的一些实现,主要代码位于 (lib/nvmf) 目录。
I/O处理模型以及数据路径的无锁化
SPDK主要的I/O 处理模型是Run-to-completion,指运行直到全部完成。上述内容中提及,使用SPDK应用框架时,一个CPU core只拥有一个thread,该thread可以执行很多Poller (包括定时和非定时器)。Run-to-completion的宗旨是让一个线程最好执行完所有的任务。显而易见,SPDK的编程框架满足了该需要。如果不使用SPDK应用编程框架,则需要编程者自己注意这个事项。例如,使用SPDK用户态NVMe驱动访问相应的I/O QPair进行读写操作,SPDK 提供了异步读写的函数 (spdk_nvme_ns_cmd_read),同时检查是否完成的函数 (spdk_nvme_qpair_process_completions)。这些函数的调用应由一个线程完成,不应该跨线程处理。
SPDK 的I/O 路径也采用无锁化机制。当多个thread操作同意SPDK 用户态block device (bdev) 时,SPDK会提供一个I/O channel的概念 (即thread和device的一个mapping关系)。不同的thread 操作同一个device应该拥有不同的I/O channel,每个I/O channel在I/O路径上使用自己独立的资源就可以避免资源竞争,从而去除锁的机制。
Spdk的主要构件如下图:
驱动(Drivers)
NVMe Driver:SPDK的基础组件,这个高优化无锁的驱动有着高扩展性、高效性和高性能的特点。
Intel QuickData Technology:也称为Intel I/O Acceleration Technology(Inter IOAT,英特尔I/O加速技术),这是一种基于Xeon处理器平台上的copy offload引擎。通过提供用户空间访问,减少了DMA数据移动的阈值,允许对小尺寸I/O或NTB的更好利用。
NVMe over Fabrics(NVMe-oF)initiator:从程序员的角度来看,本地SPDK NVMe驱动和NVMe-oF启动器共享一套共同的API命令。这意味着,例如本地/远程复制将十分容易实现。
Storage Services(存储设备)
Block device abstration layer(bdev):这种通用的块设备抽象是连接到各种不同设备驱动和块设备的存储协议的粘合剂。并且还在块层中提供灵活的API,用于额外的用户功能,如磁盘阵列、压缩、去冗等等。
Blobstore:为SPDK实现一个高精简的文件式语义(非POSIX)。这可以为数据库、容器、虚拟机或其他不依赖于大部分POSIX文件系统功能集(比如用户访问控制)的工作负载提供高性能基础。
Blobstore Block Device:由SPDK Blobstore分配的块设备,是虚拟机或数据库可以与之交互的虚拟设备。这些设备得到SPDK基础架构的优势,意味着零拷贝和令人难以置信的可扩展性。
Logical Volume:类似于内核软件栈中的逻辑卷管理,SPDK通过Blobstore的支持,同样带来了用户态逻辑卷的支持,包括更高级的按需分配、快照、克隆等功能。
Ceph RADOS Block Device(RBD):使Ceph成为SPDK的后端设备,比如这可能允许Ceph用作另一个存储层。
Linux Asynchrounous I/O(AIO):允许SPDK与内核设备(比如机械硬盘)交互。
存储协议(Storage Protocols)
iSCSI target:建立了通过以太网的块流量规范,大约是内核LIO效率的两倍。现在的版本默认使用内核TCP/IP协议栈,后期会加入对用户态TCP/IP协议栈的集成。
NVMe-oF target:实现了NVMe-oF规范。将本地的高速设备通过网络暴露出来,结合SPDK通用块层和高效用户态驱动,实现跨网络环境下的丰富特性和高性能。支持的网络不限于RDMA一种,FC,TCP等作为Fabrics的不同实现,会陆续得到支持。
vhost target:KVM/QEMU的功能利用了SPDK NVMe驱动,使得访客虚拟机访问存储设备时延迟更低,使得I/O密集型工作负载的整体CPU负载减低,支持不同的设备类型供虚拟机访问,比如SCSI, Block, NVMe块设备。
bdev实例分析
以examples/bdev/hell_word为例
int main(int argc, char **argv)
{
struct spdk_app_opts opts = {};
int rc = 0;
struct hello_context_t hello_context = {};
//使用默认值初始化opts
spdk_app_opts_init(&opts);
opts.name = "hello_bdev";
//这是没有指定具体的bdev,使用默认的Malloc0
if ((rc = spdk_app_parse_args(argc, argv, &opts, "b:", NULL, hello_bdev_parse_arg,
hello_bdev_usage)) != SPDK_APP_PARSE_ARGS_SUCCESS) {
exit(rc);
}
if (opts.config_file == NULL) {
SPDK_ERRLOG("configfile must be specified using -c <conffile> e.g. -c bdev.conf\n");
exit(1);
}
hello_context.bdev_name = g_bdev_name;
//通过spdk_app_start()库会自动生成所有请求的线程,用户的执行函数hello_start跑在该函数分配的线程上,直到应用程序通过调用spdk_app_stop()终止,或者在调用调用者提供的函数之前,在spdk_app_start()内的初始化代码中发生错误情况。
rc = spdk_app_start(&opts, hello_start, &hello_context);
if (rc) {
SPDK_ERRLOG("ERROR starting application\n");
}
//当应用程序停止时,释放我们分配的内存
spdk_dma_free(hello_context.buff);
//关闭spdk子系统
spdk_app_fini();
return rc;
}
Hell_word的具体执行函数是在hello_start中,hello_start运行在spdk分配的线程上:
static void
hello_start(void *arg1)
{
struct hello_context_t *hello_context = arg1;
uint32_t blk_size, buf_align;
int rc = 0;
hello_context->bdev = NULL;
hello_context->bdev_desc = NULL;
SPDK_NOTICELOG("Successfully started the application\n");
//根据bdev_name,这里使用默认的Malloc0,获取bdev
hello_context->bdev = spdk_bdev_get_by_name(hello_context->bdev_name);
if (hello_context->bdev == NULL) {
SPDK_ERRLOG("Could not find the bdev: %s\n", hello_context->bdev_name);
spdk_app_stop(-1);
return;
}
//通过调用spdk_bdev_Open()打开bdev函数将返回一个描述符
SPDK_NOTICELOG("Opening the bdev %s\n", hello_context->bdev_name);
rc = spdk_bdev_open(hello_context->bdev, true, NULL, NULL, &hello_context->bdev_desc);
if (rc) {
SPDK_ERRLOG("Could not open bdev: %s\n", hello_context->bdev_name);
spdk_app_stop(-1);
return;
}
SPDK_NOTICELOG("Opening io channel\n");
// 通过描述符获取io channel
hello_context->bdev_io_channel = spdk_bdev_get_io_channel(hello_context->bdev_desc);
if (hello_context->bdev_io_channel == NULL) {
SPDK_ERRLOG("Could not create bdev I/O channel!!\n");
spdk_bdev_close(hello_context->bdev_desc);
spdk_app_stop(-1);
return;
}
//这里是获取bdev的块大小和最小内存对齐,这是使用spdk_dma_zmalloc所要求的,当然了你也可以不用spdk_dma_zmalloc申请内存使用通用的内存申请方式。
blk_size = spdk_bdev_get_block_size(hello_context->bdev);
buf_align = spdk_bdev_get_buf_align(hello_context->bdev);
hello_context->buff = spdk_dma_zmalloc(blk_size, buf_align, NULL);
if (!hello_context->buff) {
SPDK_ERRLOG("Failed to allocate buffer\n");
spdk_put_io_channel(hello_context->bdev_io_channel);
spdk_bdev_close(hello_context->bdev_desc);
spdk_app_stop(-1);
return;
}
//初始化buf
snprintf(hello_context->buff, blk_size, "%s", "Hello World!\n");
//开始写
hello_write(hello_context);
}
开始写操作
static void
hello_write(void *arg)
{
struct hello_context_t *hello_context = arg;
int rc = 0;
uint32_t length = spdk_bdev_get_block_size(hello_context->bdev);
//异步写,写完成后调用回调函数write_complete
SPDK_NOTICELOG("Writing to the bdev\n");
rc = spdk_bdev_write(hello_context->bdev_desc, hello_context->bdev_io_channel,
hello_context->buff, 0, length, write_complete, hello_context);
if (rc == -ENOMEM) {
SPDK_NOTICELOG("Queueing io\n");
/* In case we cannot perform I/O now, queue I/O */
hello_context->bdev_io_wait.bdev = hello_context->bdev;
hello_context->bdev_io_wait.cb_fn = hello_write;
hello_context->bdev_io_wait.cb_arg = hello_context;
spdk_bdev_queue_io_wait(hello_context->bdev, hello_context->bdev_io_channel,
&hello_context->bdev_io_wait);
} else if (rc) {
SPDK_ERRLOG("%s error while writing to bdev: %d\n", spdk_strerror(-rc), rc);
spdk_put_io_channel(hello_context->bdev_io_channel);
spdk_bdev_close(hello_context->bdev_desc);
spdk_app_stop(-1);
}
}
写的回调函数
static void
write_complete(struct spdk_bdev_io *bdev_io, bool success, void *cb_arg)
{
struct hello_context_t *hello_context = cb_arg;
uint32_t length;
//响应完成,用户必须调用spdk_bdev_free_io()来释放资源
spdk_bdev_free_io(bdev_io);
if (success) {
SPDK_NOTICELOG("bdev io write completed successfully\n");
} else {
SPDK_ERRLOG("bdev io write error: %d\n", EIO);
spdk_put_io_channel(hello_context->bdev_io_channel);
spdk_bdev_close(hello_context->bdev_desc);
spdk_app_stop(-1);
return;
}
//初始化buf为读做准备
length = spdk_bdev_get_block_size(hello_context->bdev);
memset(hello_context->buff, 0, length);
//开始读
hello_read(hello_context);
}
读操作
static void
hello_read(void *arg)
{
struct hello_context_t *hello_context = arg;
int rc = 0;
uint32_t length = spdk_bdev_get_block_size(hello_context->bdev);
//读和写差不多,也是异步执行,等待回调函数
SPDK_NOTICELOG("Reading io\n");
rc = spdk_bdev_read(hello_context->bdev_desc, hello_context->bdev_io_channel,
hello_context->buff, 0, length, read_complete, hello_context);
if (rc == -ENOMEM) {
SPDK_NOTICELOG("Queueing io\n");
/* In case we cannot perform I/O now, queue I/O */
hello_context->bdev_io_wait.bdev = hello_context->bdev;
hello_context->bdev_io_wait.cb_fn = hello_read;
hello_context->bdev_io_wait.cb_arg = hello_context;
spdk_bdev_queue_io_wait(hello_context->bdev, hello_context->bdev_io_channel,
&hello_context->bdev_io_wait);
} else if (rc) {
SPDK_ERRLOG("%s error while reading from bdev: %d\n", spdk_strerror(-rc), rc);
spdk_put_io_channel(hello_context->bdev_io_channel);
spdk_bdev_close(hello_context->bdev_desc);
spdk_app_stop(-1);
}
}
读完成后回调函数被调用
static void
read_complete(struct spdk_bdev_io *bdev_io, bool success, void *cb_arg)
{
struct hello_context_t *hello_context = cb_arg;
if (success) {
SPDK_NOTICELOG("Read string from bdev : %s\n", hello_context->buff);
} else {
SPDK_ERRLOG("bdev io read error\n");
}
//结束后关闭channel,释放资源
spdk_bdev_free_io(bdev_io);
spdk_put_io_channel(hello_context->bdev_io_channel);
spdk_bdev_close(hello_context->bdev_desc);
SPDK_NOTICELOG("Stopping app\n");
spdk_app_stop(success ? 0 : -1);
}
spdk用起来还是非常方便de 。
上一篇: c#用表达式树实现深拷贝功能
下一篇: Rocksdb的优劣及应用场景分析