[toc]
block/elevator.c I/O调度器框架(I/O Scheduler Framework) 可插拔I/O调度的通用层
历史与背景
这项技术是为了解决什么特定问题而诞生的?
block/elevator.c 的诞生是为了解决一个核心的性能问题,并提供一个灵活的解决方案。问题在于:如何高效地组织对机械硬盘(HDD)的I/O请求。
机械硬盘的性能瓶颈在于物理移动:磁头寻道(seek)和盘片旋转(rotation)。无序地处理随机I/O请求会导致磁头在盘片上疯狂来回移动,造成极大的性能浪费。elevator.c 实现的“电梯”(Elevator)框架,其核心目标是:
- I/O请求排序:像电梯服务楼层一样,先朝一个方向(例如,逻辑块地址LBA递增)处理所有请求,到达末端后再反向,从而将多次随机寻道变为一次或几次大的顺序扫描,最大化吞吐量。
- 请求合并:将对相邻磁盘区域的小请求合并成一个大请求,减少I/O操作的总次数。
- 提供可插拔框架:认识到没有一种调度算法能适应所有工作负载(例如,数据库 vs. 桌面),
elevator.c被设计成一个通用框架,允许不同的调度算法(如Deadline, CFQ)作为“插件”注册到块层,并由系统管理员在运行时进行选择。
它的发展经历了哪些重要的里程碑或版本迭代?
elevator.c 本身是框架,其里程碑体现在构建于其之上的各种调度器:
- Linus Elevator:最早的实现,一个简单的结合了排序和合并的截止时间调度器。
- Deadline I/O Scheduler:一个重要的进步,明确分离了吞吐量优化(LBA排序)和延迟保证(请求截止时间),解决了请求饥饿问题。
- Anticipatory (AS) I/O Scheduler:尝试通过在处理完一个读请求后短暂等待,期望能有另一个物理上邻近的读请求到来,以减少寻道。在某些场景下有效,但后来被认为过于复杂。
- Completely Fair Queuing (CFQ) I/O Scheduler:一个革命性的调度器,它从面向请求转向面向进程。它为每个进程创建了独立的I/O队列,并尝试在进程间公平地分配I/O带宽。CFQ在很长一段时间内都是Linux的默认调度器,极大地改善了桌面系统的响应性。
- 废弃与被
blk-mq取代(最关键的里程碑):elevator.c及其所有调度器都构建在一个单请求队列(single request queue)的模型上。这个模型在多核CPU和高速SSD时代成为了严重的性能瓶颈,因为所有CPU核心都必须竞争同一个队列锁来提交I/O。为了解决这个问题,Linux内核引入了多队列块层(blk-mq)。blk-mq是一个全新的I/O路径,而elevator.c所代表的整个单队列框架则被标记为遗留(legacy)。
目前该技术的社区活跃度和主流应用情况如何?
block/elevator.c 目前处于纯粹的维护模式。它不再有任何新功能开发。
- 应用情况:它只在那些尚未被转换为
blk-mqAPI的旧块设备驱动上使用。对于所有现代存储驱动(NVMe, UFS, SATA/AHCI),内核都会默认使用blk-mq路径。因此,在绝大多数现代系统中,elevator.c中的代码路径实际上是不活动的。 - 社区状态:社区的全部精力和开发工作都集中在
blk-mq框架及其调度器(如mq-deadline,bfq,kyber)上。
核心原理与设计
它的核心工作原理是什么?
elevator.c 的核心是作为一个通用调度层,粘合了块设备请求队列和具体的调度器插件。
- 关联 (Association):当一个块设备被初始化时,系统管理员可以通过sysfs(
/sys/block/sda/queue/scheduler)为它的request_queue选择一个“电梯”类型(如deadline或cfq)。elevator.c中的elevator_init()函数会创建并关联一个具体的调度器实例到这个队列上。 - 请求拦截 (
elevator_add_req):当上层(如文件系统)通过旧的make_request路径提交一个bio时,这个请求不会直接发送给驱动。相反,它会被elevator.c的框架代码拦截,并传递给当前关联的调度器插件。 - 调度器处理:调度器插件(如CFQ)会将这个请求放入其内部的、经过排序的数据结构中(例如,CFQ会根据进程ID将其放入对应的队列)。调度器负责进行合并和排序。
- 请求派发 (
elevator_dispatch):当设备驱动程序准备好接收更多工作时,块层会调用elevator.c的派发函数。这个函数会反过来调用调度器插件的派发接口,要求它从其内部队列中“挑选”出下一个最合适的request。 - 交付给驱动:调度器挑选出的
request最终被移出调度器,并放入驱动程序的派发队列中,等待被发送到硬件。
它的主要优势体现在哪些方面?
- 模块化设计:在它所处的时代,其最大的优势就是将调度策略从块层核心逻辑中分离出来,实现了高度的模块化和灵活性。
- 对HDD性能的巨大提升:通过I/O排序和合并,它将HDD的性能发挥到了极致。
- 运行时可配置:允许管理员根据应用场景动态调整策略,无需重启或重新编译内核。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 单队列瓶颈:这是其致命缺陷。整个框架围绕一个由单个锁保护的全局请求队列。在多核系统上,这会导致严重的锁竞争,无法发挥多核CPU的并行处理能力。
- 不适合SSD:其核心思想是优化寻道,而SSD没有寻道时间。在SSD上,
elevator.c框架引入的排序和排队开销不仅无益,反而会增加CPU负担和I/O延迟,限制了SSD的IOPS性能。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?
在现代Linux内核中,不存在elevator.c框架是“首选解决方案”的场景。它的存在仅仅是为了向后兼容。
- 遗留驱动:它的唯一使用场景是当系统运行着一个非常古老的、或者没有被维护的、尚未更新到
blk-mq接口的块设备驱动程序时。
是否有不推荐使用该技术的场景?为什么?
对于所有现代硬件和驱动程序,都不推荐(实际上也无法选择)使用elevator.c框架。
- 多核CPU和SSD/NVMe:在这些硬件上,
blk-mq是唯一能够有效利用硬件并行性的I/O路径。elevator.c的单队列模型会成为一个巨大的性能瓶颈,完全无法发挥硬件的潜力。
对比分析
请将其 与 其他相似技术 进行详细对比。
elevator.c (单队列调度框架) vs. blk-mq (多队列块层)
| 特性 | elevator.c (单队列框架) |
blk-mq (多队列框架) |
|---|---|---|
| 队列模型 | 单队列。一个设备只有一个由全局锁保护的请求队列。 | 多队列。每个CPU核心有一个无锁的软件暂存队列,并直接映射到硬件提供的多个提交队列。 |
| 锁模型 | 粗粒度锁。所有CPU提交I/O时都会竞争同一个锁。 | 细粒度/无锁。Per-CPU队列的设计极大地减少甚至消除了提交路径上的锁竞争。 |
| 扩展性 | 差。在多核系统上扩展性非常糟糕,性能会随着核心数增加而下降。 | 优秀。能够很好地扩展到数百个CPU核心,充分利用硬件并行性。 |
| 目标硬件 | 机械硬盘 (HDD)、单核/少核CPU。 | NVMe/SSD、多核/众核CPU。 |
| 调度器实现 | 调度器(Deadline, CFQ)管理一个全局的I/O流。 | 调度器(mq-deadline, BFQ)在每个硬件派发队列上独立运行,管理多个I/O流。 |
| 当前状态 | 遗留 (Legacy),仅为兼容性保留。 | 当前标准,是所有现代存储驱动的默认I/O路径。 |
I/O 调度器注册与注销框架:elv_register 与 elv_unregister
本代码片段展示了 Linux 内核 I/O 调度器“电梯”(elevator)框架的中央注册管理机制。其核心功能是:提供 elv_register 函数,作为一个统一的入口点,供所有 I/O 调度器模块(如 mq-deadline)调用,以安全地将自己添加到系统的可用调度器列表中;同时,提供 elv_unregister 函数,用于在模块卸载时,将调度器从列表中安全移除,并处理相关的资源回收。这是实现 Linux 可插拔 I/O 调度器架构的基石。
实现原理分析
此机制通过一个受锁保护的全局链表来维护所有可用的调度器类型,并通过精细的资源管理和同步(特别是 RCU)来确保其在高度并发的内核环境中的健壮性。
注册过程 (
elv_register):- 前置校验: 函数首先执行一系列
WARN_ON_ONCE检查,验证传入的elevator_type结构体是否合法。它强制要求调度器必须提供最核心的操作函数,如finish_request(清理已完成的 I/O)、insert_requests(接收新 I/O) 和dispatch_request(分发 I/O)。这是一种“快速失败”的设计,确保了只有功能完整的调度器才能被注册。 - 可选资源分配: 它检查调度器是否请求了
icq(I/O context queue) 缓存。如果需要,它会为该调度器创建一个专用的kmem_cache。icq是一个 per-process/cgroup 的数据结构,用于某些高级调度策略。 - 原子化注册:
a.spin_lock(&elv_list_lock): 获取全局自旋锁,保护对全局调度器链表elv_list的访问。
b.__elevator_find(...): 在锁内,首先检查是否已存在同名的调度器,以防止命名冲突。
c. 错误处理: 如果发现重名,它会立即释放锁,并销毁刚刚可能已创建的icq_cache,确保没有资源泄漏,然后返回-EBUSY。
d.list_add_tail(...): 如果一切正常,就将新的调度器类型添加到elv_list的尾部。
e.spin_unlock(...): 释放锁。
- 前置校验: 函数首先执行一系列
注销过程 (
elv_unregister):- 原子化移除: 它同样首先获取
elv_list_lock锁,然后调用list_del_init将调度器从全局链表中安全地移除。 - 关键的同步:
rcu_barrier(): 这是注销过程中最重要也是最精妙的一步。
a. 问题:icq结构体是由 RCU (Read-Copy-Update) 机制管理的。这意味着,即使一个调度器已经被从elv_list中移除,系统的其他部分(在其他 CPU 上)可能仍然处于一个 RCU 读侧临界区中,并持有对从该调度器的icq_cache中分配出的icq对象的临时引用。如果此时立即销毁icq_cache,就会导致 use-after-free 错误。
b. 解决方案:rcu_barrier()是一个强同步原语。它会阻塞,直到系统中在rcu_barrier()调用之前开始的所有 RCU 读侧临界区全部结束。这相当于一个全局的“静默点”,它保证了在rcu_barrier()返回之后,内核中绝对没有任何代码再持有对旧icq对象的引用。 - 资源销毁: 在确保安全后,它才调用
kmem_cache_destroy来销毁icq_cache。
- 原子化移除: 它同样首先获取
代码分析
1 | /** |










