Linux块层架构介绍 块层IO流程与块层IO调度器详解

块存储没那么神秘!
之前一直跟大家聊文件系统,文件系统提供一层文件到物理块层的映射转换。这层逻辑可能非常复杂,依赖于文件系统的实现。今天则跟大家聊聊块层,块层位于 fs 层之下,大家可能平时不怎么接触,看不见摸不着。其实没有那么神秘,来看下块设备的使用姿势, linux 经常看见的 sda,sdb 这样的盘符文件其实就是块设备。
我们来试一下对一个块设备进行读写(注意,千万找一个不用的块设备,有写操作):
func main () {  // 千万注意:/dev/sdx1 盘,注意一定是要是不用的新盘哦。这里有写操作,可千万不要写坏数据哦  f, err := os.openfile(/dev/sdx1, os.o_rdwr, 0666)  if err != nil {   log.fatalf(err: %v, err)  }  // 写  content := hello world  _, err = f.writeat([]byte(content), 0)  if err != nil {   log.fatalf(err:%v, err)  }  // 读  buffer := make([]byte, len(content))  n, err := f.readat(buffer, 0)  if err != nil {   log.fatalf(err: %v, err)  }  fmt.printf(content= %s, n=%v , buffer, n)} 以上就完成了对块设备的一次读写,大家看用户态操作一下块设备是不是非常简单呀。说白了,就是把它当一个线性的文件来使用即可。用户态的文件系统经常喜欢这样去管理块设备,来看一下块层的架构。
块层架构
块设备的逻辑集中在 linux 的块层子系统中。由于块设备有多种的类型,并且内部关于不同的场景可能会有不同的调度需求,所以它的设计也必须满足一定的抽象,块层内一般分为三层:
通用层 :抽象不同的块设备,为上层建立统一的块设备模型; io 调度层 :io 请求入队、出队,并且按照特定的策略去调度(可配置); 块设备驱动层 :针对不同的块设备类型有着不同驱动程序。比如 scsi 设备,逻辑设备等; 聊聊这三层:
通用层:给上层一个叫做 submit_bio 的函数,并且和上层对齐的 io 类型叫做 bio ,所有的块 io 请求封装成 bio ,然后通过 submit_bio 递交即可。这是统一的抽象界面; io 调度层:无非就是上层请求怎么入队,然后怎么派发给底层。整体的块 io 调度器叫做电梯,它的架子也是电梯算法的架子,调度算法允许配置,常见的就是 cfq,deadline,noop 这三种算法; 块设备驱动层:针对不同块设备的封装,比如有顺序访问的块设备,还有随机访问的,还有逻辑栈式设备;     块层 io 流程
今天走一小段代码,省略复杂逻辑,只撸主干逻辑。
下面涉及到代码,配合 linux 3.10.1
 1   抽象界面 submit_bio  
块层的入口是 submit_bio 函数,上层填装 bio 结构之后,调用 submit_bio 递交到块层。一切的开始都是从这个函数。bio 就是块层和上层约定好的参数结构。块层的核心工作就是对这个结构做处理,做变形,最终做转发。
块设备的读写都是通过这个接口和参数来表达。来看一下 submit_bio 的逻辑:
void submit_bio(int rw, struct bio *bio) {  // 打上读写标记  bio->bi_rw |= rw;  // ...  // 构造 request  generic_make_request(bio);} 一个小信息:fs 和 块层交互的是 bio 类型,但是块层内部用的是 request 结构,由 bio 转换而来。
2   io 请求入队 make_request_fn  
来看下 generic_make_request 中 实现:
void generic_make_request(struct bio *bio) {  // 保证 make_requst 核心的逻辑在同一个进城内是串行之行的  if (current->bio_list) {   bio_list_add(current->bio_list, bio);   return;  }  bio_list_init(&bio_list_on_stack);  current->bio_list = &bio_list_on_stack;  // 循环处理 bio_list 里的内容  do {   // 获取到该块设备的队列   struct request_queue *q = bdev_get_queue(bio->bi_dev);   // 执行 make_request_fn 回调   q->make_request_fn(q, bio);   bio = bio_list_pop(current->bio_list);  } while (bio);  current->bio_list = null;} 块设备都会有个 request_queue 结构体。队列这个很容易理解,用来挂请求的。排序、合并之后 io 请求总得放在一个地方。
上面的核心是 request queue 的 make_request_fn 函数回调。合并和排序就是在该函数中实现。
思考:make_request_fn 又是什么呢?什么时候赋值的呢?
其实是设备创建之初,初始化结构体的时候赋值好的。比如 scsi 设备,那么 make_request_fn 就是 blk_queue_bio 。
注意:这里我们看到还有一个 request_fn 的回调,scsi 设备是初始化成 scsi_request_fn ,记住这个,后面会讲。
// 分配并初始化一个 scsi 设备scsi_alloc_sdev scsi_alloc_queue  // 创建、初始化请求队列, 回调 request_fn  __scsi_alloc_queue(sdev->host, scsi_request_fn);   blk_init_queue    blk_init_queue_node     blk_init_allocated_queue      // 回调 make_request_fn      blk_queue_make_request(q, blk_queue_bio); 排序、合并的逻辑则是在 make_request_fn 中完成,也就是 io 请求入队的过程就完成了。
思考:合并、排序究竟是什么意思?
合并:将磁盘上扇区连续的多个请求合并成一个,然后作为一个 scsi 请求命令下发 排序:将多个 io 请求按照磁盘扇区的位置进行排序,以便磁头能够尽可能的往一个方向摆动 合并和排序在 make_request_fn 中完成。来看一看调度器的逻辑:
void blk_queue_bio(struct request_queue *q, struct bio *bio){  el_ret = elv_merge(q, &req, bio);  if (el_ret == elevator_back_merge) {   // 往后合并  } else if (el_ret == elevator_front_merge) {   // 往前合并  }get_rq:  // 没合并的新请求走这里  plug = current->plug  if (plug) {   list_add_tail(&req->queuelist, &plug->list);  } else {   add_acct_request(q, req, where);   __blk_run_queue(q);  }} elv_merge 会返回一个常量来标识向前或者向后合并。如果是向前或者向后合并,那么返回对应的已经存在的 request 结构体。如果是不能合并,那么就创建的一个新的 request 请求。
思考:io 请求在 make_request_fn 入队,那什么时候派发呢?
是通过 request_fn 回调函数(往前看,设备初始化的时候赋值的)来处理。scsi 设备的 request_fn 回调是 scsi_request_fn 函数。函数中有个 for 循环,会按照策略逐个取出请求,封装处理一下,然后丢到底层去执行 ( 使用 scsi_dispatch_cmd )。
思考:有没有想过能够合并和排序的基础条件是啥?
基础是:有足够多的请求。这个依赖于 linux 的一个叫做蓄流/泄流的机制(plugging / unplugging)。蓄流/泄流什么意思?
linux 块层为了增加吞吐能力,对于来的请求,并不是立即下发,而是 hold 一段时间,攒一会儿,超时了或者超阈值了,再一次性处理请求下发。类似于用塞子堵住一下(蓄流)池子,水满了再拔掉塞子(泄流)。
很容易理解,块层之所以不将每个请求都立马下发,就是为了实现 io 的调度。这种短暂的 hold  请求本质上是优化的基础。如果每一个请求都不聚合,不把大家聚在一起,那么是无法找到优化的时机的。
3   io 请求派发 request_fn  
request 的派发逻辑则是在 request_fn 中完成,scsi 是 scsi_request_fn 函数。unplug 的时候就会走到这里来。
static void scsi_request_fn(struct request_queue *q) {  for (;;) {   // 按照 io 调度策略取请求出来   req = blk_peek_request(q);   // 派发命令到 scsi 驱动层   rtn = scsi_dispatch_cmd(cmd);  }} 这里的逻辑则是集中在 ”怎么取请求出来?“ 。
io 调度算法的主要发挥时机就是在这里!(还有一个时机就是入队的时候)
把 request 选出来之后,封装成 scsi cmd 命令,通过 scsi_dispatch_cmd 函数下发到 scsi 驱动层即可。这样一个请求的派发就算完成了。
块层 io 调度器
接着上面,聊聊块层 io 调度器。首先块层整体的 io 调度器是一个电梯算法的架子,你会发现 elevator 的缩写到处都是。随着 linux 系统的持续发展还是衍生了不同于真正简单电梯算法的调度算法。这个就是我们常见的 noop,deadline,cfq 等算法。这部分则是被抽象成一个界面,可以让用户来配置选择。这个统一界面类型叫做 elevator_type :
struct elevator_type {  // 最重要的是这个  struct elevator_ops ops;} 最重要就是 ops 字段,以 noop 算法为例:
static struct elevator_type elevator_noop = {  .ops = {   .elevator_merge_req_fn = noop_merged_requests,   .elevator_dispatch_fn = noop_dispatch,   .elevator_add_req_fn = noop_add_request,   .elevator_former_req_fn = noop_former_request,   .elevator_latter_req_fn = noop_latter_request,   .elevator_init_fn = noop_init_queue,   .elevator_exit_fn = noop_exit_queue,  }  .elevator_name = noop,  .elevator_owner = this_module,} 也就是说,不同的调度算法只要实现了这个 elevator_type ,就可以在电梯的大框架里运行了。所以,其实“电梯”已经不是“电梯”了。
调度算法主要做好两个事情:
bio 请求入队:合并、排序?随你。比如 noop 就没有合并和排序 request 请求出队:电梯不电梯?随你,目前就看 deadline 是变形的“电梯”。 调度算法的实现在:
noop 的实现在 block/noop-iosched.c ,及其简单 deadline 的实现在 block/deadline-iosched.c ,主要加了一个超时,饥饿的考虑 cfq 的实现在 block/cfq-iosched.c ,号称绝对公平算法,考虑到不同进程的性能公平,逻辑是最复杂的 今天先不给大家展开调度策略的实现,只简要梳理其作用。说白了,无论哪种调度策略,都只是选出一个 request 出来而已。
思考:linux 怎么查看调度策略?
cat /sys/block/{块设备符}/queue/scheduler    1   noop  
它自身不带任何逻辑。没有合并、排序。请求按照 fifo 的方式入队出队。谁先进的谁先出,不区分请求。
这种简单的算法,反而适用于超高性能的介质,比如 ssd ,因为 ssd 的性能已经足够高了,自身提供的并发能力也是够的。不需要上层做顺序化,批量化,它没有磁头摆动的问题。合并和排序等多余的逻辑对它来讲就是累赘。
2   deadline  
最像电梯算法的 io 调度算法。在电梯算法的基础上,再加上请求超时的考虑,起到防止饥饿请求的作用。它内部有两部电梯:读写。读请求优先,但也会考虑写饥饿,同一时间只有一部电梯在运行。
3   cfq  
完全公平队列,为每个进程单独创建一个队列来管理该进程所产生的请求,试图给不同的进程分配相同的块设备使用时间片,以此来保证每个进程都能被很好的分配到 io 带宽。该算法就属于万金油,通用场景用它是最保险的,是最通用的 io 调度算法。它的逻辑实现也是最复杂的。noop 100 行代码,deadline 460 行,cfq 4600 行代码,由此可见。
总结
submit_bio 是块层的统一接口函数,bio 是统一 io 结构体 request 是块层内部的 io 请求的结构体,bio 到 request 有一层转换,这层转换就是排序、合并的时候; make_request_fn 就是 bio 转换成 request 的时机,io 的排序、合并时机就在于此; request_fn 是 request 派发的时机,io 的调度算法也在此发挥作用。从 request 队列中按照策略选一个 req 出来,封装成底层想要的样子,下发下去 io 调度器合并、排序,包括按照电梯直上直下派发请求的目的是减少磁盘寻址时间,从而提高整体性能; noop,cfq,deadline 三种派发请求的算法都有适用自己的场景。noop 是几乎 bypass 请求,没有重拍、合并。deadline 是电梯算法衍生了一下。cfq 是全新的一种公平算法,跟电梯没啥关系; 块层最核心的逻辑是:bio 请求怎么入队,request 请求怎么出队而已,它远比文件系统要简单;
原文标题:linux 块层 io 子系统|最核心的逻辑是什么?
文章出处:【微信公众号:linux爱好者】欢迎添加关注!文章转载请注明出处。


RISC-V SIG成功将Chromium等桌面软件适配到openEuler RISC-V
物联网领导者如何做可以获得更好的成绩
由富士康和凌华科技合资的法博智能移动FARobot已正式成立
OPPO Reno3 Pro配置参数再曝光,重量仅有17克
主从触发器,主从触发器的原理和特点有哪些?
Linux块层架构介绍 块层IO流程与块层IO调度器详解
基于zigbee应用 油井生产
工业物联网解决方案:风沙气象数据实时上云监控
详谈AR/VR/MR数字光学元件和技术
最新!两部门发文开展中小企业数字化转型城市试点 百望云赋能企业“加速跑”
RAN市场增长强劲 中兴通讯趁势回血
无人机也能进行风力发电?
应该安装还是避免win10可选更新?
基于光化学原理的方法:无需手术也能永久治疗近视
iPhone8什么时候上市?iPhone8最新消息:iPhone8全面屏+双玻璃机身真机图赏惊艳,iPhone8功能提前看
物联网项目可以为产品设计带来什么好处
传iPhone研发内幕:参与人数达2000人
又一个爆款诞生? 新机realme 3 Pro印度市场热销
华为mate10和mate10pro在硬件上有什么区别
输出4A开关频率550KHz的变换器电路