spdk探秘-----基本框架及bdev范例分析

SPDK是由英特尔发起的,用于加速NVMe SSD作为后端存储使用的应用软件加速库。这个软件库的核心是用户态、异步、轮询方式的NVMe驱动。相比内核的NVMe驱动,SPDK可以大幅降低NVMe command的延迟,提高单CPU核的IOps,形成一套高性价比的解决方案,如SPDK的vhost解决方案可以被应用到HCI中加速虚拟机的NVMe I/O。

从目前来讲,SPDK并不是一个通用的适配解决方案。把内核驱动放到用户态,导致需要在用户态实施一套基于用户态软件驱动的完整I/O栈。文件系统毫无疑问是其中一个重要的话题,显而易见内核的文件系统,如ext4、Btrfs等都不能直接使用了。虽然目前SPDK提供了非常简单的文件系统blobfs/blostore,但是并不支持可移植操作系统接口,为此使用文件系统的应用需要将其直接迁移到SPDK的用户态“文件系统”上,同时需要做一些代码移植的工作,如不使用可移植操作系统接口,而采用类似AIO的异步读/写方式。

目前SPDK使用比较好的场景有以下几种。

· 提供块设备接口的后端存储应用,如iSCSI Target、NVMe-oF Target。

· 对虚拟机中I/O的加速,主要是指在Linux系统下QEMU/KVM作为Hypervisor管理虚拟机的场景,使用vhost交互协议,实现基于共享内存通道的高效vhost用户态Target。如vhost SCSI/blk/NVMe Target,从而加速虚拟机中virtio SCSI/blk及Kernel Native NVMe协议的I/O驱动。其主要原理是减少了VM中断等事件的数目(如interrupt、VM_EXIT),并且缩短了host OS中的I/O栈。

· SPDK加速数据库存储引擎,通过实现RocksDB中的抽象文件类,SPDK的blobfs/blobstore目前可以和RocksDB集成,用于加速在NVMe SSD上使用RocksDB引擎,其实质是bypass kernel文件系统,完全使用基于SPDK的用户态I/O栈。此外,参照SPDK对RocksDB的支持,亦可以用SPDK的blobfs/blobstore整合其他的数据库存储引擎。

SPDK NVMe驱动

在NVMe之前,相对来说一个存在时间更长的接口标准是串行ATA高级主机控制器接口(Serial ATA Advanced Host Controller Interface,AHCI)。AHCI是在英特尔领导下由多家公司联合研发的接口标准,它允许存储驱动程序启用高级串行ATA功能。相对于传统的IDE技术,AHCI对传统硬盘性能提高带来了改善。但是随着新介质、新技术的发展,AHCI对Flash SSD来说逐渐成为性能瓶颈,这个时候NVMe应运而生。

NVMe或NVMHCIS最早由英特尔于2007年提出,并领衔成立了NVMHCIS工作组。该工作组成员包括三星、美光等公司,其目标是使将来的存储产品从AHCI中解放出来。今天的固态硬盘产品已经实现了用NVMe取代AHCI的目标,从而发挥出极高的性能优势。

性能的影响主要在于固态硬盘(包括firmware)和软件的开销。从持续满足上层应用的高性能的角度看,有两种途径:一是开发更高性能的固态硬盘硬件设备;二是减少软件的开销。目前,这的确是两条在并行前进的道路。基于最新3D XPoint技术的Intel Optane NVMe SSD设备可以在延迟和吞吐量方面使得性能更上一层楼。软件的开销是NVMe SSD的性能瓶颈,如图1所示。

如此一来,急需提高软件处理的性能,以整体提高上层应用对设备的访问性能。SPDK的出现就是为了改善和解决这个问题的,SPDK的核心组件之一就是用户态NVMe驱动。

在讲解用户态驱动之前,我们先回顾一下应用程序是怎么和内核驱动进行交互的。当内核驱动模块在内核加载成功后,会被标识是块设备还是字符设备,同时定义相关的访问接口,包括管理接口、数据接口等。这些接口直接或间接和文件系统子系统结合,提供给用户态的程序,通过系统调用的方式发起控制和读/写操作。

用户态应用程序和内核驱动的交互离不开用户态和内核态的上下文切换,以及系统调用的开销。以2GHz的CPU core为例,系统调用getpid大概有100ns的开销,这部分开销还是比较可观的。不同的系统调用都会产生对应的开销。

用户态驱动出现的目的就是减少软件本身的开销,包括这里所说的上下文切换、系统调用等。在用户态,目前可以通过UIO(Userspace I/O)或VFIO(Virtual Function I/O)两种方式对硬件固态硬盘设备进行访问。

1)UIO

UIO框架最早于Linux 2.6.32版本引入,其提供了在用户态实现设备驱动的可能性。要在用户态实现设备驱动,主要需要解决以下两个问题。

· 如何访问设备的内存:Linux通过映射物理设备的内存到用户态来提供访问,但是这种方法会引入安全性和可靠性的问题。UIO通过限制不相关的物理设备的映射改善了这个问题。由此基于UIO开发的用户态驱动不需要关心与内存映射相关的安全性和可靠性的问题。

· 如何处理设备产生的中断:中断本身需要在内核处理,因此针对这个限制,还需要一个小的内核模块通过最基本的中断服务程序来处理。这个中断服务程序可以只是向操作系统确认中断,或者关闭中断等最基础的操作,剩下的具体操作可以在用户态处理。UIO架构如下图所示,用户态驱动和UIO内核模块通过/dev/uioX设备来实现基本交互,同时通过sysfs来得到相关的设备、内存映射、内核驱动等信息。

2)VFIO

相对于UIO,VFIO不仅提供了UIO所能提供的两个最基础的功能,更多的是从安全角度考虑,把设备I/O、中断、DMA暴露到用户空间,从而可以在用户空间完成设备驱动的框架。这里的一个难点是如何将DMA以安全可控的方式暴露到用户空间,防止设备通过写内存的任意页来发动DMA攻击。

IOMMU(I/O Memory Management Unit)的引入对设备进行了限制,设备I/O地址需要经过IOMMU重映射为内存物理地址(见下图)。那么恶意的或存在错误的设备就不能读/写没有被明确映射过的内存。操作系统以互斥的方式管理MMU和IOMMU,这样物理设备将不能绕过或污染可配置的内存管理表项。

解决了通过安全可控的方式暴露DMA到用户空间这个问题后,用户基本上就可以实现使用用户态驱动操作硬件设备了。考虑到NVMe SSD是一个通用的PCI设备,VFIO的PCI设备实现层(vfio-pci模块)提供了和普通设备驱动类似的作用,可高效地穿过内核若干抽象层,在/dev/vfio目录下为设备所在的IOMMU group生成相关文件,继而将设备暴露出来。

UIO和VFIO两种方式各有优势,也在不断地完善和演进中。如果想要更安全可控地操作硬件设备,笔者推荐通过采用VFIO的方式来实现用户态驱动。SPDK用户态驱动同时支持UIO和VFIO两种方式。

3)用户态DMA

基于UIO和VFIO,我们可以实现用户态的驱动,把一个硬件设备分配给一个进程,允许该进程来操作和读/写该设备。这在一定程度上提高了进程对设备的访问效率,不需要通过内核驱动来产生额外的内存复制,而是可以直接从用户态发起对设备的DMA,用户态DMA和内核态DMA如下图所示,其中虚线代表设备通过DMA直接访问相应的内存页,实线代表CPU访问内存页的方式。

但是这里需要考虑以下3个问题。

· 提供设备可以认知的内存地址(可以是物理地址,也可以是虚拟地址)。

· 物理内存必须在位(考虑到虚拟内存可能被操作系统交换出去,产生缺页)。

· CPU对内存的更新必须对设备可见。

其中第1个和第3个问题得益于英特尔平台的技术演进,通过设备直接支持IOMMU,同时和CPU之间实现缓存一致性(Cache-Coherent)来解决。这里的难点是第2个问题,从用户态进程来看,怎么保证在DMA的过程中物理内存是在位的。

现在Linux主流被认可的方法是人工把虚拟地址对应的物理内存Pin在位置上(意思是不会被换出)。这样,无论你用的是物理地址还是虚拟地址,只要完成了Pin的操作,这个地址就可以用于DMA。前面提到VFIO能安全可控地暴露DMA操作到用户态进程,在本质上也是通过Pin操作来实现的。但是这里还是有少数特定场景下的潜在问题,虚拟地址对应的页可能会被重新映射,虽然没有换出物理页。目前来说VFIO提供的DMA操作在大部分情况下是可靠的。因此,对SPDK而言,在同时能和UIO、VFIO交互的情况下,这里还是推荐使用VFIO,以确保有更好的安全性和可靠性。

4)大页(Hugepage)

前面提到了用户态驱动是如何通过DMA加速对设备进行读/写操作的,可以通过物理地址,也可以通过逻辑地址(等同于虚拟地址)。

虚拟地址映射到物理地址的工作主要是TLB(Translation Lookaside Buffers)与MMU一起来完成的。以4KB的页大小为例,虚拟地址寻址时,首先在TLB中查找,如果没有找到,则需要通过MMU加载的页表基地址进行多次寻表来找到对应的物理地址。如果找不到,则产生缺页,这时会有相应的handler进行处理,来填充页表和更新TLB。

总的来说,通过页表查询而导致缺页带来的CPU开销是非常大的,TLB的出现能很好地解决性能问题。但是经常性的缺页是不可避免的,为此我们可以采取大页的方式。

通过使用Hugepage分配大页可以提高性能。因为页大小的增加,可以减少缺页异常。例如,2MB大小的内容(假设是2MB对齐),如果是4KB大小的页粒度,则会产生512次缺页异常,但是使用2MB大小的页,只会产生一次缺页异常。页粒度的改变,使得TLB同样的空间可以保存更多虚存空间到物理空间的映射。尽可能地利用TLB,少用MMU,以减少寻址和缺页处理带来的开销,从而提高应用程序的整体性能。

大页还有一个优势是这些预先分配的内存基本上不会被换出,当进行DMA的时候,所对应的虚拟地址永远有相对应的物理页。结合VFIO,可以显著并安全地提升用户态对设备的读/写操作效率。当然,大页也有缺点,比如它需要额外配置,需要应用程序事先预估使用多少内存,大页需要在进程启动之前实现启用和分配好内存。目前,在大部分场景下,系统配置的主内存越来越多,这个限制不会成为太大的障碍。

SPDK用户态驱动

SPDK用户态驱动基于前面的各种技术,除了UIO和VFIO的支持,以及DMA和大页的加速优化,SPDK还引入了其他的优化技术来提高用户态驱动对设备的访问效率。Linux内核NVMe驱动和SPDK NVMe驱动实现的区别如表4-1所示,有针对性地描述了SPDK提高设备访问的效率所采用的方法。

  1. 异步轮询方式

UIO和VFIO需要在内核实现最基本的中断功能来响应设备的中断请求。而SPDK更进一步,这些中断请求不需要通知到用户态来处理,在UIO和VFIO内核模块做最简化的处理就可以了。SPDK用户态驱动对设备完成状态的检测,是通过异步轮询的方式来实现的,进而避免了对中断的依赖。采用这种处理方式的原因如下。

· 把内核态的中断抛到用户态进程来处理对大部分硬件是不合适的。

· 中断会引入软件处理的不确定性,同时不能避免上下文的切换。

SPDK用户态驱动的操作基本上都是采用了异步轮询的方式,轮询到操作完成时会触发上层的回调函数,这样使得应用程序无须等待读或写操作的完成,就可以按需发送多个请求,再由回调函数处理。由此来提高应用的读/写性能。这样的方式从性能上来说是有很大帮助的,但是要发挥出这个特点,需要应用做出相应的修改来匹配优化的异步轮询操作。

对NVMe SSD设备的轮询是非常快速的。按照NVMe规范,只需要读取内存中的相应内容来检测队列是否有新的操作完成。英特尔的DDIO技术可以保证设备在更新以后,相应的内容是在CPU的缓存中的,以此实现高性能的设备访问。

2.无锁化

内核态的驱动为了实现通用的块设备驱动,同时和内核其他模块深度集成,需要一些隔离的方法,比如信号量、锁、临界区等来保证操作的唯一性。SPDK用户态驱动从性能优化的角度看,一个重要的优化点就是在数据通道上去掉对锁的依赖。这里主要考虑以下问题。

· 读/写处理要在一个CPU核上完成,避免核间的缓存同步。

· 单核上的处理,对资源的分配是无锁化的。

针对第一个问题,可以通过线程亲和性的方法,来将某个处理线程绑定到某个特定的核上,同时通过轮询的方式占住该核的使用,避免操作系统调度其他的线程到该核上面。当应用程序接收到这个核上的读/写请求的时候,采用运行直到完成(Run To Completion)的方式,把这个读/写请求的整个生命周期都绑定在这个核上来完成。

这其中涉及第二个问题,在处理该核上的读/写请求时,需要分配相关的资源,如Buffer。这些Buffer主要通过大页分配而来。DPDK为SPDK提供了基础的内存管理,单核上的资源依赖于DPDK的内存管理,不仅提供了核上的专门资源,还提供了高效访问全局资源的数据结构,如mempool、无锁队列、环等。

3.专门为Flash来优化

内核驱动的设计以通用性为主,考虑了不同的硬件设备实现一个通用的块设备驱动的问题。这样的设计有很好的兼容性和维护性,但是单从性能角度看,不一定能发挥出特定性能的优势。

SPDK作为用户态驱动,就是专门针对高速NVMe SSD设备的。为了能让上层应用程序充分利用硬件设备的高性能(高带宽、低延时),SPDK实现了一组C代码开发库,这些开发库的接口可以直接和应用程序结合起来。

通过UIO或VFIO把PCI设备的BAR(Base Address Register)地址映射到应用进程的空间,这样SPDK用户态驱动就可以遵循NVMe的规范来初始化NVMe SSD,创建出最基本的I/O发送和完成队列,最终实现对NVMe SSD设备的I/O读或写操作。

这样针对Flash进行定制化的优化,能够使得SPDK用户态驱动最大化地发挥出NVMe SSD的性能优势。如果确实需要块设备的访问,SPDK也封装了自己的用户态块设备接口,用户一样可以通过逻辑区块地址来访问块设备。

SPDK NVMe驱动新特性

SPDK会随着NVMe规范的丰富不断引入新的特性到用户态驱动里面,如下图所示。

这些新特性的支持可以丰富SPDK用户态驱动的使用场景。

· Reservations:可以很好地支持双控制器的NVMe SSD(如Intel D3700),在需要高可靠性的场景下,达到控制器的备份冗余。

· Scatter Gather List(SGL):可以更灵活地分配内存,减少I/O操作,提供高效的读/写操作。

· Multiple Namespace:可以暴露给上层应用多个逻辑空间,做到在同一物理设备上的共享和隔离。

· In Controller Memory Buffer(CMB):可以把I/O的发送和完成队列放到固态硬盘设备上,同时相应的Buffer也从固态硬盘设备上来分配,一方面可以减少延时,另一方面使得两个NVMe SSD设备间的DMA成为可能。

SPDK作为高效用户态驱动的存在,除了对性能进行考量,也会从各种NVMe规范的特性的支持角度来丰富和加强SPDK对不同应用和不同物理设备需求的支持和集成。

SPDK用户态驱动多进程的支持

前面提到SPDK用户态驱动会暴露对应的API给应用程序来控制和操作硬件设备。此时内核NVMe驱动已经不会对设备做任何的操作,所以类似于/dev/nvme0和/dev/nvme0n1的设备不会存在。这样带来一个问题,如果多个应用程序都需要访问同一个硬件设备的话,那么SPDK用户态驱动该如何来支持。典型的场景有以下两种。

· NVMe SSD本身容量足够大,不同的应用程序可以共享该设备。

· 系统中还有相关的管理工具,如nvme-cli工具(用于监控和配置管理NVMe设备),用来同时访问相应的NVMe SSD。

下图所示为一个常见的多应用程序共同访问NVMe SSD的案例。

这里NVMe SSD可以通过不同的Namespace,或者在同一个Namespace中划分出不同的空间分配给不同的应用程序来进行数据存储。Optane作为性能极高的设备,可以划分不同的空间给不同的应用作为数据缓存。基于DPDK共享设备的底层支持,SPDK用户态驱动也解决了应用之间共享同一个硬件设备的问题。

1)共享内存

为了实现多个进程对同一设备的访问,这里最基础的技术就是允许内存资源能够在多个进程间共享。当内存资源可以在多个进程间共享了,那么IPC(Inter Process Communication)就会变得容易很多。

在初始化这些共享资源之前,我们给相关的进程做了区分,可以显示指定某个进程为主进程(Master Process),或者系统自动判断第一个进程为主进程。当主进程启动的时候,把相关的资源分配好,同时初始化完成需要共享的资源。当配置副进程(Slave Process)的应用启动时,无须再去分配内存资源,只需要通过共同的标识符来匹配主进程,把相关的内存资源配置到副进程上即可。DPDK中主进程和副进程共享内存的模式如下图所示。

 

2)共享NVMe SSD

前面提到SPDK实现高性能的一个技术是无锁,那么在引入共享内存的机制后,对数据通道会带来什么影响?这里需要讨论一下在多进程共享同一个NVMe SSD硬件的情况下,哪些资源是需要协同操作的,哪些资源即便是多进程相互可见,也是可以在逻辑上单独给某个进程使用的。

为了实现数据通道上的无锁化以保证高性能,我们更关注的是数据通道上的隔离。SPDK在单CPU核的情况下,可以很容易地具备低延时、高带宽的特性,这些性能指标只需要依赖少数甚至单个I/O队列就可以达到。因此这里的I/O队列是需要让某个进程从逻辑上单独使用的,即便整个NVMe SSD是对多个进程共享可见的。SPDK的用户态驱动对单独I/O队列是无锁化处理的,因此从性能考虑,只需要应用程序分配自己的I/O队列就可以达到较高的性能。

另外,由于NVMe SSD本身只有一个管理队列,因此当多个应用程序需要对设备发起相应的管理操作时,这个管理队列需要通过互斥的机制来保证操作的顺序性。相对来说,在控制通道上引入互斥机制对每个进程影响不会很大。同时,为了能够记录和回调每个应用对设备发起的管理操作,这里引入了逻辑上每个进程独有的管理队列的完成队列来完成对设备的控制。下图描述了多个进程对单个设备操作的时候,哪些是共享的,哪些是可以单独独享的。

3)管理软件完成队列

如前所述,NVMe SSD只有一个管理队列,对应一个发送队列和一个完成队列。这个管理队列是共享给所有进程的,比如每个进程都需要通过这个管理队列来创建逻辑上独享的I/O队列。这里除了通过互斥的机制来串行多个进程的需求,还需要记录请求操作和进程的对应关系,这样才能避免出现一种场景:进程A发送的创建I/O队列的请求由进程B来处理回调函数。

由此SPDK引入了针对每个进程的单独数据结构,来记录每个进程独享的资源,比如这里需要的软件模拟的完成队列。多个软件模拟的完成队列都对应到同一个管理完成队列(Admin Completion Queue)。为了区分哪一个操作属于哪一个进程,这里通过PID(Process Identifier)来标识每个进程。当任何一个进程去异步轮询管理队列时,会把所有硬件设备完成的操作取回来,同时根据请求的PID标志,将这些请求插入到对应进程的软件完成队列。之后该进程会处理对应的软件完成队列来回调用户的操作,多进程模式下Admin管理队列的处理如下图所示。

4)NVMe SSD共享管理流程

最后我们具体看一下在多进程情况下,主进程和副进程需要做些什么工作来实现多个应用对同一个设备的共享。

主进程在初始化的时候,会首先分配一个带名字且共享的资源,这样副进程可以通过名字来获得这个共享的资源。这里需要提到的是,任何需要共享的资源都应该放在某个带名字的共享资源下(见下图左侧的步骤1)。当主进程完成了共享资源的分配后,将初始化用来同步进程的互斥机制(见下图左侧的步骤2),然后初始化所有的硬件设备,完成设备的启动(见下图左侧的步骤3-4)。如果当前进程需要分配I/O队列的话,在设备正常启动后可以分配逻辑上独享的I/O队列来进行设备的操作(见下图左侧的步骤5)。

副进程在启动的时候,首先需要做的是去查找这个带名字的共享资源(见下图右侧的步骤1),然后通过共享内存的机制访问该共享资源下所有由主进程分配和初始化的资源。同时通过PID的方式创建特定的数据结构来保存属于当前副进程的资源(见下图右侧的步骤)。因为设备已经由主进程完成了正常启动,所以副进程可以直接向该设备发送管理请求来创建I/O队列(见下图右侧的步骤3)。

SPDK用户态驱动提供了对多进程访问的支持后,有几个典型的使用场景。

· 主进程完成对设备的管理和读/写操作,副进程来监控设备,读取设备使用信息。

· 主进程只负责资源的初始化和设备的初始化工作,多个副进程来操作设备,区分设备的管理通道和数据通道。

· 当主进程和副进程不进行区分时,都会对设备进行管理和读/写操作。

需要注意的是,在使用SPDK提供的该功能的时候,或者显示指定主进程,或者让系统来默认指定主进程,不可以出现都是显示指定副进程的场景。同时考虑到任何一个进程都有可能出现异步退出的场景,所以需要引入相关的锁机制和资源清理机制来保证资源的正常释放,以及后续进程的正常启动。

SPDK应用框架

仅仅提供用户态NVMe驱动的一些操作函数是不够的,如果在某些应用场景中使用不当,不仅不能发挥出用户态NVMe驱动的高性能的作用,甚至会导致程序出现错误。

虽然NVMe的底层函数有一些说明,但为了更好地发挥出底层NVMe的性能,SPDK提供了一套编程框架(Application Framework)如下图所示,用于指导软件开发人员基于SPDK的用户态NVMe驱动及用户态块设备层构造高效的存储应用。用户可以有以下两种选择。

· 直接使用SPDK应用编程框架实现应用的逻辑。

· 利用SPDK编程框架的思想,改造已有应用的编程逻辑,以更好地适配SPDK的用户态NVMe驱动。

总的来说,SPDK的应用框架可以分为:① 对CPU core和线程的管理;② 线程间的高效通信;③ I/O的处理模型;④ 数据路径的无锁化机制。

1)对CPU core和线程的管理

SPDK的原则是使用最少的CPU核和线程来完成最多的任务。为此SPDK在初始化程序的时候限定使用绑定CPU的哪些核。可以在配置文件或命名行中配置,如在命令行中使用“-c 0x5”,这是指使用core 0和core 2来启动程序。

通过CPU核绑定函数的亲和性,可以限制对CPU的使用,并且在每个核上运行一个thread,这个thread在SPDK中叫作Reactor。目前SPDK的环境库默认使用了DPDK的EAL库来进行管理。总的来说,这个Reactor thread执行一个函数_spdk_reactor_run,这个函数的主体包含一个“while(1){}”,直到这个Reactor的state被改变。当然,为了提高效率,这个循环中也会有一些相应的机制让出CPU资源,如sleep,这样的机制在很多时候会导致CPU使用100%的情况,类似于DPDK。

也就是说,一个使用SPDK编程框架的应用,假设使用了两个CPU core,每个core上就会启动一个Reactor thread,那么用户怎么执行自己的函数呢?为了解决这个问题,SPDK提供了一个Poller机制。所谓Poller,其实就是用户定义函数的封装。SPDK提供的Poller分为两种:基于定时器的Poller和基于非定时器的Poller。SPDK的Reactor thread对应的数据结构由相应的列表来维护Poller的机制,比如一个链表维护定时器的Poller,另一个链表维护非定时器的Poller,并且提供Poller的注册及销毁函数。在Reactor的while循环中,会不停地检查这些Poller的状态,并且进行相应的调用,这样用户的函数就可以进行相应的执行了。由于单个CPU核上,只有一个Reactor thread,所以同一个Reactor thread中不需要一些锁的机制来保护资源。当然位于不同CPU核上的thread还是有通信的必要的。为此,SPDK封装了线程间异步传递消息(Async Messaging Passing)的功能。

2)线程间的高效通信

SPDK放弃使用传统的、低效的加锁方式来进行线程间的通信。为了使同一个thread只执行自己所管理的资源,SPDK提供了事件调用(Event)的机制。这个机制的本质是每个Reactor对应的数据结构维护了一个Event事件的环,这个环是多生产者和单消费者(Multiple Producer Single Consumer,MPSC)的模型,意思是每个Reactor thread可以接收来自任何其他Reactor thread(包括当前的Reactor thread)的事件消息进行处理。

目前SPDK中这个Event环的默认实现依赖于DPDK的机制,这个环应该有线性的锁的机制,但是相比较于线程间采用锁的机制进行同步,要高效得多。毫无疑问的是,这个Event环其实也在Reactor的函数_spdk_reactor_run中进行处理。每个Event事件的数据结构包括了需要执行的函数和相应的参数,以及要执行的core。

简单来说,一个Reactor A向另外一个Reactor B通信,其实就是需要Reactor B执行函数F(X),X是相应的参数。基于这样的机制,SPDK就实现了一套比较高效的线程间通信的机制,具体例子可以参照SPDK NVMe-oF Target内部的一些实现,主要代码位于lib/nvmf目录下。

3)I/O的处理模型及数据路径的无锁化机制

SPDK主要的I/O处理模型是运行直到完成。如前所述,使用SPDK应用框架,一个CPU core只拥有一个thread,这个thread可以执行很多Poller(包括定时器和非定时器)。运行直到完成的原则是让一个线程最好执行完所有的任务。

显而易见,SPDK的编程框架满足了这个需要。如果不使用SPDK应用编程框架,则需要编程者自己注意这个事项。比如使用SPDK用户态NVMe驱动访问相应的I/O QPair进行读/写操作,SPDK提供了异步读/写的函数spdk_nvme_ns_cmd_read,以及检查是否完成的函数spdk_nvme_qpair_process_completions,这些函数的调用应当由一个线程去完成,而不应该跨线程去处理。

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用起来还是非常方便的 。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值