Linux 块设备多队列架构逐层拆解:blk-mq 在 eMMC 驱动中的硬软队列调度机制
一、单队列瓶颈的工程现场:当 eMMC 的 IOPS 被内核锁死
嵌入式 Linux 设备中使用 eMMC 作为主存储时,一个典型的性能瓶颈出现在高并发读写场景下。假设设备同时执行多个应用的数据写入,系统监控显示 CPU 的 iowait 达到 60% 以上,而 eMMC 的物理带宽仅用了 30%。根因排查后发现,传统 Linux 块层的单请求队列(blk-sq)成为瓶颈——所有 I/O 请求需要通过一把全局自旋锁串行入队,多核 CPU 上的并发提交变成了锁竞争的灾难。
以四核 Cortex-A53 平台上 eMMC 5.1 设备为例,HS400 模式下的理论带宽为 400MB/s,随机读 IOPS 可达 30K。但在blk-sq模式下实测随机读 IOPS 仅 12K,根本原因是每次提交 bio 都需要获取request_queue的全局锁,四个核在锁上轮转等待的时间超过实际 I/O 执行时间。
Linux 内核从 3.13 引入 blk-mq(Multi-Queue Block Layer)就是为了解决多核扩展性问题。blk-mq 为每个 CPU 核心分配独立的软件提交队列,消除全局锁竞争;并为底层硬件(如 eMMC 控制器的命令槽位)映射为独立的硬件队列,实现 I/O 提交路径的全链路并行化。理解 blk-mq 中软件队列与硬件队列的映射关系,是优化 eMMC 存储性能的前提。
二、blk-mq 的双层队列架构与 eMMC 请求分发路径
blk-mq 的设计分为两层队列:软件暂存队列(Software Staging Queue,ctx->rq_list)和硬件分发队列(Hardware Dispatch Queue,hctx->dispatch)。上层应用提交的 bio 在内核中转换为struct request后,根据当前 CPU 编号进入对应的软件队列。当软件队列积压一定量请求,或者硬件队列空闲时,请求通过->queue_rq回调被下发到 eMMC 驱动层。
标签管理(Tag Management)是 blk-mq 实现请求跟踪的核心机制。每个硬件队列维护一个固定大小的 tag 池,tag 数量等于硬件能同时处理的命令槽位数。当应用层提交 I/O 请求时,blk-mq 从 tag 池中分配一个唯一的 tag 编号给该请求。eMMC 驱动在发送 CMD 时将此 tag 编号填入命令参数,硬件控制器利用 tag 来匹配返回的响应和对应的请求。当驱动层 I/O 完成时,通过 tag 编号找到原始请求,完成 bio 的回调通知。
三、eMMC blk-mq 驱动的初始化与请求处理流程
eMMC 驱动切换到 blk-mq 需要在mmc_blk_probe中设置对应的tag_set参数:
/* eMMC 块设备驱动 blk-mq 初始化 — drivers/mmc/core/queue.c */ static int mmc_mq_init_queue(struct mmc_queue *mq, struct mmc_card *card) { struct mmc_host *host = card->host; struct blk_mq_tag_set *set; int ret; set = &mq->tag_set; memset(set, 0, sizeof(*set)); /* 1. 配置 tag 集合参数 */ set->ops = &mmc_mq_ops; /* blk-mq 操作回调 */ set->nr_hw_queues = 1; /* eMMC 通常只有 1 个硬件队列 */ /* 关键: 队列深度等于硬件命令槽位数 — 不能超过 eMMC 协议限制 */ set->queue_depth = host->cmdq_depth ?: MMC_QUEUE_DEPTH; /* 默认 64 */ set->numa_node = NUMA_NO_NODE; set->flags = BLK_MQ_F_SHOULD_MERGE | BLK_MQ_F_BLOCKING; /* eMMC 操作可能阻塞 */ /* 2. 设置每个软件队列的最大请求数 */ set->cmd_size = sizeof(struct mmc_queue_req); set->nr_maps = 1; /* nr_queues = nr_hw_queues ≈ 各 CPU 共享到同一个硬件队列的映射表 */ ret = blk_mq_alloc_tag_set(set); if (ret) { dev_err(mmc_dev(host), "Failed to alloc blk-mq tag set: %d\n", ret); return ret; } /* 3. 创建请求队列,绑定到 gendisk */ mq->queue = blk_mq_init_queue(set); if (IS_ERR(mq->queue)) { ret = PTR_ERR(mq->queue); dev_err(mmc_dev(host), "Failed to init blk-mq queue: %d\n", ret); blk_mq_free_tag_set(set); return ret; } /* 4. 设置 eMMC 特定的调度器参数 */ /* eMMC 建议使用 none 或 kyber 调度器 — mq-deadline 适合旋转介质 */ mq->queue->nr_requests = set->queue_depth; /* 5. 启动内核线程处理 eMMC 请求 */ /* mmc_queue_thread 从硬件队列中取出请求并转化为 MMC 命令 */ sema_init(&mq->thread_sem, 0); mq->thread = kthread_run(mmc_queue_thread, mq, "mmcqd/%d%s", host->index, mmc_card_mmc(card) ? "" : "rpmb"); if (IS_ERR(mq->thread)) { ret = PTR_ERR(mq->thread); blk_cleanup_queue(mq->queue); blk_mq_free_tag_set(set); return ret; } return 0; } /* blk-mq 操作回调 — command 被 blk-mq 核心在硬件队列消费时调用 */ static blk_status_t mmc_mq_queue_rq(struct blk_mq_hw_ctx *hctx, const struct blk_mq_queue_data *bd) { struct request *req = bd->rq; struct mmc_queue *mq = hctx->driver_data; struct mmc_queue_req *mqrq = blk_mq_rq_to_pdu(req); int ret; /* 1. 检查卡是否处于正常工作状态 */ if (mq->card && mmc_card_removed(mq->card)) { /* 卡已拔出,直接失败所有等待请求 */ blk_mq_start_request(req); return BLK_STS_IOERR; } /* 2. 将请求加入 eMMC 命令队列 */ mqrq->req = req; blk_mq_start_request(req); /* 3. 唤醒 mmc_queue_thread 处理该请求 */ spin_lock_irq(&mq->lock); list_add_tail(&mqrq->queuenode, &mq->queue_list); spin_unlock_irq(&mq->lock); up(&mq->thread_sem); /* 唤醒 mmcqd 内核线程 */ /* 返回 BLK_STS_RESOURCE 表示请求已排队但尚未完成 */ return BLK_STS_OK; /* eMMC 使用异步完成模式 */ } /* mmc_queue_thread 的核心处理循环 */ static int mmc_queue_thread(void *data) { struct mmc_queue *mq = data; while (1) { /* 等待 blk-mq 提交请求或停止信号 */ if (down_interruptible(&mq->thread_sem)) break; /* 批量处理请求,减少上下文切换 */ spin_lock_irq(&mq->lock); while (!list_empty(&mq->queue_list)) { struct mmc_queue_req *mqrq = list_first_entry(&mq->queue_list, struct mmc_queue_req, queuenode); list_del(&mqrq->queuenode); spin_unlock_irq(&mq->lock); /* 将请求转化为 MMC 命令并发送 */ int ret = mmc_blk_mq_issue_rq(mq, mqrq->req); if (ret) { blk_mq_end_request(mqrq->req, errno_to_blk_status(ret)); } spin_lock_irq(&mq->lock); } spin_unlock_irq(&mq->lock); } /* 线程退出时刷新队列 */ mmc_queue_flush(mq); return 0; }四、命令排队深度与调度延迟的架构权衡
blk-mq 的队列深度参数直接影响 eMMC 的吞吐与延迟。较大的队列深度(如 64)可以让 eMMC 控制器始终有命令可执行,提升带宽利用率;但过深的队列意味着单个请求的排队延迟增加,对于fsync这类需要顺序保证的操作尤为敏感。eMMC 5.1 引入了 CMD Queue(命令队列)特性,支持最多 32 个排队命令,控制器可以乱序执行以最大化吞吐,但这与上层文件系统期望的 Barrier 语义产生了冲突。
另一个需要权衡的点是软中断上下文与内核线程的处理模型。部分 eMMC 驱动选择在->queue_rq回调中直接发送命令(软中断上下文),而非通过内核线程转发。前者的延迟更低,但软中断不能睡眠的约束限制了可执行的操作类型(如不能进行需要睡眠的寄存器配置)。后者的调度开销更大(线程切换约 5~10us),但可以在任何上下文中执行复杂的错误恢复逻辑。对于可插拔的 eMMC 设备,推荐使用内核线程模型以支持卡插入/拔出时的热插拔事件处理。
五、总结
blk-mq 架构通过在软件层面为每个 CPU 分配独立队列、在硬件层面映射为驱动命令槽位,解决了多核平台上的块 I/O 扩展瓶颈。eMMC 驱动的适配要点包括:
- tag_set 参数必须匹配硬件命令槽位数,tag 数量过多会浪费内存,过少会限制并发度;
- eMMC 的 CMD Queue 特性可以进一步提升随机吞吐,但需要上层 Barrier 支持的配套修改;
- 内核线程 vs 软中断的处理模型选择取决于是否需要睡眠操作和热插拔支持;
- 队列深度需要在吞吐与延迟之间权衡,建议通过基准测试确定最优值。