摘要:01 概叙 随着新兴硬件的发展,固态设备比如 PCIe®4.0 PM9A3 NVMe SSD 的随机读性能已经达到百万 IOPS 以上。 如今面临的一个主要挑战是:因为固态设
01 概叙
随着新兴硬件的发展,固态设备比如 PCIe®4.0 PM9A3 NVMe SSD 的随机读性能已经达到百万 IOPS 以上。
如今面临的一个主要挑战是:因为固态设备吞吐量和延迟性能比传统的磁盘好太多,现在总的 IO 处理时间中,存储软件(比如内核态 IO 栈)占用了很大的比例。
换句话说,存储软件栈的性能和效率在整个存储系统中越来越重要,随着存储设备的继续发展,存储系统的性能将会越来越受制于相关软件的实现。
NVMe SSD
在 NVMe 之前,相对来说存在时间更长的接口标准是串行 ATA 高级主机控制器接口(Serial ATA Advanced Host Controller Interface,AHCI)。AHCI 允许存储驱动程序启用高级串行 ATA 功能,相比传统的 IDE 技术,AHCI 改善了传统硬盘的性能。但是随着新介质、新技术的发展,AHCI 对新兴固态设备来说逐渐成为性能瓶颈,这个时候 NVMe 顺势而生。
现在的多层工艺 3D NAND 技术可以 SSD 在容量和访问速度上远超普通的 SSD 固态硬盘,而 NVMe(Non-Volatile Mmeory Host Controller Interface Specification)非易失性存储主机控制器接口标准则是能让 3D NAND SSD 发挥出最好性能的协议接口。NVMe 使用 PCIe 总线来提供更大的带宽和更低的延迟连接,同时实现了数据解析传输的并行方式,当数据从存储设备传输到主机时会进入队列中,普通的 SATA 设备只能支持一个队列,而 NVMe 则支持最多 65536 个队列,每个队列有 65536 个条目,这极大地提高了 IO 吞吐量。
NVMe 协议在设计时采用了环型结构,通过提交队列和完成队列作为请求和响应。对 NVMe SSD 设备的完成队列结果轮询是非常快速的,按照 NVMe 规范,只需要读取内存中的相应内容来检测队列是否有新的操作完成。而英特尔的 DDIO 技术可以保证设备在更新以后,相应的内容是在 CPU 的缓存中的,以此实现高性能的设备访问。
内核 NVMe 驱动
NVMe 设备通过 probe 注册驱动后,会为每个 CPU 创建独立的 IO 队列,每个 IO 队列包含一组提交请求和完成结果的环形队列。
中断模式
当启用中断处理模式时,会为每个 CPU 队列申请独立的中断向量 irqs,CPU 之间互不干扰,数据提交和响应收割不需要加锁,保证性能最优。中断触发时执行中断函数向完成结果队列进行 IO 响应结果的收割,执行响应结果 bio 回调函数完成 IO 请求。
轮训模式
在旋转设备时代(磁带和机械硬盘),中断开销只占整个 I/O 时间的一个很小的百分比,因此给系统带来了巨大的效率提升。然而,在 NVMe 固态设备的时代,中断开销成为了整个 I/O 时间中不能被忽视的部分。这个问题在更低延迟的设备上只会越来越严重。
图示:IO 设备产生中断(IRQ)异步事件,设备驱动 IRQ handler 执行完成事件
在此背景下,也催生了相应支持轮训模式的设备驱动。当启用 Polled 轮训模式时,通过在提交完请求后发起 nvme_poll 操作来主动发起轮训操作,收割 IO 响应结果,以此来优化降低 IO 时延。
内核态驱动支持普通轮训和混合轮训两种模式:
普通轮训在发起 IO 请求后立即发起轮训操作收割 IO 响应结果,该方式可最大限度降低 IO 时延,但相应也会增加 CPU 开销(即存在无效轮训的情况)。
混合轮训模式支持自适应和固定时间轮训,即在自适应时间或者固定时间以后发起 IO 轮训,以此减少无效 CPU 的开销。对于自适应模式,轮询延迟(睡眠时间)设置为通过经典轮询获得的平均设备命令服务时间的一半(通过收集命令统计信息后启动)。
02 高性能存储 IO 技术
本章我们详细剖析下传统的软件技术栈对于 NVMe SSD 的性能影响,同时介绍当下高性能存储 IO 的几种技术实现。
科普文:软件架构Linux系列之【五种IO模型】-CSDN博客
科普文:软件架构Linux系列之【五种 I/O 模型-补】-CSDN博客
科普文:软件架构Linux系列之【I/O 多路复用梳理】-CSDN博客
同步 IO
同步 IO 通过标准的 Posix 接口可以提供同步 IO 读写的支持,如 open/write/read/pread/pwrite 等常见的 IO 操作接口均为同步 IO 方式。
应用通过共享库或 Syscall 指令执行系统调用,进行上下文切换(栈切换、指令寄存器现场保存等)进入内核态,执行系统调用逻辑。
执行系统调用 write/read 逻辑,进入 VFS 虚拟文件系统层。非 direct IO 场景,存储设备的读写都经过 page cache 进行 redix tree 的页读写,写入的页称为脏页,fsync 或 sync 命令可将脏页持久到下层,内核 flush 线程也会定期将 page cache 中的脏页持久化到块设备层。
而 direct IO 场景会绕过 page cache 调用下层文件系统进行读写操作,特殊地,对于块设备句柄的 Direct IO 会直接向通用 IO 层发起读写请求。
对于文件的 offset+length 读写,会经过文件系统层的转换函数进行转换,得到具体要读写的扇区,扇区读写请求落到通用块层后,会根据不同扇区构造成 bio 块设备请求,连续的扇区将被构造成同一个 bio 块设备请求,非连续的扇区将被构造成不同的 bio 请求。对于内存组织而言,DMA 支持将连续的扇区数据映射传输到连续的内存页上,也支持将连续的内存页映射传输到连续的扇区上,但不支持非连续的内存页的读写,虽然在某些架构 io_mmu 下可以同时对多个扇区进行 DMA 的数据读取,但大部分情况下是不支持的。
通用块层主要是执行扇区到 bio 的转换,之后根据注册的驱动情况进行判断,对于 NVMe 等块设备驱动来说,是不需要进入 IO 调度层和 scsi 层的,bio 请求会直接进入相应的驱动层进行数据读写。
对于 SAS 类型接口设备而言,通用块层的 bio 请求会先进入 IO 调度层进行调度,此处会根据不同的调度策略(Noop/CFQ/Deadline)采取不同的调度算法进行 bio 请求的合并和重排序,之后通过将 bio 任务写入任务队列后即可返回(read/write 系统调用到此处就返回了,之后进入等待),之后由其他线程执行 request queue 的处理操作,将任务队列下发到驱动层进行处理。
我们以 Ext4 为例,剖析下 Read 系统调用的完整 IO 调用栈 :
在上述流程中,用户态线程通过系统调用切换上下文陷入内核态,初始化 iov_iter 和 kiocb 两种数据结构,即将 kiocb 描述的文件数据,读取到 iov_iter 描述的内存中。
接着执行具体文件系统的页读取函数,对于 bufferIO,需要申请相应的文件页缓存,并且在涉及页操作过程需要加解锁保证互斥,同时还有文件系统页访问偏移到块设备 bio 的转换。
在执行完块设备 submit bio 提交操作后,当前线程还会加入页 Page 的等待队列中等待块设备执行完成后唤醒。
提交的 bio 请求会被初始化成 bio request 并执行相应的蓄流和调度算法合并流程,最终通过具体设备驱动挂载的提交函数 queue_rq 将请求提交到具体块设备控制器中。
IO 请求执行完成后,由设备控制器发起中断,CPU 陷入中断上下文执行中断向量函数,收割 IO 请求结果并执行相应的 bio 回调函数,同步唤醒页上的等待队列成员,接着用户线程进行数据的内存拷贝(对于 buffer IO 场景需要进行两次的数据内存拷贝),最后进行系统调用的上下文切换回到用户态继续执行。
可以看到,传统的同步 IO 模式下,除了系统调用上下文切换、文件系统、块 IO 栈的开销以外,还有中断上下文切换以及内存拷贝等开销。这些开销直接导致了同步 IO 场景下的性能瓶颈。
异步 IO
虽然在 Linux 系统中提供了丰富的 IO 操作语义,如 read/write/pread/pwrite/preadv/pwritev 等等,但这些语义都属于同步 IO 操作,即系统调用只会在数据就绪时才返回,在此期间,用户态线程会被阻塞等待。这种方式使得程序无法达到最佳的性能,本章我们将介绍 Linux 系统下的异步 IO 技术及相应原理。
异步IO:AIO
AIO 技术是 Linux 2.6 版本最初支持的原生异步 IO 接口,有一个限制是必须与 directIO 一同使用才能有异步的效果。
以下是最主要的三个系统调用:
// 创建一个异步 IO 上下文int io_setup(int maxevents, io_context_t *ctxp); // 提交 IO 请求int io_submit(io_context_t ctx, long nr, struct iocb *ios[]);// 获取完成的 IO 响应int io_getevents(io_context_t ctx_id, long min_nr, long nr, struct io_event *events, struct timespec *timeout);初始化
初始化
在 io_setup()系统调用中完成,主要工作是初始化 kioctx 结构,在 aio_setup_ring()函数中创建虚拟文件,进行内存映射,初始化 ring buffer。
struct kioctx { struct percpu_refusers; atomic_tdead;
struct percpu_refreqs;
unsigned longuser_id; // io_setup()中返回给应用的 io_context_t *ctxp,同时等于 mmap_base ... unsignedmax_reqs; // io_setup()中的 nr_events unsignednr_events; // ringbuffer 数量,根据 nr_events 计算
unsigned longmmap_base; // aio ring buffer 内存起始地址 unsigned longmmap_size; // aio ring buffer 内存大小
struct page**ring_pages; // aio ring buffer 对应的内存页 longnr_pages; // aio ring buffer 内存页数量 ... struct file*aio_ring_file; // aio ring buffer 内存映射的文件
unsignedid;}
kioctx 代表一个 aio 上下文,描述 aio 的全部信息,在 io_setup()系统调用中生成,与应用层的 aio_context_t 对应。其中最重要的是通过 mmap_base,mmap_size,ring_pages,nr_pages 等数据描述一段内存,用于以 ringbuffer 的形式存放 aio 请求结果 io_event。
应用在初始化 kioctx 时创建一个虚拟文件,通过 mmap 映射该文件得到一块共享内存,这样就可以直接访问该段内存来查看 io 完成状态。
提交 IO 请求
struct iocb { void *data; short aio_lio_opcode; int aio_fildes;
union { struct { void *buf; unsigned long nbytes; long long offset; } c; } u;};
io_submit()系统调用进行 IO 请求的提交,以读操作为例,主要调用栈为:io_submit()->io_submit_one()->__io_submit_one()->aio_read()->call_read_iter()。
aio_read()函数将应用层传入的 iocb 结构转换为具体的 io 操作,大致流程如下:
调用 aio_prep_rw(),设置 kiocb 结构的参数,最重要的是设置了 kiocb->ki_complete 回调为 aio_complete_rw(),例如直接 directIO 读写块设备文件时的 blkdev_direct_IO()函数中,是根据 kiocb->ki_complete 非空来判定本次 io 是否为异步。
调用 aio_setup_rw(),将 iocb 中传入的 aio_buf,aio_nbytes 数据转换为 iov_iter 结构。
调用 rw_verify_area(),校验是否可读。
调用 call_read_iter(),读取文件数据。结合对 generic_file_read_iter() 以及 directIO 的分析,只有当使用 directIO 时 call_read_iter()才会立即返回-EIOCBQUEUED,否则仍然会阻塞至 io 完成。
IO 请求完成
IO 处理完成时触发 kiocb->ki_complete 的函数回调,即执行 aio_complete_rw(),在其中调用 aio_complete()函数将结果按 io_event 结构存入 ringbuffer,并更新 aio_ring。若有在阻塞等待结果的线程,则将其唤醒。若跟 epoll 一起使用而配置了 eventfd,则发信号通知 epoll 线程有事件可处理。
应用等待 IO 完成
应用层通过 io_getevents()系统调用获取 io 请求结果,可由参数配置是否需要阻塞,结果以 io_event 结构放回给应用层。其调用栈为:io_getevents()->do_io_getevents()->read_events()->aio_read_events()->aio_read_events_ring(),最终在 aio_read_events_ring()中将 ringbuffer 中的 io_event 数据拷贝给应用层,并更新 aio_ring。
总的来说 aio 技术主要是利用了 block 层的 IO 完成回调机制来实现异步,aio 在 Linux2.6 中就已经支持了,但并未得到广泛的使用,其中主要有以下几个原因:
其一为 aio 技术与 directIO 的耦合太深,当用户想使用异步 IO 时必须要使用 directIO,就不得不放弃 pagecache 的优化效果,并且必须处理 IO 的大小偏移使其对齐 blocksize,也使得应用起来并不方便。而如果要使用带缓存的方式,则接口的工作方式与同步的相同。
其二对于一些存储设备,仅有固定个数的请求槽(request slot)。如果某个时刻这些 request slot 都正在使用,那么 IO 的提交过程需要阻塞等待,而该阻塞具有不确定性。
其三在 IO 操作的过程包括提交请求与等待完成两个步骤。该接口的每个 IO 的提交需要复制 64 + 8 个字节,并且在完成时需要复制 32 字节的数据。对于完整的单个 IO 操作总共需要复制 104 个字节,这种额外的复制操作也会使得 IO 操作变得缓慢。
社区内一直对 aio 的很多设计不够满意,直到全新的异步 io 接口 iouring 的出现,它提供了一套全新的异步 IO 交互方式,用于解决 aio 使用上的问题,以及更好的发挥异步 IO 性能。
异步IO:IOURING
前面提到 Linux 的 aio 接口会在 IO 过程中涉及到比较多的复制操作。为了进一步提高 IO 性能,我们需要避免复制操作,这就要求内核与应用不需要共享 IO 过程中的数据结构,同时还要管理两者之间的同步。Linux 在 5.1 版本支持全新的异步 IO 技术 IOURING,由 Block IO 的作者 Jens Axboe 开发,其本意是提供一套公用的网络和磁盘异步 IO,不过 IOURING 目前在磁盘方面要比网络方面更加成熟。
IOURING 总体设计上通过一对单生产者单消费者的环形缓冲区,分别是提交队列(submission queue, SQ)与 完成队列(completion queue,CQ)来实现用户态与内核态的异步 IO 协作。采用这种方式,整个异步 IO 的操作包含两个部分,分别是 IO 请求的提交以及对应的结束事件处理。对于 IO 操作请求提交步骤,应用程序是生产者而内核是消费者,而对于 IO 操作的结束事件则与之相反。
以下是主要的几个系统调用:
// 初始化 io_uringint io_uring_setup(unsigned entries, struct io_uring_params *params);// 用户态应用提交 IO 请求后通知内核需要进行 IO 处理int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t sig);// 用于支持优化文件 fd 引用计数、页缓存区映射优化等特性int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);
初始化
io_uring_setup 函数返回一个 io_uring 的 fd,后续通过这个 fd 来操作 io_uring。entries 是创建出的 io_uring 中包含的 sqe(请求)数量,必须是 1-4096 间的 2 的幂级数。io_uring_params 是一个与内核的交互参数,用户态调用者在其中指定需要的参数,内核也在其中反馈实际创建的情况,其定义和解释如下:
struct io_uring_params { __u32 sq_entries; // sq 请求队列的长度,由内核设置 __u32 cq_entries; // cq 完成对列的长度,由内核设置 __u32 flags; // io_uring 运行模式和配置,由应用设置 __u32 sq_thread_cpu; // 为内核侧轮询线程设置具体的 CPU __u32 sq_thread_idle; // 内核侧轮询最大空闲时间,超过空闲时间,内核线程将会休眠,等待 WAKEUP 唤醒 __u32 resv[5]; // 预留字段 struct io_sqring_offsets sq_off; // sq 队列各个成员变量的 offset,用于初始化时生成应用端的 sq 队列结构体信息 struct io_cqring_offsets cq_off; // 同上,只不过是 cq 队列的}
当初始化完成之后,用户态应用需要通过 mmap 映射将 SQ Ring 和 CQ Ring 映射到自身虚拟内存地址空间上,这样就可以直接和内核共享同一片 Ring Buffer 内存,数据不再需要经过系统调用进行拷贝。
预定义的 mmap 映射时的起始位置如下:
#define IORING_OFF_SQ_RING 0ULL#define IORING_OFF_CQ_RING 0x8000000ULL#define IORING_OFF_SQES 0x10000000UL
IO 提交
如上提到的,用户态通过 mmap 映射将 SQ Ring 和 CQ Ring 映射到自身虚拟内存地址空间上以后,就可以直接进行 IO 请求的提交操作了:
sqe = io_uring_get_seq(&ring);io_uring_prep_read(sqe, fd, buffer, 4096, offset);
IO 提交请求项 sqe 详细结构如下:
struct io_uring_sqe { __u8 opcode; // io 请求的操作类型,比如 IORING_OP_READV ——u8 flags; __u16 ioprio; // 优先级,和 ioprio_set 系统调用的作用类似 __s32 fd; // 需要操作的文件 fd __u64 off; // 文件偏移位置 __u64 addr; // 读写数据地址,如果时 readv/writev 请求则是 iovec 数组地址。如果是普通 read/write 则是对应的数据内存读写地址 __u32 len; // 读写数据长度,如果时 readv/writev 请求则是 iovec 数组长度。如果是普通 read/write 则是对应要读写的数据长度 union { // 跟特定 op-code 相关的一些 flag,比如 IORING_OP_READV 操作,那么这些 flag 跟 preadv2 系统调用是一一对应的 __kernel_rwf_t rw_flags; __u32 fsync_flags; __u16 poll_events; __u32 sync_range_flags; __u32 msg_flags; }; __u64 user_data; // 用户数据标记,跟 cqe 中的 user_data 对应 union { __u16 buf_index; // 预留字段 __u64 __pad2[3]; // 对齐 };}
当应用端提交了 IO 请求了,同样地也需要告知内核需要进行处理,为此需要执行 io_uring_enter 系统调用。当然,实际执行过程中在执行一定 IO 队列深度的设备上,我们可以批量提交 IO 请求之后再进行 io_uring_enter 系统调用,依此提高系统的整体吞吐。
int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t sig);
fd 为需要执行 IO 操作的文件 fd
to_submit 告诉内核当前 SQ ring 有多少个待处理请求
flags 标记位目前有多种用途:比如设置 flags 为 IOURING_ENTER_GETEVENTS 时表明当前需要等待 IO 请求完成后才返回系统调用,或者设置 flags 为 IORING_ENTER_SQ_WAKEUP 代表需要唤醒当前启用的内核轮询线程(稍后展开这部分)
min_complete 告诉内核需要等待多少个 IO 请求完成后才返回,当 to_submit 和 min_complete 都设置时,意味着应用端只需要一个系统调用即可提交 IO 请求并等待返回。同时需要设置 flags 为 IOURING_ENTER_GETEVENTS
IO 完成处理
IO 完成处理只需要轮询完成队列 CQ Ring 即可,内核 IO 请求处理完成后,会将相应完成项 cqe 放入 CQ Ring 中,并更新 CQ Ring 的 tail 指针。
实际操作如下:
ret = io_uring_peek_cqe(&ring, &cqe)
用户态应用自行检查 CQ 队列尾部来感知是否有请求完成只是其中一种方式,也可以通过 io_uring_enter 时传递 min_complete 来等待 IO 执行完成后再轮询 CQ 队列。
实现原理
这节我们简单剖析下 IOURING 中使用到的一些技术原理。
共享内存
为了最大程度的减少系统调用过程中的参数内存拷贝,io_uring 采用了将内核态地址空间映射到用户态的方式。通过在用户态对 io_uring fd 进行 mmap,可以获得 io_uring 相关的两个内核队列(IO 请求和 IO 完成事件)的用户态地址。用户态程序可以直接操作这两个队列来向内核发送 IO 请求,接收内核完成 IO 的事件。IO 请求和完成事件不需要通过系统调用传递,也就完全避免了 copy to user/copy from user 的开销。
使用共享内存和无锁队列最需要注意的就是保证内存操作的顺序和一致性。这部分内容在 Efficient IO with io_uring 中做了简单的介绍。主要就是保证两点:
修改队列状态时,必须保证对队列元素的写入已经完成。这需要调用 write barrier 来保证之前的写入已经完成。在 x86 架构上这一点其实是针对编译器优化,防止编译器将修改队列状态的指令放到队列元素写入完成之前。
读取队列状态时,需要获取到最新写入和修改的值。这需要调用 read barrier 来保证之前的写入都能被读取到,这主要是对缓存一致性的要求。
内存屏障在不同架构的 CPU 上有不同的实现和效果,要正确使用需要对 CPU 和编译器有一定了解。但在 liburing 中已经屏蔽了这些细节,查看 https://github.com/axboe/liburing/blob/master/src/queue.c 中的源码可以实现,其中 read barrier 和 write barrier 实现调用是 io_uring_smp_store_acquire 和 io_uring_smp_store_release。其实质也是 std 库中的 std::atomic_store_explicit 和 std::atomic_load_explicit 原子操作方法。
建议在一般情况下使用 liburing 来实现对队列的操作即可。
而对于 Golang 来说,并没有提供语言级别的 sequential instruction 机制能让我们做到类似 read_barrier/write_barrier 的效果,但是根据 #1 和 #2 提到的,Golang 的 sync/atomic 是提供了顺序性的保证的,即 store 和 load 的顺序不会由于编译指令优化导致前后不一致。所以市面上的 Pure Golang iouring 库都是通过 sync/atomic 来提供内存顺序性的保障。
IO Poll
对于用户态应用来说,只需要通过一个线程持续轮询请求完成事件队列即可。但这个层次的轮询只是轮询了 io_uring 的队列,内核从 IO 设备获取完成 IO 情况仍然是基于设备中断通知的。
对于普通的中断模式,我们提交的 IO 请求由内核侧消费提交到 IO 设备。当 IO 请求在 IO 设备完成后,通过中断方式响应并回调 iouring 下的 io_complete_rw 函数,进而标记和更新完成队列 CQ Ring。基于中断通知的方式的问题和开销我们在上面初步讲过,中断上下文切换的时延通常在 0.5~2us,而日渐成熟的快速设备(NVMe SSD 等)的访问时延已经接近甚至低于上下文切换的开销,这意味着在这类设备中中断模式的开销已经成为主要的性能瓶颈。
而对于 Polling 模式来说,这是一种通过牺牲一些 CPU 开销来换取更低 IO 时延的方式。通过 Polling 模式我们可以减少 IO 路径上的上下文切换的开销,同时也能减少中断 IRQ 的传递时延和 IRQ handler 调度的时延等等。
通过引入 Polling 机制,对于 NVMe SSD 等快速设备来说提升是巨大的:
混合 Polling 模式
不停的轮训显然是非常低效的方式,因此 kernel 支持了新的混合 Polling 模式来优化 CPU 的开销。混合 Polling 通过在下发 IO 请求后休眠等待一定时间的方式来节省 CPU 开销。
目前支持两种混合 Polling 模式配置:自适应和固定时间轮训。我们可以通过 io_poll_delay sysfs 文件来设置轮训模式:
> cat /sys/block/nvme0n1/queue/io_poll_delay-1
-1 为经典的轮训模式(默认值)0 为自适应的混合 polling 模式
对于自适应模式,轮询延迟(睡眠时间)设置为通过经典轮询获得的平均设备命令服务时间的一半(通过收集命令统计信息后启动)。
不同 polling 模式的测试表现:
自适应模式的请求时延几乎和经典的 polling 模式一样
而固定休眠时间的 polling 模式表现取决于休眠时间的设置
轮询延迟设置为命令平均服务时间的一半的结果最好
自适应的 polling 模式 CPU 负载只有经典模式的 58%
IOURING 通过支持 IO Poll 特性来进一步优化提升 IO 的访问时延,当 IO 请求直接提交后驱动队列后,内核侧会不断轮询设备来确认是否有 IO 完成返回。
IO Poll 模式下,应用程序无法通过检查 CQ 队列 head 和 tail 来确认是否有请求返回,而是必须通过 IO_URING_ENTER 调用并设置 IOURING_ENTER_GETEVENTS flag 来获取是否有 IO 请求完成,再去查询 CQ 队列 head 和 tail 获取完成的 IO 请求。原因在于此时没有中断触发的异步事件回调通知,因此只能由用户态应用通知内核主动轮询检查设备完成队列并修改 CQ Ring。
在 io_uring_enter 系统调用流程中,当 iouring ctx 启用了 IORING_SETUP_IOPOLL 时,会执行 io_iopoll_check 流程,主动轮询完成事件。这个流程主体为一个 while 循环,当获取到的完成事件满足 min_complete 时即可返回系统调用。
{ ... // iopoll 主入口,每次 poll 成功,nr_events 都会增加,当 nr_events 大于等于 min 最小完成事件时返回 ret = io_do_iopoll(ctx, &nr_events, min, true);} while (!ret && nr_events < min && !need_resched());
io_do_iopoll 最终执行块层的 blk_poll, 在分配给调用 CPU 的硬件队列上下文的完成队列上进行轮询, 不影响分配给不同 CPU 的其他队列。同时会根据参数和队列配置决定是否执行混合 poll 模式,最终执行每个设备队列的 poll 函数。
对于 NVMe 来说,设备队列的.poll 函数指针指向 nvme_poll,nvme_poll 轮训只需要检查对应的完成队列内存状态即可,非常高效。
static int nvme_poll(struct blk_mq_hw_ctx *hctx){ struct nvme_queue *nvmeq = hctx->driver_data; bool found;
if (!nvme_cqe_pending(nvmeq)) return 0; spin_lock(&nvmeq->cq_poll_lock); found = nvme_process_cq(nvmeq); spin_unlock(&nvmeq->cq_poll_lock); return found;}
实际测试发现,在开启 IO POLL 模式下执行的 IO URING 随机读操作(4K+128 深度)相比同步系统调用,整体 IO 带宽和 IOPS 提升了将近 6 倍。
内核 Poll
通过 POLL 模式我们已经可以获得相当大的性能提升了,但 IOURING 还支持内核侧的 Poll 模式(SQ_POLL),这可以允许用户态应用提交 IO 请求后不再需要通过。
io_uring_enter 系统调用告知内核处理,从而进一步优化性能并减少系统调用的次数。当然,这里内核侧的 Poll 会占用更多的 CPU 开销。
// 开启 SQPOLLret = io_uring_queue_init(128, &ring, IORING_SETUP_SQPOLL);
在初始化开启 SQ_POLL 后会在内核侧开启新的内核线程并轮询 SQ 队列是否有新 IO 请求提交以及处理,内核线程在持续空闲一段时间(io_uring_params->sq_thread_idle)后会进入休眠,此时 SQ Ring.flags 标记会被设置为 IORING_SQ_NEED_WAKEUP,此时需要应用端通过 io_uring_enter 系统调用唤醒内核轮询线程。
if (IO_URING_READ_ONCE(*ring->sq.kflags) & IORING_SQ_NEED_WAKEUP) { *flags |= IORING_ENTER_SQ_WAKEUP;}
实际测试发现,在开启 SQ POLL 模式后,相比 IO POLL 模式能提升 25%左右的 IOPS。
Buffer IO 支持
对于 buffer IO 模式的支持,IOURING 在提交 Buffer IO 时会先尝试以 kiocb.flags | IOCB_NOIO 非下发 IO 的方式访问 page cache 数据,如果能获取到并且是最新的,则直接返回。如果 page 不是最新的,那么会将 IO 请求放入 io-wq 的内核异步 IO 队列中,由内核线程去执行阻塞操作(vfs 的 buffer IO 流程会直接阻塞线程),最后通过 kiocb->ki_complete 回调函数更新 CQ Ring 的状态。用户态应用直接轮询 CQ Ring 即可获得 IO 完成结果,从而达到不阻塞用户态线程的目的。
SPDK
SPDK 全称是 Storage Performance Development Kit,本身是作为一种高性能 IO 解决方案而出现存储性能开发套件。SPDK 是由英特尔发起的,用于加速 NVMe SSD 等高速设备作为后端存储使用的应用软件加速库。其核心是用户态、异步、轮询方式的 NVMe 驱动,相比于内核的 NVMe 驱动,SPDK 可以大幅降低 NVMe command 的延迟,提高单 CPU 核的 IOps,形成一套高性能的解决方案。
整个架构如图所示主要分为三层:
协议层:主要处理 nvme-of target、vhost_blk/scsi/nvme target、iscsi_target 等对相应协议的解析处理。
块层(bdev):实现对不同后端设备的支持,提供对上层的统一接口,包括逻辑卷的支持、流量控制的支持等存储服务。这一层也提供了对 Blob (Binary Larger Object)及简单用户态文件系统 BlobFS 的支持。主要提供了虚拟卷、压缩、加密、raid、ocf 及 qos 等功能,类似 kernel 的 dm,同时对接了几种常见块设备,如 aio,rbd,malloc 及 nvme 等,新版本还加入了 io_uring 的支持。
驱动层:主要提供了 nvme device 及 virtio 的用户态驱动等,管理物理和虚拟设备,还管理本地和远端设备。
总体来说,SPDK 并不是一个通用的解决方案。因为需要把内核驱动放到用户态,导致需要在用户态构建一套基于用户态软件驱动的完整 I/O 栈,这毫无疑问会导致许多问题。例如文件系统就是其中一个重要的话题,常用的文件系统诸如 ext4、Btrfs 等都无法直接使用,为此使用文件系统的应用需要进行改造适配并移植到 SPDK 的用户态“文件系统”上(比如目前 SPDK 提供的非常简单的文件系统 blobfs/blostore 等)。当然,这并不仅仅只是代码移植这么简单,因为 SPDK 目前提供的文件系统并不支持可移植操作系统接口,因而 IO 流程上还需要采用类似 AIO 的异步读/写方式。
就目前看,SPDK 更适合以下几类应用场景:
提供块设备接口的后端存储应用,比如 iSCSI Target、NVMe-oF Target。目前 SPDK 提供了较为完善的 Target 端实现,相比需要引入 tgt 库等方式进行二次开发来说会简单很多。
通过 nvme 用户态驱动或者 blobfs/blobstore 直接读写底层 nvme 设备,用于加速存储引擎/数据库等类型的 IO 访问速度
应用可以直接通过 nvme 用户态驱动(走 nvme 协议)或是 nvme bdev 通用块设备(走块协议)接口来加速读写底层 nvme SSD 设备
SPDK 的 blobfs/blobstore 目前可以和 rocksdb 集成,用于加速在 nvme SSD 上使用 rocksdb 引擎
对虚拟机中 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 中的几大关键设计,包括用户态驱动、应用框架和用户态块设备层等。
用户态驱动
用户态应用程序和内核驱动的交互离不开用户态和内核态的上下文切换,以及系统调用的开销。而用户态驱动出现的目的就是减少软件本身的开销,包括这里所说的上下文切换、系统调用等。在 linux 下,目前可以通过 UIO(Userspace I/O)或 VFIO(Virtual Function I/O)两种方式对硬件固态硬盘设备进行访问。SPDK 通过 DPDK 提供的 UIO 和 VFIO 封装来实现用户态驱动。
UIO
UIO 框架于 Linux 2.6.32 版本引入,主要提供了在用户态实现设备驱动的以下方案:
访问设备的内存:Linux 通过映射物理设备的内存到用户态来提供访问,但是这种方法会引入安全性和可靠性的问题。UIO 通过限制不相关的物理设备的映射改善了这个问题
处理设备产生的中断:中断本身需要在内核处理,因此针对这个限制,UIO 提供了一个小的内核模块通过最基本的中断服务程序来处理。这个中断服务程序只是向操作系统确认中断,或者关闭中断等最基础的操作,剩下的具体操作可以在用户态处理。
如下图:用户态驱动和 UIO 内核模块通过/dev/uioX 设备来实现基本交互,同时通过 sysfs 来得到相关的设备、内存映射、内核驱动等信息。
VFIO
VFIO 除了提供了 UIO 所能提供的两个最基础的功能以外,还把设备 I/O、中断、DMA 暴露到用户空间,从而可以在用户空间去完成设备驱动的框架。IOMMU(I/O Memory Management Unit)的引入对设备操作进行了限制,设备 I/O 地址需要经过 IOMMU 重映射为内存物理地址。因此恶意的或存在错误的设备就不能读/写没有被明确映射过的内存。操作系统以互斥的方式管理 MMU 和 IOMMU,这样物理设备将不能绕过或污染可配置的内存管理表项。
用户态 DMA
基于 UIO 和 VFIO,我们可以实现用户态的驱动,把一个设备分配给一个进程,允许该进程来直接操作该设备。不需要通过内核驱动来产生额外的内存复制,而是可以直接从用户态发起对设备的 DMA。得益于英特尔平台的技术严谨,设备直接支持 IOMMU 后,这里 DMA 操作可以是物理地址,也可以是虚拟地址。
大页内存
虚拟地址映射到物理地址的工作主要是 TLB(Translation Lookaside Buffers)与 MMU 一起来完成的。以 4KB 的页大小为例,虚拟地址寻址时,首先在 TLB 中查找,如果没有找到,则需要通过 MMU 加载的页表基地址进行多次寻表来找到对应的物理地址。如果找不到,则产生缺页,这时会有相应的 handler 进行处理,来填充页表和更新 TLB。
通过页表查询而导致缺页带来的 CPU 开销是非常大的,虽然 TLB 的出现能很好地解决性能问题。但是经常性的缺页是不可避免的,为此 SPDK 采取了大页内存的方式。通过使用 Hugepage 分配大页可以提高性能,因为页大小的增加,可以减少缺页异常,从而大大提高了内存操作的性能。
大页还有一个优势是这些预先分配的内存基本上不会被换出,当进行 DMA 的时候,所对应的虚拟地址永远有相对应的物理页。
异步轮询
SPDK 用户态驱动通过异步轮训的方式来对设备完成状态进行检测,去除了对中断通知。采用这种处理方式的原因如下:
把内核态的中断抛到用户态进程来处理对大部分硬件是不合适的。
中断会引入上下文的切换,带来额外的开销。
对 NVMe SSD 设备的轮询是非常快速的,按照 NVMe 的规范,只需要读取内存中的完成队列指针来判断是否有新的操作完成。英特尔的 DDIO 技术可以保证设备在更新以后,相应的内容是在 CPU 的缓存中的,以此实现高性能的设备访问。
SPDK 轮询到操作完成时会触发上层的回调函数,这样使得应用程序无须等待读或写操作的完成,就可以按需发送多个请求,再由回调函数处理,由此来提高应用的读/写性能。
IO 流程无锁化
SPDK 用户态驱动遵循 NVMe 的规范来初始化 NVMe SSD,创建出独占的 I/O 发送和完成队列,在单核上的 IO 流程操作和资源分配无需加锁。同时还可以通过线程亲和性的方法来将处理线程绑定到特定的核上,以此保证读/写处理都在一个 CPU 核上完成,从而避免了核间的缓存同步。
当应用程序执行读/写请求时,采用 Run-To-Complete 方式,将整个读写请求的生命周期都绑定在当前核上。
SPDK 用户态驱动正是基于上面提到的 UIO 和 VFIO、用户态 DMA 操作,以及大页和 IO 流程无锁化等等的加速优化支持,来达到对 NVMe SSD 设备的 I/O 高性能访问的目的。
应用框架
SPDK 推荐开发人员使用 SPDK 应用编程的框架来实现应用的逻辑,开发人员只需要遵循使用框架和流程就能快速上手并达到高性能的效果。
框架总体设计如下:
总体看,SPDK 的应用框架可以分为以下几个部分:
对 CPU core 和线程的管理
线程间高效通信方式;
I/O 的处理模型,数据路径的无锁化机制;
对 CPU core 和线程的管理
SPDK 在初始化程序时可以限定使用绑定 CPU 的哪些核,通过 CPU 核绑定函数的亲和性,可以限制每个核上运行一个 thread。在 SPDK 中,这个 thread 叫做 Reactor。这个 Reactor thread 执行一个函数_spdk_reactor_run,函数主体包含一个"while(1) {}"循环,一直运行直到这个 Reactor 的 state 被改变。
为了在 Reactor thread 中运行应用自定义的逻辑,SPDK 提供了 Poller 机制。所谓的 Poller,其实就是用户定义函数的封装,通过 Poller 执行用户函数,来向底层 IO 设备发起读写请求。SPDK 提供的 Poller 分为两种:
基于定时器的 Poller
基于非定时器的 Poller
每个 Reactor thread 所在的数据结构相应维护了 Poller 的列表,比如通过一个链表维护定时器的 Poller,另一个链表维护非定时器的 Poller,并且提供 Poller 的注册和销毁函数。在 Reactor 的 while 循环中,会不停地检查这些 Poller 的状态,并进行相应地调用,这样用户的函数就可以被执行了。由于单个 CPU 核上,只有一个 Reactor thread,因此不需要额外的锁机制来保护资源。
线程间高效通信方式
SPDK 提供高效的 Event 事件机制进行通信,这个机制的本质是每个 Reacto 所在的数据结构维护了一个 Event 事件的多生产者单消费者环,也就是每个 Reactor 可以接受来自任何其他的 Reactor 发过来的事件消息。
目前这个环是采用 DPDK 的实现,其本身也是一个有线性锁的环,但相比线程间通过锁的机制进行同步要更加高效。这个 Event 环的消费处理也是在 Reactor 的_spdk_reactor_run 中进行的,每个 Event 事件的数据结构包括了需要执行的函数和相应的参数以及要执行的 core。
I/O 的处理模型,数据路径的无锁化机制
SPDK 的 I/O 处理模型是 run-on-complete(运行直到完成),即在一个线程中执行完所有的任务,这种模型也就意味着 SPDK 的数据处理路径上是不需要加锁的,同时也能避免由于 CPU 缓存失效带来的问题,更加高效。
用户态块设备层
上层应用通过 SPDK 提供的 API 来直接操作 NVMe SSD 硬件设备,从而达到加速的效果,这个是一个典型场景。但除了这个场景以外,上层还需要更加丰富的特性来满足不同场景的需求。因此 SPDK 出于通用性考虑,基于用户态驱动之上实现了通用块设备层 Bdev,用于提供块设备访问接口。同时,还提供了基于通用块设备接口的 Blobstore/BlobFS 和各种逻辑卷特性(精简配置、快照和克隆等)以及流量控制等功能特性。
SPDK Bdev 层类似于内核中的通用块设备层,是对底层不同类型设备的统一抽象管理,例如 NVMe bdev、Malloc bdev、AIO bdev 等。
通用块层引入了逻辑上的 I/O Channel 概念来屏蔽下层的具体实现。目前来说,I/O Channel 和 Thread 和 Core 的对应关系是 1∶1:1的匹配。每个线程通过调用spdk_bdev_get_io_channel()来获得一个单独的IO Channel,这将为每个线程分配必要的资源,以便在不获取锁的情况下向bdev提交IO请求。要释放一个IO Channel,则需要调用spdk_put_io_channel()。
IO Channel 是上层模块访问通用块层的 I/O 通道,上层通过 IO Channel 发起通用块设备的读/写操作。同时为了方便上层操作通用块设备,SPDK 为每个 I/O Channel 分配了相应的 Bdev Channel 来保存块设备的一些上下文信息,比如 I/O 操作的相关信息。
核心数据结构
struct spdk_bdev:通用块设备的数据结构,包括标识符如名字、UUID、对应的后端硬件名字等;块设备的属性如大小、最小单块大小、对齐要求等;操作该设备的方法如注册和销毁等;该设备的状态,如重置、记录相关功能的数据结构等。
struct spdk_bdev_fn_table:操作通用设备的函数指针表,定义通用的操作设备的方法。包括如何拿到后端具体设备相应的 I/O Channel、后端设备如何处理 I/O(Read、Write、Unmap 等)、支持的 I/O 类型、销毁后端具体块设备等操作。每一类具体的后端设备都需要实现这张函数指针表,使得通用块设备可以屏蔽这些实现的细节,只需要调用对应的操作方法就可以了。
struct spdk_bdev_io:块设备 I/O 数据结构,类似于内核驱动中的 bio 数据结构,同样需要一个 I/O 块数据结构来具体描述操作的块设备和后端对应的具体设备。具体的 I/O 读和写的信息都会在这个数据结构中被保存,以及涉及的 Buffer、Bdev Channel 等相关资源,后期需要结合高级的存储特性像逻辑卷、流量控制等都需要在 I/O 数据结构这里有相关的标识符和额外的属性。
上述核心的数据结构设计,为 SPDK 提供了最基本的功能特性来支持不同后端块设备,比如通过用户态 NVMe 驱动实现了 NVMe SSD 块设备,通过 Linux AIO 来实现除 NVMe SSD 外的慢速存储设备(HDD、SATA SSD、SAS SSD 等),通过 PMDK(Persistent Memory Development Kit)来实现持久化内存块设备,通过 CephRBD 来实现远端 Ceph 块设备等等。
Bdev 管理
SPDK 把块设备分为两种:
Base Bdev:支持直接操作后端硬件的块设备,可以称之为基础块设备(Base Bdev)
Virtual Bdev:构建在基础块设备之上的设备,这样一些 Bdevs 通过将请求路由到其他 Bdevs 来服务 I/O,这可以用于实现缓存、RAID、逻辑卷管理等。将 I/O 路由到其他 bdev 的 bdev 通常称为虚拟 bdev,或简称 vbdev。
SPDK 中的块设备管理主要通过 struct spdk_bdev_module 数据结构来支持,该数据结构定义了下面几个重要的函数指针,需要具体的设备模块来实现:
module_init(),当 SPDK 应用启动的时候,初始化某个具体块设备模块,如 NVMe。
module_fini(),当 SPDK 应用退出的时候,销毁某个具体块设备模块,如分配的各种资源。
examine(),当某个块设备,如基础块设备创建出来后对应的其他设备,尤其是虚拟块设备可以被通知做出相应的操作,比如创建出对应的虚拟块设备和基础块设备。
Blobstore
SPDK 的 Blobstore 设计为一个 blob 的分配管理器,如果一个 SPDK 通用设备的空间被初始化成通过 Blob 接口来访问,而不是通过固有的块接口来操作,那么这个通用块设备就被称为一个 Blobstore (Blob 的存储池)。Blob 代表 Binary Large Object,大小可配置,但远比块设备的扇区大小要大得多,可以从几百 KB 到几 MB。越小的 Blob 需要越多的元数据维护。
Blobstore 可以为上层提供更高层次的存储服务,比如逻辑卷管理、BlobFS 文件系统等。其内部层级结构如下:
逻辑块(Logical Block):一般就是指后端具体设备本身的扇区大小,比如常见的 512B 或 4KiB 大小,整体空间可以相应地划分成逻辑块 0~N。
页:一个页的大小定义成逻辑块的整数倍,在创建 Blobstore 时固定下来,后续无法再进行修改。为了管理方便,比如快速映射到某个具体的逻辑块,往往一个页是由物理上连续的逻辑块组成的。同样地,页也会有相应的索引,从 0~N 来指定。
如果考虑单个页的原子操作的话,一个简单的方法是按照后端的具体硬件支持的原子大小来设定页的大小。比如说大部分 NVMe SSD 支持 4KiB 大小的原子操作,那么这个页可以是 4KiB,这样,如果逻辑块是 512B 的话,那么页的大小就是 8 个连续逻辑块。
Cluster:类似于页的实现,一个 Cluster 的大小是多个固定的页的大小,也是在 Blobstore 创建的时候确定下来的。组成单个 Cluster 的多个页是连续的,页就是物理上连续的逻辑块。这些操作都是为了能够通过算数的方法来找到对应的逻辑块的位置,最终实现对后端具体块设备的读/写操作,完全是从性能角度考虑的。类似于页,Cluster 也是从 0 开始的索引。Cluster 不考虑原子性,因此 Cluster 可以定义的相对来说比较大,如 1MiB 的大小。如果页是 4KiB 的话,对应 256 个连续的页。
Blob:一个 Blob 是一个有序的队列,存放了 Cluster 的相关信息。Blob 物理上是不连续的,无法通过索引来读/写某个 Cluster,而是需要队列的查找来操作某个特定的 Cluster。这样的设计在性能和管理上带来了一定的复杂性,比如这些信息需要固定下来,在系统遇到故障时,还能重新恢复和原来一样的信息。但是从提供更多高级的存储服务的角度看,这样的设计可以很容易地实现快照、克隆等功能。
在 SPDK Blobstore 的设计中,Blob 是对上层模块可见、可操作的对象,隐藏了 Cluster、页、逻辑块的具体实现。每个 Blob 都有唯一的标识符提供给上层模块进行操作,通过具体的起始地址、偏移量和长度,可以很容易地算出具体的哪个页、哪个逻辑块来读/写具体的后端设备。针对 NAND NVMe SSD 硬件设备,Blob 的大小可以是 NAND NVMe SSD 最小擦除单位(块大小)的整数倍。这样可以支持快速的随机读/写性能,同时避免了进行后端 NAND 管理的垃圾回收工作。
Blobstore 会管理整个通用块设备,除了那些可以给到上层应用访问的 Blob 以外,还会有相应的私有的元数据空间来固化这些信息。在 Blobstore 中,会将 cluster 0 作为一个特殊的 cluster。该 cluster 用于存放 Blobtore 的所有信息以及元数据,对每个 blob 数据块的查找、分配都是依赖 cluster 0 中所记录的元数据所进行的。Cluster 0 的结构如下:
Cluster 0 中的第一个 page 作为 super block,Blobstore 初始化后的一些基本信息都存放在 super block 中,例如 cluster 的大小、已使用 page 的起始位置、已使用 page 的个数、已使用 cluster 的起始位置、已使用 cluster 的个数、Blobstore 的大小等信息。
Cluster 0 中的其它 page 将组成元数据域(metadata region)。元数据域主要由以下几部分组成:
Metadata Page Allocation:用于记录所有元数据页的分配情况。在分配或释放元数据页后,将会对 metadata page allocation 中的数据做相应的修改。
Cluster Allocation:用于记录所有 cluster 的分配情况。在分配新的 cluster 或释放 cluster 后会对 cluster allocation 中的数据做相应的修改。
Blob Id Allocation:用于记录 blob id 的分配情况。对于 blobstore 中的所有 blob,都是通过唯一的标识符 blob id 将其对应起来。在元数据域中,将会在 blob allocation 中记录所有的 blob id 分配情况。
Metadata Pages Region:元数据页区域中存放着每个 blob 的元数据页。每个 blob 中所分配的 cluster 都会记录在该 blob 的元数据页中,在读写 blob 时,首先会通过 blob id 定位到该 blob 的元数据页,其次根据元数据页中所记录的信息,检索到对应的 cluster。对于每个 blob 的元数据页,并不是连续的。
对于一个 blob 来说,metadata page 记录了该 blob 的所有信息,数据存放于分配给该 blob 的 cluster 中。在创建 blob 时,首先会为其分配 blob id 以及 metadata page,其次更新 metadata region。当对 blob 进行写入时,首先会为其分配 cluster,其次更新该 blob 的 metadata page,最后将数据写入,并持久化到磁盘中。
对于每个 blob,通过相应的结构维护当前使用的 cluster 以及 metadata page 的信息:clusters 与 pages。Cluster 中记录了当前该 blob 所有 cluster 的 LBA 起始地址,pages 中记录了当前该 blob 所有 metadata page 的 LBA 起始地址。
BlobFS
SPDK 用户态文件系统 BlobFS 通过与 Blobstore 集成,来向上层提供更高维度的接口服务,比如为数据库 Mysql、K-V 存储引擎 Rocksdb 等提供类似的文件读写接口。以 Rocksdb 为例,通过继承和实现 Rocksdb 的 EnvWrapper 类,可以将 Rocksdb 的文件读写请求经由 BlobFS 下发到 Blobstore,进而转发到 Bdev 块设备层,最终通过 SPDK 用户态驱动写入到硬盘上,整个 I/O 流从发起到持久化均在用户态完成,同时还能充分利用 SPDK 所提供的异步、无锁化、Zero Copy、轮询等机制,大幅度减少额外的系统开销。
BlobFS 在管理文件时,主要依赖于 Blobstore 对 blob 的分配与管理。BlobFS 与 Blobstore 的关系可以理解为 Blobstore 实现了对 Blob 的管理,包括 Blob 的分配、删除、读取、写入、元数据的管理等,而 BlobFS 是在 Blobstore 的基础上进行封装的一个轻量级文件系统,用于提供部分对于文件操作的接口,并将对文件的操作转换为对 Blob 的操作,BlobFS 中的文件与 Blobstore 中的 Blob 一一对应。在 Blobstore 下层,与 SPDK Bdev 层对接。
03 最后
本文主要从新兴硬件背景和 NVMe SSD 介绍开始,重点阐述了 Linux 系统下同步 IO 技术及其性能瓶颈点分析。同时对目前业界高性能存储 IO 的几大技术和相应的基本原理做了一些简单介绍,希望本文能对各个工程应用上的同学能有所借鉴和帮助。
04 参考
《Linux 开源存储全栈详解》
《Linux 内核设计与实现》
《 I/O Latency Optimization with Polling 》
《 Efficient IO with io_uring 》
https://developers.mattermost.com/blog/hands-on-iouring-go/
https://unixism.net/loti/
https://github.com/dshulyak/uring