写点什么

技术干货 | 漫游 Linux 块 IO

作者:沃趣科技
  • 2022 年 9 月 27 日
    浙江
  • 本文字数:4247 字

    阅读完需:约 14 分钟

技术干货 | 漫游Linux块IO

前言

在计算机的世界里,我们可以将业务进行抽象简化为两种场景——计算密集型IO 密集型。这两种场景下的表现,决定这一个计算机系统的能力。数据库作为一个典型的基础软件,它的所有业务逻辑同样可以抽象为这两种场景的混合。因此,一个数据库系统性能的强悍与否,往往跟操作系统和硬件提供的计算能力、IO 能力紧密相关。


除了硬件本身的物理极限,操作系统在软件层面的处理以及提供的相关机制也尤为重要。因此,想要数据库发挥更加极限的性能,对操作系统内部相关机制和流程的理解就很重要。


本篇文章,我们就一起看下 Linux 中一个 IO 请求的生命周期。Linux 发展到今天,其内部的 IO 子系统已经相当复杂。每个点展开都能自成一篇,所以本次仅是对块设备的写 IO 做一个快速的漫游,后续再对相关专题进行详细分解。




从用户态程序出发

首先需要明确的是,什么是块设备?我们知道 IO 设备可以分为字符设备和块设备,字符设备以字节流的方式访问数据,比如我们的键盘鼠标。而块设备则是以块为单位访问数据,并且支持随机访问,典型的块设备就是我们常见的机械硬盘和固态硬盘。


一个应用程序想将数据写入磁盘,需要通过系统调用来完成:open 打开文件 ---> write 写入文件 ---> close 关闭文件。


下面是 write 系统调用的定义,我们可以看到,应用程序只需要指定三个参数:

1. 想要写入的文件

2. 写入数据所在的内存地址

3. 写入数据的长度


SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,        size_t, count){    struct fd f = fdget_pos(fd);    ssize_t ret = -EBADF;
if (f.file) { loff_t pos = file_pos_read(f.file); ret = vfs_write(f.file, buf, count, &pos); if (ret >= 0) file_pos_write(f.file, pos); fdput_pos(f); }
return ret;}
复制代码


而剩下的工作就进入到内核中的虚拟文件系统(vfs)中进行处理。



虚拟文件系统(VFS)

在 Linux 中一切皆文件,它提供了虚拟文件系统 VFS 的机制,用来抽象各种资源,使应用程序无需关心底层细节,只需通过 open、read/write、close 这几个通用接口便可以管理各种不同的资源。不同的文件系统通过实现各自的通用接口来满足不同的功能。


devtmpfs

挂载在/dev 目录,devtmpfs 中的文件代表各种设备。因此,对 devtmpfs 文件的读写操作,就是直接对相应设备的操作。


如果应用程序打开的是一个块设备文件,则说明它直接对一个块设备进行读写,调用块设备的 write 函数:


const struct file_operations def_blk_fops = {    .open    = blkdev_open,    ... ...    .read    = do_sync_read,    .write    = do_sync_write,    ... ...};
复制代码

磁盘文件系统(ext4 等)

这是我们最为熟悉的文件系统类型,它的文件就是我们一般理解的文件,对应实际磁盘中按照特定格式组织并管理的区域。对这类文件的读写操作都会按照固定规则转化为对应磁盘的读写操作。


应用程序如果打开的是一个 ext4 文件系统的文件,则会调用 ext4 的 write 函数:


const struct file_operations_extend  ext4_file_operations = {    .kabi_fops = {    ... ...        .read    = do_sync_read,        .write    = do_sync_write,    ... ...        .open    = ext4_file_open,    ... ...};
复制代码

buffer/cache

Linux 提供了缓存来提高 IO 的性能,无论打开的是设备文件还是磁盘文件,一般情况 IO 会先写入到系统缓存中并直接返回,IO 生命周期结束。后续系统刷新缓存或者主动调用 sync,数据才会被真正写入到块设备中。有意思的是针对块设备的称为 buffer,针对磁盘文件的称为 cache。


ssize_t __generic_file_aio_write(struct kiocb *iocb, const struct iovec *iov,                 unsigned long nr_segs, loff_t *ppos)    ... ...    if (io_is_direct(file)) {    ... ...        written = generic_file_direct_write(iocb, iov, &nr_segs, pos,                            ppos, count, ocount);    ... ...    } else {        written = generic_file_buffered_write(iocb, iov, nr_segs,                pos, ppos, count, written);    }    ... ...
复制代码

Direct IO

当打开文件时候指定了 O_DIRECT 标志,则指定对文件的 IO 为 direct IO,它会绕过系统缓存直接发送给块设备。在发送给块设备之前,虚拟文件系统会将 write 函数参数表示的 IO 转化为 dio,在其中封装了一个个 bio 结构,接着调用 submit_bio 将这些 bio 提交到通用块层进行处理。


    do_blockdev_direct_IO         -> dio_bio_submit             -> submit_bio
复制代码



通用块层

通用块层对块设备进行了高度抽象,定义了一系列高效的处理块设备 IO 的机制,通过通用的数据结构和接口,使各种不同的磁盘等物理存储设备的驱动可以很简单的嵌入其中并使用这些机制。

核心结构

bio/request

  • bio 即 block io,它是 linux 通用块层和底层驱动的 IO 基本单位,可以看到它的最重要的几个属性,一个 bio 就可以表示一个完整的 IO 操作:


struct bio {    sector_t    bi_sector; //io的起始扇区... ...    struct block_device  *bi_bdev;  //对应的块设备... ...    bio_end_io_t    *bi_end_io;  //io结束的回调函数... ...    struct bio_vec    *bi_io_vec;  //内存page列表... ...};
复制代码


  • request 代表一个独立的 IO 请求,是通用块层和驱动层进行 IO 传递的结构。它容纳了一组连续的 bio。通用块层提供了很多 IO 调度策略将多个 bio 合并生成一个 request 以提高 IO 的效率。

gendisk

每个块设备都对应一个 gendisk 结构,它定义了设备名、主次设备号、请求队列,和设备的相关操作函数。通过 add_disk 我们就真正在系统中定义一个块设备。

request_queue

这个即是日常所说的 IO 请求队列,通用块层将 IO 转化为 request 并插入到 request_queue 中,随后底层驱动从中取出完成后续 IO 处理。


struct request_queue {    ... ...    struct elevator_queue  *elevator;  //调度器
request_fn_proc *request_fn; //请求处理函数 make_request_fn *make_request_fn; //请求入队函数 ... ... softirq_done_fn *softirq_done_fn; //软中断处理
struct device *dev; unsigned long nr_requests; ... ...};
复制代码



处理流程

在收到上层文件系统提交的 bio 后,通用块层最主要的功能就是根据 bio 创建 request 并插入到 request_queue 中。


在这个过程中会对 bio 进行一系列处理:当 bio 长度超过限制会被分割,当 bio 访问地址相邻则会被合并。


request 创建后,根据 request_queue 配置的不同 elevator 调度器,request 插入到对应调度器队列中。在底层设备驱动程序从 request_queue 取出 request 处理时,不同 elevator 调度器返回 request 策略不同,从而实现对 request 的调度。


void blk_queue_bio(struct request_queue *q, struct bio *bio){    ... ...    el_ret = elv_merge(q, &req, bio);    //尝试将bio合并到已有的request中    ... ...    req = get_request(q, rw_flags, bio, 0);  //无法合并,申请新的request    ... ...    init_request_from_bio(req, bio);}
void blk_flush_plug_list(struct blk_plug *plug, bool from_schedule){ ... ... __elv_add_request(q, rq, ELEVATOR_INSERT_SORT_MERGE); //将request插入request_queue的elevator调度器 ... ...}
复制代码

请求队列

Linux 中提供了不同类型的 request_queue,一个是本文主要涉及的 single-queue,另外一个是 multi-queue。single-queue 是在早期的硬件设备(例如机械硬盘)只能串行处理 IO 的背景下创建的,而随着更快速的 SSD 设备的普及 single-queue 已经无法发挥底层存储的性能了,进而诞生了 multi-queue,它优化了很多机制使 IOPS 达到了百万级别以上,至于 multi-queue 和 single-queue 的详细区别,本篇不做讨论。


每个队列都可以配置不同的调度器,常见的有:noop,deadline,cfq 等。不同的调度器会根据:IO 类型、进程优先级、deadline,等因素对 request 请求进一步进行合并和排序。我们可以通过 sysfs 进行配置,来满足业务场景的需求:


#/sys/block/sdx/queuescheduler      #调度器配置nr_requests      #队列深度max_sectors_kb    #最大IO大小
复制代码



设备驱动

在 IO 经过通用块层的处理和调度后就进入到了设备驱动层,设备驱动层开始就需要和存储硬件进行交互。


以 scsi 驱动为例:在 scsi 的 request 处理函数 scsi_request_fn 中,循环从 request_queue 中取 request,并创建 scsi_cmd 下发给注册到 scsi 子系统的设备驱动。需要注意的是 scsi_cmd 中会注册一个 scsi_done 的回调函数。


static void scsi_request_fn(struct request_queue *q){    for (;;) {        ... ...        req = blk_peek_request(q);    //从request_queue中取出request        ... ...        cmd->scsi_done = scsi_done;    //指定cmd完成后回调        rtn = scsi_dispatch_cmd(cmd);  //下发将request对应的scsi_cmd        ... ...    }}
int scsi_dispatch_cmd(struct scsi_cmnd *cmd){ ... ... rtn = host->hostt->queuecommand(host, cmd); ... ...}
复制代码



IO 完成

软中断

每个 request_queue 都会注册软中断号,用来进行 IO 完成后的下半部处理,scsi 驱动中注册的为:scsi_softirq_done


struct request_queue *scsi_alloc_queue(struct scsi_device *sdev){    ... ...    q = __scsi_alloc_queue(sdev->host, scsi_request_fn);    ... ...    blk_queue_softirq_done(q, scsi_softirq_done);    ... ...}
复制代码

硬中断

当存储设备完成 IO 后会通过硬件中断通知设备驱动,此时设备驱动程序会调用 scsi_done 回调函数完成 scsi_cmd,并最终触发 BLOCK_SOFTIRQ 软中断。


void __blk_complete_request(struct request *req){            ... ...            raise_softirq_irqoff(BLOCK_SOFTIRQ);            ... ...}
复制代码


而 BLOCK_SOFTIRQ 软中断的处理函数就是之前注册的 scsi_softirq_done,通过自下而上层层回调,到达 bio_end_io,完成整个 IO 的生命周期。


    -> scsi_finish_command        -> scsi_io_completion            -> scsi_end_request                -> blk_update_request                    -> req_bio_endio                        -> bio_endio
复制代码



总结

以上我们很粗略的漫游了 Linux 中一个块设备 IO 的生命周期,这是一个很复杂的过程,其中很多机制和细节只是点到为止,但是我们有了对整个 IO 路径的整体的认识,当我们再遇到 IO 相关问题的时候就可以更加快速的找到关键部分,并深入研究解决。


发布于: 刚刚阅读数: 3
用户头像

沃趣科技

关注

玩转数据库生态的技术迷 2022.02.23 加入

专注数据库云生态领域,期待与各位一起探索技术奥秘,乐承分享乃永恒之道。

评论

发布
暂无评论
技术干货 | 漫游Linux块IO_沃趣科技_InfoQ写作社区