什么是 ‘Block Layer I/O Scheduling’?解析 `deadline` 与 `mq-deadline` 算法如何合并磁盘读写请求?

尊敬的各位同仁,下午好!

今天,我们将深入探讨一个在操作系统内核中至关重要,但往往被应用程序开发者所忽视的领域——块层I/O调度(Block Layer I/O Scheduling)。这是一个关于如何高效管理和优化磁盘或固态硬盘(SSD)读写操作的艺术与科学。我将以编程专家的视角,为大家剖析其核心机制,特别是聚焦于Linux内核中经典的 deadline 算法以及为现代高性能存储而生的 mq-deadline 算法,并详细解析它们如何合并磁盘读写请求。

一、 块层I/O调度:为什么我们需要它?

在计算机系统中,CPU、内存与存储设备之间存在着巨大的性能鸿沟。CPU以纳秒级进行操作,内存以几十到几百纳秒级响应,而传统的机械硬盘(HDD)则以毫秒级响应I/O请求。即使是现代的固态硬盘(SSD)和NVMe设备,其内部并行度高,但单个I/O请求的延迟也远高于CPU和内存。

当多个进程同时向存储设备发出读写请求时,如果没有一个智能的协调机制,这些请求将以它们到达的任意顺序被发送到硬件。这会导致以下问题:

  1. 低效率的机械臂移动(针对HDD):对于HDD,随机访问是性能杀手。每次磁头从一个磁道移动到另一个磁道,都需要消耗大量的寻道时间(seek time)和旋转延迟(rotational latency)。如果请求是随机的,磁头将频繁地来回摆动,导致吞吐量急剧下降。
  2. 写放大与磨损(针对SSD):虽然SSD没有机械部件,但频繁的小块随机写入仍然会因为内部垃圾回收、FTL(Flash Translation Layer)管理等原因,导致不必要的写放大,降低SSD寿命。
  3. 请求饥饿(Request Starvation):某些延迟敏感型请求(如用户界面的响应数据)可能会被大量非延迟敏感型请求(如后台批处理任务的数据)所淹没,导致用户体验下降。
  4. CPU开销:频繁地中断CPU处理单个小I/O请求,可能会增加CPU的上下文切换开销。

块层I/O调度器的核心目标正是为了解决这些问题。它位于操作系统内核的块设备层,负责在将I/O请求传递给设备驱动程序之前,对这些请求进行排序、合并和优先级的管理。其主要目标包括:

  • 提高吞吐量(Throughput):通过减少寻道时间(对HDD)和优化数据传输模式,使得单位时间内完成更多的I/O操作。
  • 降低延迟(Latency):确保关键的I/O请求能够及时得到处理,避免饥饿。
  • 公平性(Fairness):在多个竞争进程之间,相对公平地分配I/O带宽。
  • 减少CPU开销:通过合并请求,减少系统调用和中断处理的频率。

在Linux内核中,块层I/O调度器经历了多次演进,从最初的noopcfq(Completely Fair Queuing)到我们今天要详细讨论的deadlinemq-deadline。理解它们的工作原理,对于系统调优、存储系统架构设计以及性能问题诊断至关重要。

二、 deadline 算法:经典与平衡的艺术

deadline(截止时间)调度器是Linux内核中一个非常经典的I/O调度算法,其设计理念是在保证请求截止时间的前提下,尽可能提高I/O吞吐量。它特别适用于那些同时存在延迟敏感型和吞吐量敏感型工作负载的场景,如数据库服务器。

2.1 核心思想与数据结构

deadline调度器的核心思想是为每个I/O请求设置一个“截止时间”(deadline)。当一个请求到达时,调度器会将其放入两个队列中:一个按逻辑块地址(LBA)排序的队列,用于优化吞吐量;另一个按截止时间排序的队列,用于保证请求的及时响应。

为了实现这一目标,deadline调度器维护了以下关键数据结构:

  1. 排序队列(Sorted Queues)

    • read_requests:一个红黑树(或者链表,取决于实现细节,但通常是红黑树以保证排序和查找效率),其中的I/O请求按其起始LBA升序排列。
    • write_requests:同样是一个红黑树,其中的I/O请求按其起始LBA升序排列。
    • 这些LBA排序的队列旨在将物理上相邻的请求分组处理,从而减少HDD的寻道时间。
  2. 截止时间队列(Deadline Queues)

    • read_deadline_tree:一个红黑树,其中的I/O请求按其截止时间升序排列(最小截止时间在树的顶部)。
    • write_deadline_tree:同样是一个红黑树,其中的I/O请求按其截止时间升序排列。
    • 这些队列确保了任何请求在超时之前都能被处理,防止饥饿。

每个I/O请求在进入调度器时,会被封装成一个struct request结构体。这个结构体包含了请求的LBA、数据长度、读/写类型、所属进程ID以及由调度器计算出的截止时间。

// 伪代码: 简化版的I/O请求结构体
struct io_request {
    unsigned long sector;       // 逻辑块地址 (LBA)
    unsigned int len_sectors;   // 请求长度 (扇区数)
    enum rw_flags rw_flags;     // 读或写标记 (READ, WRITE)
    unsigned long deadline;     // 截止时间 (jiffies 或 timestamp)
    void *private_data;         // 指向bio结构或其他原始请求数据
    // ... 其他调度器内部字段,如链表节点、红黑树节点等
    struct list_head sorted_list; // 用于LBA排序链表
    struct rb_node deadline_node; // 用于deadline红黑树
};

// 调度器上下文结构体
struct deadline_data {
    struct request_queue *queue; // 指向块设备请求队列
    struct rb_root read_requests;
    struct rb_root write_requests;
    struct rb_root read_deadline_tree;
    struct rb_root write_deadline_tree;

    unsigned long last_sector;   // 上次完成的扇区位置,用于猜测寻道方向
    unsigned int nr_batch;       // 当前批处理计数
    unsigned int batch_size;     // 批处理大小限制
    unsigned int read_expire;    // 读请求截止时间 (jiffies)
    unsigned int write_expire;   // 写请求截止时间 (jiffies)
    unsigned int fifo_batch;     // 每次调度从FIFO队列中取出的最大请求数
    // ... 其他参数
};

2.2 请求的合并

在I/O调度中,请求合并是一个极其重要的优化手段。它可以在两个层面发生:

  1. 文件系统层或页缓存层:当文件系统或页缓存收到多个对相邻数据块的读写请求时,它们可能会在生成bio(Block I/O)结构之前就将这些请求合并成一个更大的bio
  2. 块层调度器内部:当一个新的bio到达块层调度器时,调度器会尝试将其与队列中已有的、物理上相邻的request进行合并。如果成功,则新bio的数据会追加到现有request中,避免创建一个新的request对象。这不仅减少了调度器的内部管理开销,更重要的是,它将多个小的I/O操作合并成一个大的I/O操作,从而减少了设备驱动程序的调用次数和硬件处理的开销。

deadline调度器在接收到一个新的bio时,会执行以下合并尝试:

  • 前向合并(Front Merge):检查新bio的结束地址是否紧邻队列中某个request的起始地址。如果是,并且两者类型相同(都是读或都是写),则将新bio合并到该request的头部。
  • 后向合并(Back Merge):检查新bio的起始地址是否紧邻队列中某个request的结束地址。如果是,并且两者类型相同,则将新bio合并到该request的尾部。

内核中的blk_attempt_merging函数及其相关逻辑负责处理这些合并操作。

// 伪代码: 简化版请求合并逻辑
bool try_merge_requests(struct io_request *new_req, struct deadline_data *dd) {
    // 遍历LBA排序队列 (read_requests 或 write_requests)
    // 假设 new_req 是一个待插入的新请求,我们尝试向前或向后合并
    struct io_request *existing_req;

    // 尝试后向合并 (new_req 紧随 existing_req 之后)
    existing_req = find_adjacent_request_by_end_lba(dd, new_req->sector - 1, new_req->rw_flags);
    if (existing_req && can_merge(existing_req, new_req)) {
        existing_req->len_sectors += new_req->len_sectors;
        // 更新deadline,取两者中较小的一个,确保新合并请求的deadline仍然有效
        existing_req->deadline = min(existing_req->deadline, new_req->deadline);
        return true; // 合并成功
    }

    // 尝试前向合并 (new_req 紧随 existing_req 之前)
    existing_req = find_adjacent_request_by_start_lba(dd, new_req->sector + new_req->len_sectors, new_req->rw_flags);
    if (existing_req && can_merge(new_req, existing_req)) { // 注意参数顺序,这里是new_req在前
        existing_req->sector = new_req->sector;
        existing_req->len_sectors += new_req->len_sectors;
        existing_req->deadline = min(existing_req->deadline, new_req->deadline);
        return true; // 合并成功
    }

    return false; // 未能合并
}

can_merge函数会检查请求的类型(读/写)、设备号、以及其他一些标志,确保合并是合法的。合并成功后,通常只需要更新现有request的长度和可能的截止时间,而无需创建新的request结构体,节省了资源。

2.3 调度算法

当设备驱动程序准备好处理下一个I/O请求时,它会调用调度器的dispatch_next_request函数。deadline调度器会根据一系列规则来决定从哪个队列中选择下一个请求:

  1. 检查截止时间队列

    • 首先,调度器会检查读和写截止时间队列的顶部请求。如果任何一个请求的截止时间已经过期(即当前时间超过了其deadline),那么这个请求会被优先选择并发送给设备。
    • 为了防止一个类型的请求(例如,大量写请求)完全饿死另一个类型(例如,少量读请求),deadline调度器通常会设置一个读/写截止时间的阈值。例如,如果读请求的截止时间非常临近,即使没有过期,也可能优先于写请求。
    • read_expirewrite_expire参数控制了读写请求的默认截止时间。
  2. 批处理(Batching)

    • 为了提高吞吐量,deadline调度器会尝试进行批处理。它会选择一个读或写队列,并从该队列中连续发送一批请求。这个批处理的大小由fifo_batch参数控制。
    • 当选择批处理时,调度器会优先选择与上次完成的I/O操作扇区位置(last_sector)相邻的请求,以最大限度地减少寻道时间。
    • 读请求通常比写请求具有更高的优先级,因为读操作通常是同步的,会阻塞用户进程。

deadline调度器的调度逻辑可以用以下伪代码概括:

// 伪代码: deadline调度器的dispatch_next_request函数
struct io_request* deadline_dispatch_next_request(struct deadline_data *dd) {
    struct io_request *rq_deadline_read = get_min_deadline_request(dd->read_deadline_tree);
    struct io_request *rq_deadline_write = get_min_deadline_request(dd->write_deadline_tree);

    unsigned long now = get_current_jiffies();

    bool read_expired = (rq_deadline_read && rq_deadline_read->deadline <= now);
    bool write_expired = (rq_deadline_write && rq_deadline_write->deadline <= now);

    // 1. 优先处理过期的请求
    if (read_expired && write_expired) {
        // 如果读写都有过期,优先处理更紧急的 (或读请求)
        if (rq_deadline_read->deadline <= rq_deadline_write->deadline) {
            return remove_request(dd, rq_deadline_read, true); // true表示从deadline树移除
        } else {
            return remove_request(dd, rq_deadline_write, false);
        }
    } else if (read_expired) {
        return remove_request(dd, rq_deadline_read, true);
    } else if (write_expired) {
        return remove_request(dd, rq_deadline_write, false);
    }

    // 2. 如果没有过期请求,根据启发式规则选择批处理
    // 假设有一个内部计数器 dd->current_batch_count
    // 假设 dd->next_dispatch_type 存储了上次选择的类型,以实现读写平衡

    struct io_request *rq_lba_read = get_next_lba_sorted_request(dd->read_requests, dd->last_sector);
    struct io_request *rq_lba_write = get_next_lba_sorted_request(dd->write_requests, dd->last_sector);

    if (rq_lba_read && (!rq_lba_write || (dd->next_dispatch_type == DISPATCH_READ && dd->current_batch_count < dd->fifo_batch) || (now - rq_deadline_write->deadline > dd->write_expire_threshold))) {
        // 优先处理读请求,或当前是读批处理时间,或写请求离过期还远
        if (dd->next_dispatch_type != DISPATCH_READ) {
            dd->next_dispatch_type = DISPATCH_READ;
            dd->current_batch_count = 0;
        }
        dd->current_batch_count++;
        return remove_request(dd, rq_lba_read, true);
    } else if (rq_lba_write) {
        // 处理写请求
        if (dd->next_dispatch_type != DISPATCH_WRITE) {
            dd->next_dispatch_type = DISPATCH_WRITE;
            dd->current_batch_count = 0;
        }
        dd->current_batch_count++;
        return remove_request(dd, rq_lba_write, false);
    }

    return NULL; // 没有可调度的请求
}

// remove_request 会将请求从LBA排序队列和deadline队列中移除
struct io_request* remove_request(struct deadline_data *dd, struct io_request *rq, bool is_read) {
    if (is_read) {
        rb_erase(&rq->deadline_node, &dd->read_deadline_tree);
        rb_erase(&rq->sorted_node, &dd->read_requests); // 假设sorted_node用于LBA排序
    } else {
        rb_erase(&rq->deadline_node, &dd->write_deadline_tree);
        rb_erase(&rq->sorted_node, &dd->write_requests);
    }
    dd->last_sector = rq->sector + rq->len_sectors - 1; // 更新上次处理的扇区位置
    return rq;
}

2.4 参数与调优

deadline调度器提供了一些可配置的参数,可以通过 /sys/block/<device>/queue/iosched/ 路径进行调整:

  • read_expire:读请求的截止时间(毫秒)。
  • write_expire:写请求的截止时间(毫秒)。
  • fifo_batch:每次调度从LBA排序队列中取出的最大请求数。
  • writes_starved:在调度读请求之前,允许写请求被饥饿的次数。

这些参数的调整需要根据具体的工作负载和硬件特性进行。例如,对于延迟敏感型应用,可以适当降低read_expirewrite_expire;对于吞吐量要求高的应用,可以增加fifo_batch

三、 mq-deadline 算法:拥抱多核与NVMe时代

随着多核CPU和高性能存储设备(特别是NVMe SSD)的普及,传统的单队列I/O调度器(包括deadlinecfq)开始暴露出瓶颈。这些瓶颈主要体现在:

  1. 锁竞争(Lock Contention):单队列模型意味着所有的CPU核心在提交或调度I/O请求时,都需要争抢同一个全局锁来保护调度器的数据结构。在高并发场景下,这会成为严重的性能瓶颈。
  2. CPU缓存未命中(Cache Misses):I/O请求数据和调度器状态在不同CPU核心之间频繁迁移,导致CPU缓存效率低下。
  3. 无法充分利用设备并行性:NVMe等现代存储设备支持数千个队列和大量的并行I/O操作。单队列调度器无法有效利用这种硬件级别的并行能力。

为了解决这些问题,Linux内核引入了多队列块层(blk-mq)架构。blk-mq将I/O请求的处理路径从单队列模式转变为多队列模式,旨在消除锁竞争,提高CPU扩展性,并更好地利用现代存储设备的并行能力。

3.1 blk-mq 架构概述

blk-mq的核心思想是为每个CPU核心或每个硬件队列提供一个独立的提交队列和/或调度器实例。其主要组件包括:

  • Software Queues (Per-CPU Submission Queues):每个CPU核心都有一个或多个独立的提交队列。当用户进程在某个CPU上发出I/O请求时,该请求会被提交到对应的CPU提交队列中。这样就避免了全局锁竞争。
  • Hardware Queues (Dispatch Queues):这些队列直接映射到存储设备的硬件队列。例如,一个NVMe SSD可能支持多个硬件队列。blk-mq负责将来自软件队列的请求分发到这些硬件队列。
  • Tag Setblk-mq使用标签(Tag)来管理未完成的I/O请求,每个请求在进入硬件队列时都会被分配一个唯一的标签,以便设备完成时能够准确地识别和处理。
  • I/O Scheduler (Per-Hardware-Queue):调度器现在是可选的,并且可以为每个硬件队列实例化。这意味着每个硬件队列都可以运行自己的调度算法,或者根本不运行调度器(直接将请求发送到硬件)。

在这种架构下,mq-deadline调度器应运而生,它是deadline算法在blk-mq框架下的实现。

3.2 mq-deadline 的核心思想与数据结构

mq-deadline 的核心思想是将deadline调度算法的逻辑应用到blk-mq的每个硬件队列上。这意味着每个硬件队列都有自己的独立的deadline调度器实例,拥有自己的一套读/写LBA排序队列和读/写截止时间队列。

// 伪代码: mq-deadline的调度器上下文
// 注意: 这个结构体现在是为每个blk-mq的hw_queue实例化的
struct mq_deadline_data {
    struct request_queue *queue; // 指向blk-mq的请求队列
    struct rb_root read_requests;
    struct rb_root write_requests;
    struct rb_root read_deadline_tree;
    struct rb_root write_deadline_tree;

    unsigned long last_sector;   // 上次完成的扇区位置
    unsigned int nr_batch;       // 当前批处理计数
    unsigned int batch_size;     // 批处理大小限制
    unsigned int read_expire;    // 读请求截止时间 (jiffies)
    unsigned int write_expire;   // 写请求截止时间 (jiffies)
    unsigned int fifo_batch;     // 每次调度从FIFO队列中取出的最大请求数

    // ... 其他 blk-mq 特有的字段
    // 例如,一个指向其所属的 blk_mq_hw_ctx 的指针
    struct blk_mq_hw_ctx *hctx;
};

// blk-mq 硬件上下文结构体 (简化)
struct blk_mq_hw_ctx {
    // ... 其他字段
    void *driver_data; // 指向具体的调度器实例 (如 mq_deadline_data)
    // ... 用于管理硬件队列的锁,但调度器内部不再需要全局锁
};

3.3 请求的合并在 mq-deadline

blk-mq架构下,请求的合并逻辑与传统的deadline调度器略有不同,但核心思想保持一致。

  1. 提交队列级别的合并:当一个bio被提交到一个CPU的软件队列时,blk-mq层会尝试在该软件队列中查找是否有可以合并的现有request
  2. 硬件队列级别的合并:当blk-mq将请求从软件队列分发到某个硬件队列时,如果该硬件队列配置了调度器(如mq-deadline),那么mq-deadline调度器在接收到请求时,会像传统deadline一样,尝试与该硬件队列中已有的request进行前后向合并。

这意味着合并操作可以在I/O路径的更早阶段(软件队列)发生,也可以在更晚的阶段(硬件队列的调度器)发生。无论在哪个阶段,合并的原理都是相同的:寻找物理上连续的相邻请求,并将其合并成一个更大的请求,以减少I/O操作次数和开销。

blk-mqblk_mq_attempt_merge函数是处理合并的核心入口点,它会尝试将新的bio合并到属于相同blk_mq_ctx(CPU上下文)的现有request中。如果成功,则新的bio会直接被添加到现有requestbio链表中。如果无法合并,才会创建一个新的request结构体。

// 伪代码: blk-mq 中的请求提交与合并流程
void blk_mq_submit_bio(struct bio *bio) {
    // 1. 获取当前CPU的blk_mq上下文
    struct blk_mq_ctx *ctx = get_current_cpu_mq_ctx();

    // 2. 尝试合并
    // blk_mq_attempt_merge 会尝试将bio合并到ctx的请求队列中
    // 如果成功,bio会被添加到现有request的bio链表,并返回该request
    // 如果失败,返回NULL
    struct request *existing_req = blk_mq_attempt_merge(ctx, bio);

    if (existing_req) {
        // 合并成功,bio已附加到existing_req,无需创建新request
        // 更新existing_req的deadline等信息
        update_request_deadline(existing_req, bio_get_deadline(bio));
        // ...
        return;
    }

    // 3. 如果无法合并,创建新的request
    struct request *new_req = blk_mq_alloc_request(ctx, bio->cmd_flags);
    new_req->bio = bio; // 将bio添加到request的bio链表头部
    new_req->sector = bio->bi_iter.bi_sector;
    new_req->nr_sectors = bio->bi_iter.bi_size >> 9; // 字节转扇区
    new_req->deadline = calculate_deadline(bio);
    // ... 设置其他字段

    // 4. 将新请求加入到调度器(如果有的话)
    // 这里会调用 mq-deadline 的 add_request 函数
    struct blk_mq_hw_ctx *hctx = blk_mq_map_queue(new_req->q, new_req->sector);
    mq_deadline_add_request_to_hctx_scheduler(hctx->driver_data, new_req);

    // 5. 通知硬件队列有新请求
    blk_mq_run_hw_queue(hctx);
}

// mq_deadline_add_request_to_hctx_scheduler 函数内部会执行LBA排序和deadline排序
void mq_deadline_add_request_to_hctx_scheduler(struct mq_deadline_data *dd, struct request *rq) {
    // 将rq插入到dd的read_requests/write_requests (LBA排序)
    // 将rq插入到dd的read_deadline_tree/write_deadline_tree (deadline排序)
    // 逻辑与经典deadline的request_insert类似,只是现在是per-hw-queue
    // ...
}

3.4 mq-deadline 的调度算法

mq-deadline的调度算法与传统deadline在核心逻辑上非常相似,但它是为每个blk_mq_hw_ctx(硬件队列上下文)独立运行的。

当一个硬件队列需要调度下一个I/O请求时,它会调用其关联的mq-deadline调度器实例的dispatch_next_request函数。这个函数会使用与传统deadline相同的启发式规则:

  1. 检查过期请求:优先处理读或写截止时间队列中已过期的请求。
  2. 批处理:如果没有过期请求,则选择一个读或写队列,从其LBA排序队列中取出一批请求进行处理,尝试连续访问相邻的扇区。

由于每个硬件队列都有自己的调度器实例,它们可以并行地进行调度决策,大大减少了全局锁的开销,从而提升了多核CPU下I/O的并发性和吞吐量。

// 伪代码: mq-deadline调度器的dispatch_next_request函数 (per-hw-queue)
struct request* mq_deadline_dispatch_next_request(struct blk_mq_hw_ctx *hctx) {
    struct mq_deadline_data *dd = (struct mq_deadline_data *)hctx->driver_data;
    // ... (调度逻辑与经典deadline的dispatch_next_request基本相同)
    // 区别在于所有的操作都是针对dd这个per-hw-queue的实例
    // dd->read_requests, dd->write_requests, dd->read_deadline_tree, dd->write_deadline_tree
    // 都是hctx这个硬件队列私有的
    // ...
    return picked_request; // 返回选定的请求
}

3.5 参数与调优

mq-deadline的参数与传统deadline类似,但通常位于/sys/block/<device>/queue/iosched/路径下,并且可能因内核版本和驱动实现而略有差异。

  • read_expire
  • write_expire
  • fifo_batch

这些参数仍然用于控制读写请求的截止时间和批处理行为。由于mq-deadline是每个硬件队列独立运行,其调优效果在多并发场景下可能更为显著。

四、 deadlinemq-deadline 的比较

理解了两种调度器的内部机制,现在我们可以进行一个直接的比较,以明确它们的适用场景和优势。

特性/算法 deadline mq-deadline
架构 单队列块层(blk-classic)调度器,全局锁保护。 多队列块层(blk-mq)调度器,per-hardware-queue 实例。
并发模型 多个CPU竞争一个I/O调度器实例,存在锁竞争。 每个硬件队列有独立的调度器实例,消除全局锁竞争。
CPU扩展性 有限,在高并发下CPU利用率可能因锁竞争而受限。 优秀,可扩展到大量CPU核心。
适用存储 机械硬盘(HDD)、SATA SSD(传统AHCI接口)。 NVMe SSD、高性能PCIe SSD、多队列SCSI/SAS设备。
性能瓶颈 全局锁、单队列吞吐量限制、CPU缓存效率。 调度器内部数据结构(红黑树)操作,但并行度高。
延迟控制 提供截止时间机制,有效防止请求饥饿。 同样提供截止时间机制,且因并行度高,实际延迟可能更低。
吞吐量 通过LBA排序和批处理优化吞吐量。 通过LBA排序和批处理,结合硬件并行性,提供更高的吞吐量。
请求合并 在单请求队列中尝试前后向合并。 可以在CPU上下文队列和硬件队列调度器中独立尝试合并。
默认选择 较旧的Linux内核版本或特定HDD系统。 较新的Linux内核版本(通常是默认),以及所有NVMe设备。

从上表可以看出,mq-deadlinedeadline调度器在现代硬件和多核CPU环境下的演进版本。它保留了deadline算法在平衡吞吐量和延迟方面的优点,同时通过blk-mq架构解决了传统单队列调度器的可伸缩性问题。

对于HDD而言,deadline仍然是一个不错的选择,因为HDD本身的寻道延迟是主要瓶颈,单队列调度器的锁开销可能相对不那么显著。但是,即使对于HDD,如果系统CPU核心数较多且I/O并发度极高,mq-deadline也可能表现出更好的CPU扩展性。

对于SSD和NVMe设备,mq-deadline是毫无疑问的首选。这些设备能够并行处理大量的I/O请求,blk-mqmq-deadline能够充分发挥其并行潜力,提供更高的IOPS(Input/Output Operations Per Second)和更低的延迟。

五、 实践与配置

在Linux系统中,我们可以很方便地查看和修改块设备的I/O调度器。

  1. 查看当前调度器

    cat /sys/block/sda/queue/scheduler

    输出通常会显示当前活动的调度器,并用方括号[]括起来,例如 noop deadline [mq-deadline] none

  2. 修改调度器

    echo mq-deadline > /sys/block/nvme0n1/queue/scheduler

    请注意,并非所有设备都支持所有调度器。特别是NVMe设备,它们通常只支持mq-deadlinenone(直接将请求发送到硬件,不进行额外的调度)。对于blk-mq设备,通常推荐使用mq-deadlinenonenone调度器意味着没有额外的排序或批处理,请求会尽可能快地传递给设备驱动。对于非常高性能的NVMe设备,如果应用程序本身已经做了很好的I/O排序或并行化,none可能提供最低的延迟。

  3. 查看调度器参数

    ls /sys/block/sda/queue/iosched/
    cat /sys/block/sda/queue/iosched/read_expire

何时选择哪个调度器?

  • 机械硬盘 (HDD)
    • deadline:对于大多数混合工作负载,是一个平衡的选择。
    • cfq:在较旧的内核版本中是默认,但在现代内核中已被淘汰。它更注重公平性,但可能牺牲吞吐量。
    • noop:如果应用程序已经对I/O进行了完美的排序,或者设备本身有智能调度器,noop可以减少内核开销。
  • SATA SSD (使用AHCI驱动)
    • deadline:通常是一个合理的选择,因为SSD没有寻道时间,但deadline的批处理可以合并请求,减少CPU开销。
    • noop:对于高性能SSD,如果I/O模式主要是随机小块,或者应用程序已经高度优化,noop可能提供最佳性能。
  • NVMe SSD / PCIe SSD (使用blk-mq驱动)
    • mq-deadline:推荐的通用调度器。它利用了多队列架构,同时保留了deadline的延迟保护和请求合并能力。
    • none:如果工作负载对延迟极其敏感,并且应用程序能够自行管理I/O队列和排序,none可以提供最低的内核开销。但请谨慎选择,因为它放弃了内核层面的I/O优化。

总结与展望

块层I/O调度是操作系统内核中一个精妙且复杂的子系统,它在将应用程序的I/O请求转化为物理存储设备的实际操作之间扮演着至关重要的协调角色。deadline调度器以其独特的截止时间与LBA排序结合的策略,在传统的单队列时代为HDD和SATA SSD提供了卓越的平衡性能。

随着硬件技术的飞速发展,特别是多核CPU和NVMe SSD的普及,传统的单队列模型日益暴露出其性能瓶颈。Linux内核引入的blk-mq架构,以及基于其构建的mq-deadline调度器,成功地将deadline算法的优势扩展到了高性能、高并发的现代存储环境中,通过多队列并行处理,极大地提升了I/O的扩展性和吞吐量,同时保持了对延迟的良好控制。理解这些调度器的设计哲学和实现细节,对于构建和优化高性能计算系统至关重要,它帮助我们更好地驾驭底层硬件的潜力,为上层应用提供稳定、高效的存储服务。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注