vLLM 深度解析系列:理解现代大语言模型服务

系列概述

本系列对 vLLM 进行全面深入的技术剖析。vLLM 是大语言模型推理与服务领域最重要的开源项目之一,最初由 UC Berkeley 的 Sky Computing Lab 开发,现已成为生产环境中高性能 LLM 服务的事实标准。

Read more »

引言

prow-images 仓库是基于 Kubernetes Prow 构建的复杂 CI/CD 基础设施的核心组件。它作为专用容器镜像的集中式集合,为持续集成和交付流水线的各个方面提供动力。本文将深入探讨 prow-images 生态系统的架构、组件和工作流程,特别关注它与 prow-configs 仓库和 manual-trigger 服务之间的关系。

什么是 prow-images?

prow-images 仓库是一个包含超过 35 个不同专用容器镜像的单体仓库(monorepo),每个镜像都设计用于处理 Prow 作业中的特定任务。这些镜像从基本实用工具(如 Git 操作)到复杂工具(如 E2E 测试框架、Kubernetes 集群配置和自动安全 PR 生成)应有尽有。

仓库结构

仓库中的每个组件都遵循一致的结构:

  • 用于构建容器镜像的 Dockerfile
  • 跟踪当前版本的 VERSION 文件(例如 v0.0.1
  • 包含基于 Go 的主应用程序的 entrypoint 目录
  • 组件特定的 README 文档

根目录的 Makefile 负责协调所有镜像的构建和推送到中央镜像仓库 hub.mycloud.io/prowimages/

核心组件

让我们深入了解组成这个生态系统的一些关键组件:

1. CI Generator - 配置自动化引擎

CI Generator 是生态系统中最关键的组件之一。它从简化的清单文件自动生成 Prow 作业规范。

主要特性:

  • 从 prow-configs 仓库读取 .manifest 文件
  • 支持多种作业生成类型:BuildUnitTest
  • 自动生成 presubmit 和 postsubmit 作业配置
  • 处理与 Kaniko 集成的复杂构建场景

工作原理:

  1. 开发者在 prow-configs 中的仓库作业目录中创建 ci.manifest 文件
  2. CI Generator 读取这些清单文件并生成完整的 Prow 作业 YAML 规范
  3. 生成的文件会自动标记头部信息:”此文件由 ci generator 自动生成,请勿手动编辑”
  4. 作业可以配置不同的触发器:PR 时 (onPr)、标签时 (onTag),支持正则表达式模式

清单示例片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
platform/maintenance:
- jobGenType: Build
name: build-maintenance-controller
branch: master
dockerFile: Dockerfile
versionFile: VERSION
buildTime: kaniko
targets:
- onPr:
imageTags:
- hub.mycloud.io/maintenance/maintenance-controller:pr-${PULL_NUMBER}
- onTag:
tagRegex:
- ^v(\d)+\.(\d)+\.(\d)+
imageTags:
- hub.mycloud.io/maintenance/maintenance-controller:${PULL_BASE_REF}

2. Kaniko - 安全的容器构建

Kaniko 镜像包装器提供了一种在 Kubernetes Pod 中安全构建容器镜像的方式,无需访问 Docker 守护进程。

功能:

  • 支持可配置深度的 Git 仓库克隆
  • 支持 git-crypt 加密仓库
  • 多个目标镜像仓库
  • 构建参数和标签
  • 镜像仓库镜像支持
  • TLS 验证选项
  • 自动向 GitHub PR 发送构建结果评论
  • 构建后命令执行

在 Prow 作业中的使用模式:

1
2
3
4
5
6
7
8
spec:
containers:
- image: hub.mycloud.io/prowimages/kaniko:latest
args:
- --dockerfile=Dockerfile
- --context=/workspace/repo
- --destination=hub.mycloud.io/myapp:${PULL_NUMBER}
- --build-arg=VERSION=${VERSION}

3. Kind - Docker 中的 Kubernetes 测试环境

Kind 镜像能够在 CI 流水线中创建临时 Kubernetes 集群用于 E2E 测试。

特性:

  • 创建隔离的 Kubernetes 集群
  • 支持多个 Kubernetes 版本(1.20、1.32、1.34)
  • 与上游 Kubernetes 补丁集成
  • 可脚本化的集群配置
  • 自动清理

4. Auto Security PR - 自动化 RBAC 管理

这个专用工具自动化创建跨多个集群的安全相关 RBAC 资源的拉取请求。

工作流程:

  1. 在 YAML 中定义 RBAC 资源(ClusterRoles、ServiceAccounts、ClusterRoleBindings)
  2. 指定作用域:fcpclustertessAppsAZtessNetAZtessMasterAZ
  3. 针对特定集群或使用 all: true 针对作用域中的所有集群
  4. 该工具生成并提交 PR 到 sig-security 仓库

使用示例:

1
2
3
4
5
6
7
8
9
10
clusterRoles:
- metadata:
name: cluster-role-1
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
scope:
cluster:
all: true

5. Auto Approval/Validation - PR 自动化

这些组件处理拉取请求的自动批准和验证:

  • Auto Approval:当特定文件包含某些键值对时自动批准 PR
  • Auto Validation:验证 PR 更改的不可变性和 Kubernetes 对象的正确性

6. E2E 测试套件

多个 E2E 测试镜像提供全面的测试能力:

  • e2e:通用 E2E 测试的基础镜像
  • fd-e2e:专门用于功能开发 E2E 测试
  • tessci:用于 E2E 测试的集群获取和管理工具

7. 构建工具集合

各种专业的构建工具:

  • Bazel:多个版本(3.4.1、4.2.2、7.3.1、7.7.1)用于 Bazel 构建
  • Go:标准化的 Golang 构建环境
  • Ko:Go 容器镜像构建器
  • Buildctl:BuildKit CLI 包装器
  • Python:Python 运行时环境(3.8.15、3.14.0)

8. Git 操作

Git 相关实用工具:

  • git:核心 Git 操作包装器
  • git-sync-k8s-patches:同步 Kubernetes 补丁与上游
  • make-commit:自动化提交创建

9. 开发工具

  • helm-bot:自动化 Helm chart 管理和 PR 创建
  • autotag:自动化版本标记
  • clone-and-do:克隆仓库并执行命令(Bash、Make)
  • gotestcover:Go 测试覆盖率分析和报告

10. 专用工具

  • canirun:镜像漏洞扫描
  • prow-config-validator:验证 Prow 配置文件
  • prow-image-builder:构建此仓库中定义的镜像
  • create-release:自动化发布创建
  • release-notes:生成发布说明

三仓库生态系统

仓库 1:prow-images

用途:容器镜像定义和构建逻辑

位置/Users/tashen/prow-images

内容

  • 35+ 个专用容器镜像定义
  • Dockerfile 和 VERSION 文件
  • 基于 Go 的入口应用程序
  • 通过 Makefile 进行构建编排
  • 公共 Go 库(git 实用工具、清单处理)

构建过程

1
2
3
4
5
6
7
8
# 构建所有镜像
make image

# 推送所有镜像到镜像仓库
make push

# 构建特定镜像
make image-kaniko

镜像仓库:所有镜像都推送到 hub.mycloud.io/prowimages/

仓库 2:prow-configs

用途:Prow 作业配置和 CI/CD 流水线定义

位置/Users/tashen/prow-configs

结构

1
2
3
4
5
6
7
8
9
prow-configs/
├── jobs/
│ ├── example-org/ # 组织特定的作业
│ ├── example-istio/
│ ├── ESTOOLS/
│ └── ...
├── prow-configs/ # Prow 配置文件
├── hack/ # 辅助脚本
└── Makefile

关键文件

  • ci.manifest:CI Generator 处理的简化作业定义
  • *.yaml:自动生成的 Prow 作业规范
  • presubmit、postsubmit 和 periodic 作业的配置

工作流程

  1. 开发者创建/修改 ci.manifest 文件
  2. 运行 make jobgen 生成作业规范
  3. CI 验证生成的配置
  4. 合并后,Prow 加载新配置

仓库 3:test-infra/prow/cmd/manual-trigger

用途:手动作业触发服务

位置/Users/tashen/test-infra/prow/cmd/manual-trigger

功能:HTTP 服务,允许在没有 GitHub 事件的情况下触发 Prow 作业

完整工作流程

场景 1:添加新的构建作业

  1. 开发者操作(prow-configs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 在 prow-configs/jobs/myorg/myrepo/ci.manifest
    myorg/myrepo:
    - jobGenType: Build
    name: build-myapp
    branch: main
    dockerFile: Dockerfile
    versionFile: VERSION
    buildTime: kaniko
    targets:
    - onPr:
    imageTags:
    - hub.mycloud.io/myorg/myapp:pr-${PULL_NUMBER}
  2. CI 生成

    • CI Generator(来自 prow-images)读取清单
    • 生成完整的 Prow 作业 YAML 规范
    • 创建在 PR 上运行的 presubmit 作业
    • 配置来自 prow-images 的 Kaniko 镜像作为作业容器
  3. 作业执行

    • 当创建 PR 时,Prow 触发 presubmit 作业
    • Kaniko 镜像克隆仓库
    • 使用指定的 Dockerfile 构建容器
    • 推送到镜像仓库,标签为 pr-${PULL_NUMBER}
    • 将构建状态发布回 GitHub PR

场景 2:手动运行 E2E 测试

  1. 开发者需要测试特定提交

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    curl -X POST "http://manual-trigger.ci-system/manual-trigger" \
    -H "Content-Type: application/json" \
    -d '{
    "org": "platform",
    "repo": "tessops",
    "base_ref": "feature-branch",
    "prowtype": "postsubmit",
    "prowjob": "sddz-e2e-k8s-1.32",
    "user": "developer-name"
    }'
  2. Manual Trigger 服务

    • 验证请求参数
    • 在来自 prow-configs 的 Prow 配置中查找作业
    • 在 Kubernetes 中创建 ProwJob 自定义资源
    • 设置 AUTHOR=developer-name 环境变量
  3. 作业执行

    • Prow 调度器拾取 ProwJob
    • 使用来自 prow-images 的 e2e 镜像
    • 配置 Kind 集群(使用来自 prow-images 的 kind 镜像)
    • 运行 E2E 测试
    • 报告结果(但不会发布到 GitHub,因为是手动触发)

场景 3:自动生成的安全 PR

  1. 安全团队操作

    • 在 YAML 文件中定义 RBAC 资源
    • 指定目标集群(集群 11、22 或所有集群)
  2. 自动化工作流程

    • Prow periodic 作业触发 auto-security-pr 镜像
    • 镜像扫描集群配置
    • 为每个集群生成适当的 RBAC 清单
    • 创建 PR 到 sig-security 仓库
    • Auto-validation 作业验证 PR
    • 如果满足条件,Auto-approval 作业批准

场景 4:构建和更新 prow-images 本身

  1. 开发者更新 Kaniko 镜像

    • 修改 /Users/tashen/prow-images/kaniko/entrypoint/main.go
    • /Users/tashen/prow-images/kaniko/VERSION 中将版本提升到 v0.0.2
  2. 本地构建

    1
    2
    3
    cd /Users/tashen/prow-images
    make image-kaniko
    make push-kaniko
  3. CI 集成

    • prow-images 的 PR 触发 presubmit 作业
    • prow-image-builder 作业构建所有修改的镜像
    • 测试验证新镜像
    • 合并后,postsubmit 作业构建并推送到镜像仓库
  4. Prow-configs 更新

    • prow-configs 中引用 Kaniko 镜像的作业现在可以使用新版本
    • 在清单文件中更新镜像标签或使用 latest 标签自动更新

关键集成点

1. 镜像仓库作为中心枢纽

所有组件通过 hub.mycloud.io 的中央镜像仓库进行通信:

  • prow-images 构建并推送到 hub.mycloud.io/prowimages/
  • prow-configs 从此镜像仓库引用镜像
  • 应用程序镜像构建并推送到组织特定的命名空间

2. 清单驱动的配置

CI Generator 在简单清单和复杂 Prow 配置之间建立桥梁:

  • 开发者编写简单的 .manifest 文件
  • CI Generator(来自 prow-images)将它们转换为完整的作业规范
  • Prow(由 prow-configs 配置)执行这些作业
  • 作业使用来自 prow-images 的镜像

3. Git 作为真相来源

所有三个仓库都使用 Git 进行版本控制和触发:

  • prow-images 的更改触发镜像重建
  • prow-configs 的更改触发配置验证
  • 应用程序仓库的更改触发在 prow-configs 中定义的作业
  • Manual-trigger 在需要时提供带外触发

4. Kubernetes 原生架构

一切都在 Kubernetes 上运行:

  • Prow 组件作为 Kubernetes 服务运行
  • ProwJob 是 Kubernetes 自定义资源
  • 所有作业执行都在 Kubernetes Pod 中进行
  • 来自 prow-images 的镜像提供 Pod 容器

高级功能

版本管理

prow-images 中的每个镜像都维护一个 VERSION 文件:

1
v0.0.1

这使得:

  • 工具的语义版本控制
  • 可重现的构建
  • 回滚能力
  • CI 作业中的镜像标签生成

多触发器支持

作业可以配置在不同的触发器上运行:

  • onPr:在拉取请求时运行
  • onTag:当推送特定标签模式时运行
  • Manual:通过 manual-trigger 服务触发
  • Periodic:定期执行

安全和认证

  • Git 操作使用基于令牌的认证
  • 镜像支持用于加密仓库的 git-crypt
  • 通过 Kubernetes Secret 进行镜像仓库认证
  • Prow 作业执行权限的 RBAC

可观测性

  • 在端口 9090 上暴露 Prometheus 指标
  • 所有镜像中的详细日志
  • PR 作业的 GitHub 状态报告
  • 在 Prow UI(deck)中可见 ProwJob 状态

最佳实践

prow-images 开发

  1. 版本提升:进行更改时始终更新 VERSION 文件
  2. 测试:推送前在本地测试镜像
  3. 文档:对重大更改更新组件 README
  4. 向后兼容性:更改接口时考虑现有用户

prow-configs 管理

  1. 使用清单:优先使用 .manifest 文件而不是手写作业 YAML
  2. 本地验证:提交 PR 前运行 make jobgen
  3. 测试作业:合并前使用 manual-trigger 测试新作业
  4. 避免手动编辑:永远不要直接编辑自动生成的文件

手动触发

  1. 使用正确的类型:选择 presubmit 用于 PR 测试,postsubmit 用于分支测试
  2. 设置用户:始终提供 user 参数以进行审计跟踪
  3. 监控作业:在 Prow UI 或通过 kubectl 检查作业状态
  4. 清理:应该调查并清理失败的作业

常见工作流程总结

向 prow-images 添加新工具

1
2
3
4
5
6
7
8
1. 创建目录:prow-images/mytool/
2. 添加 Dockerfile
3. 添加 VERSION 文件
4. 实现 entrypoint/main.go
5. 更新 Makefile 添加构建目标
6. 构建:make image-mytool
7. 推送:make push-mytool
8. 在 prow-configs 作业定义中使用

向 prow-configs 添加新作业

1
2
3
4
5
6
7
1. 创建/编辑 ci.manifest 文件
2. 使用 jobGenType、name、triggers 定义作业
3. 运行:make jobgen
4. 验证生成的 YAML
5. 创建 PR 到 prow-configs
6. CI 验证配置
7. 合并 → Prow 加载新作业

手动触发作业

1
2
3
4
5
6
1. 从 prow-configs 查找作业名称
2. 识别 org、repo、branch
3. POST 到 manual-trigger 服务
4. 在响应中接收 job_name
5. 监控:kubectl get prowjobs -n ci-system | grep <job_name>
6. 在 Prow UI 中查看日志

架构图(概念)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
┌─────────────────────────────────────────────────────────┐
│ GitHub 事件 │
│ (创建 PR、推送提交) │
└────────────────────┬────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│ Prow 控制器 │
│ (从 prow-configs 仓库读取配置) │
└─────────┬──────────────────────────────────────┬────────┘
│ │
│ 触发 ProwJob │
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Manual Trigger │ │ 定期作业 │
│ (HTTP 服务) │ │ (Periodic) │
└─────────┬────────────┘ └──────────┬───────────┘
│ │
│ 创建 ProwJob │
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Kubernetes ProwJob 资源 │
└─────────┬───────────────────────────────────────────────┘

│ 生成 Pod


┌─────────────────────────────────────────────────────────┐
│ 容器执行 │
│ (使用来自 prow-images 仓库的镜像) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Kaniko │ │ Kind │ │ E2E │ │
│ │ 镜像 │ │ 镜像 │ │ 镜像 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Git │ │ CI-Gen │ │ Auto-Sec │ │
│ │ 镜像 │ │ 镜像 │ │ 镜像 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────┬───────────────────────────────────────────────┘

│ 结果


┌─────────────────────────────────────────────────────────┐
│ GitHub 状态 / Prow UI │
└─────────────────────────────────────────────────────────┘

故障排除指南

镜像构建失败

问题make image-kaniko 失败

  • 检查 Dockerfile 语法
  • 验证基础镜像可用性
  • 确保依赖项已被 vendor
  • 检查 Docker 守护进程是否运行

作业不运行

问题:Prow 作业在 PR 上不触发

  • 验证 prow-configs 中是否启用了 trigger 插件
  • 检查作业名称在配置中是否匹配
  • 确保 prow-configs release 对象状态为 Succeeded
  • 验证清单文件是否已正确处理

Manual Trigger 错误

问题:”配置中未找到作业 X”

  • 验证确切的作业名称(区分大小写)
  • 检查 org/repo 组合是否正确
  • 确保已部署最新的 prow-configs
  • 验证作业类型匹配(presubmit/postsubmit)

镜像版本不匹配

问题:作业使用旧镜像版本

  • 检查作业规范中的镜像标签
  • 验证新镜像是否已推送到镜像仓库
  • 通过删除 :latest 标签强制拉取
  • 检查 imagePullPolicy 是否设置正确

结论

prow-images 生态系统代表了基于 Kubernetes 和 Prow 构建的全面的生产级 CI/CD 基础设施。三仓库架构提供了清晰的关注点分离:

  • prow-images:工具和实用程序(”如何做”)
  • prow-configs:作业定义和流水线(”做什么”)
  • manual-trigger:带外控制平面(”何时做”)

这些组件共同实现了:

  • 自动化构建和测试
  • 安全的容器镜像创建
  • 临时测试环境
  • 自动化安全管理
  • 灵活的作业触发
  • 企业级规模的可扩展 CI/CD

通过 CI Generator 的清单驱动方法显著降低了 Prow 配置的复杂性,使开发者能够轻松使用,同时保持 Kubernetes 原生 CI/CD 的全部功能。

无论您是构建新工具、添加测试作业还是手动触发部署,理解这三个仓库如何交互是有效使用这个强大 CI/CD 平台的关键。

延伸阅读


最后更新:2026 年 4 月 8 日

存储 IO 性能问题是生产环境中最常见也最棘手的问题之一。数据库响应变慢、应用延迟飙升、批处理任务超时,背后往往隐藏着复杂的 IO 瓶颈。本文从内核源码层面出发,系统梳理 Linux 存储性能的监控、分析与调优方法,覆盖从 iostat 到 eBPF、从 blktrace 到 ftrace 的完整工具链。

Read more »

一、IO 模型对比

在深入探讨 Direct IO 与异步 IO 实现之前,有必要先厘清 Linux 下各种 IO 模型的本质差异。POSIX 标准定义了同步与异步两大类 IO,而 Linux 在此之上提供了更丰富的变体。

1.1 四种经典 IO 模型

同步阻塞 IO(Blocking IO)是最直观的模型。read() 系统调用发出后,进程进入睡眠,内核等待数据就绪并完成内存拷贝,随后唤醒进程。整个过程中用户进程挂起,无法做其他事情。这是绝大多数传统应用的默认行为。

同步非阻塞 IO(Non-blocking IO)通过设置 O_NONBLOCK 标志,让 read() 在数据未就绪时立即返回 EAGAIN,而不是阻塞。应用程序需要循环轮询,CPU 利用率高但响应延迟低。这种模型适合极少数对延迟极度敏感的场景,但大多数情况下会造成 CPU 空转。

IO 多路复用(IO Multiplexing)通过 select/poll/epoll 等机制,让单线程同时监听多个文件描述符。epoll 采用事件驱动模型,内核通过红黑树管理监听集合,通过双向链表维护就绪队列,时间复杂度为 O(1)。当 fd 就绪时,epoll_wait 返回,应用再调用 read()/write(),此时数据已就绪,IO 操作本身不再阻塞。注意:IO 多路复用仍属于同步 IO,因为真正的数据拷贝(内核空间到用户空间)依然由调用 read() 的进程同步完成。

异步 IO(Asynchronous IO)是真正的异步模型。应用提交 IO 请求后立即返回,内核在后台完成数据读写和内存拷贝,完成后通过信号、回调或完成队列通知应用。Linux AIO(io_submit/io_getevents)和 io_uring 都属于此类,但实现机制和能力有本质区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────────────────────────────────────┐
│ IO 模型对比图 │
│ │
│ 同步阻塞 同步非阻塞 IO多路复用 异步IO │
│ │
│ 进程 进程 进程 进程 │
│ │ │ │ │ │
│ │ read() │ read() │ epoll_wait │ io_submit() │
│ │ │ EAGAIN ← │ │ ←── 立即返回 │
│ │ 阻塞等待 │ 轮询... │ 阻塞等待 │ │
│ │ │ read() │ 数据就绪↓ │ 内核处理中 │
│ │ 数据就绪 │ 数据就绪 │ read() │ │
│ │ ←── 返回 │ ←── 返回 │ ←── 返回 │ 完成通知 ← │
└─────────────────────────────────────────────────────────────────┘

两个关键维度区分这些模型:等待数据就绪的过程是否阻塞;数据拷贝(内核→用户)的过程是否阻塞。只有真正的异步 IO 在两个维度上都不阻塞调用者。

1.2 Linux 信号驱动 IO

Linux 还支持信号驱动 IO(SIGIO/O_ASYNC),通过 fcntl(fd, F_SETOWN, pid) 注册信号接收者。当 fd 就绪时内核发送 SIGIO 信号。但这种模式在实践中很少使用,因为信号处理函数受到很多限制,且无法区分多个 fd 的就绪事件。


二、直接 IO(O_DIRECT)实现

2.1 为什么数据库需要 Direct IO

Linux 的页缓存(Page Cache)是提升 IO 性能的核心机制:读操作的数据被缓存在内存中供后续复用,写操作先写入内存中的脏页(dirty page),由内核的 pdflush/writeback 线程异步刷盘。对于大多数应用,这种双重缓冲能显著提升吞吐量。

然而,对于数据库系统(PostgreSQL、MySQL InnoDB、Oracle 等),页缓存是一个障碍:

  1. 双重缓冲浪费内存:数据库有自己的 Buffer Pool,页缓存与之重叠,同样的数据在内存中存两份。
  2. 缓存污染:大规模全表扫描会把热数据从页缓存中驱逐,破坏缓存效果。
  3. fsync 语义复杂:数据库需要精确控制数据落盘时机(WAL 机制),通过页缓存的异步写入会引入不确定性。
  4. O_DIRECT 绕过页缓存,数据直接在用户缓冲区与磁盘之间传输(通过 DMA),数据库可以自主管理缓存,实现更精确的持久化控制。

2.2 对齐要求

Direct IO 有严格的内存和偏移对齐要求,违反会得到 EINVAL

  • 内存缓冲区地址:必须按扇区大小(通常 512 字节)或文件系统块大小(通常 4096 字节)对齐
  • 文件偏移量:同样须对齐
  • 传输长度:须为扇区/块大小的整数倍

内核在 do_blockdev_direct_IO 入口检查这些约束:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* fs/direct-io.c */
static inline int dio_bio_reap(struct dio *dio, struct dio_submit *sdio)
{
int ret = 0;

if (sdio->reap_counter++ >= 64) {
while (dio->bio_list) {
unsigned long flags;
struct bio *bio;
int ret2;

spin_lock_irqsave(&dio->bio_lock, flags);
bio = dio->bio_list;
dio->bio_list = bio->bi_private;
spin_unlock_irqrestore(&dio->bio_lock, flags);
ret2 = blkdev_issue_flush(bio->bi_bdev);
if (ret == 0)
ret = ret2;
bio_put(bio);
}
sdio->reap_counter = 0;
}
return ret;
}

2.3 struct dio 结构体与核心函数

struct dio 是 Direct IO 操作的核心控制块,定义在 fs/direct-io.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* fs/direct-io.c */
struct dio {
int flags; /* flags from file open */
blk_opf_t opf; /* request operation type and flags */
struct gendisk *bio_disk;
struct inode *inode;
loff_t i_size; /* i_size when submitted */
dio_iodone_t *end_io; /* IO completion function */

void *private; /* copy from map_bh.b_private */

/* BIO completion state */
spinlock_t bio_lock; /* protects BIO fields below */
int page_errors; /* errno from get_user_pages() */
int is_async; /* non-zero if this is async DIO */
bool defer_completion; /* defer AIO completion to workqueue? */
bool should_dirty; /* if pages should be dirtied */
int io_error; /* IO error in completion path */
unsigned long refcount; /* direct_io_worker() and bios */
struct bio *bio_list; /* singly linked via bi_private */
struct task_struct *waiter; /* waiting task (NULL if none) */

/* AIO related stuff */
struct kiocb *iocb; /* kiocb */
ssize_t result; /* IO result */

/*
* pages[] (and any fields after it) are not zeroed out at
* allocation time. Don't add new fields after pages[] unless
* you handle that.
*/
union {
struct page *pages[DIO_PAGES]; /* page buffer */
struct work_struct complete_work; /* deferred AIO completion */
};
} ____cacheline_aligned_in_smp;

do_blockdev_direct_IO 是发起 Direct IO 的核心入口,它协调用户空间缓冲区的 pin(通过 get_user_pages)、构建 bio 链并提交到块层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
ssize_t __blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,
struct block_device *bdev,
struct iov_iter *iter,
get_block_t get_block,
dio_iodone_t end_io,
int flags)
{
unsigned i_blkbits = READ_ONCE(inode->i_blkbits);
unsigned blkbits = i_blkbits;
unsigned blocksize_mask = (1 << blkbits) - 1;
ssize_t retval = -EINVAL;
const size_t count = iov_iter_count(iter);
loff_t offset = iocb->ki_pos;
const loff_t end = offset + count;
struct dio *dio;
struct dio_submit sdio = { 0, };
struct buffer_head map_bh = { 0, };

/* ... 对齐检查 ... */
if ((offset & blocksize_mask) || (count & blocksize_mask)) {
if (bdev) {
blkbits = blksize_bits(bdev_logical_block_size(bdev));
blocksize_mask = (1 << blkbits) - 1;
if ((offset & blocksize_mask) || (count & blocksize_mask))
goto out;
} else {
goto out;
}
}

dio = kmem_cache_alloc(dio_cache, GFP_KERNEL);
/* ... 初始化 dio,提交 bio,等待完成 ... */
}

2.4 dio_bio_submit 与 DMA 传输

dio_bio_submit 将构建好的 bio 提交到通用块层(Generic Block Layer),最终由设备驱动程序通过 DMA 完成数据传输:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static void dio_bio_submit(struct dio *dio, struct dio_submit *sdio)
{
struct bio *bio = sdio->bio;
unsigned long flags;

bio->bi_private = dio;

spin_lock_irqsave(&dio->bio_lock, flags);
dio->refcount++;
spin_unlock_irqrestore(&dio->bio_lock, flags);

if (dio->is_async && dio->opf == REQ_OP_READ && dio->should_dirty)
bio_set_pages_dirty(bio);

dio->bio_disk = bio->bi_bdev->bd_disk;

if (sdio->submit_io) {
sdio->submit_io(bio, dio->inode, sdio->logical_offset_in_bio);
sdio->bio = NULL;
sdio->boundary = 0;
sdio->logical_offset_in_bio = 0;
} else {
submit_bio(bio);
}

sdio->bio = NULL;
sdio->boundary = 0;
sdio->logical_offset_in_bio = 0;
}

DMA(Direct Memory Access)传输的原理:控制器从 bio 中取出物理内存页地址(bio_vec 数组),通过总线直接在磁盘控制器与主存之间搬运数据,CPU 全程不参与数据拷贝,完成后通过中断通知内核。这是 Direct IO 高效的根本原因——减少了一次内核缓冲区到用户缓冲区的 memcpy


三、Linux 内核 AIO(fs/aio.c

3.1 struct kiocb 字段解析

struct kiocb(Kernel IO Control Block)是内核异步 IO 的基本请求描述符,定义在 include/linux/fs.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct kiocb {
struct file *ki_filp; /* 目标文件 */
loff_t ki_pos; /* 当前 IO 偏移量 */
void (*ki_complete)(struct kiocb *iocb, long ret); /* 完成回调 */
void *private;
int ki_flags; /* IOCB_* 标志位 */
u16 ki_ioprio; /* 请求优先级 (IOPRIO_PRIO_VALUE) */
union {
/* AIO 完成事件关联 */
struct wait_page_queue *ki_waitq;
/* 用于 poll 路径 */
__poll_t (*ki_poll)(struct file *, struct poll_table_struct *);
};
};

ki_flags 中的关键标志:

  • IOCB_EVENTFD:完成时通过 eventfd 通知
  • IOCB_DIRECT:使用 Direct IO 路径
  • IOCB_NOWAIT:如果操作需要等待则立即返回 EAGAIN
  • IOCB_NOIO:不允许发起新 IO(用于预读路径)

3.2 io_submit() 系统调用实现

Linux AIO 的提交入口是 io_submit() 系统调用,对应内核函数 __io_submit_one

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/* fs/aio.c */
static int __io_submit_one(struct kioctx *ctx, const struct iocb *iocb,
struct iocb __user *user_iocb, struct aio_kiocb *req,
bool compat)
{
req->ki_filp = fget(iocb->aio_fildes);
if (unlikely(!req->ki_filp))
return -EBADF;

if (iocb->aio_flags & IOCB_FLAG_RESFD) {
/*
* eventfd 通知模式:完成时写入 eventfd
*/
req->ki_eventfd = eventfd_ctx_fdget(iocb->aio_resfd);
if (IS_ERR(req->ki_eventfd)) {
int ret = PTR_ERR(req->ki_eventfd);
req->ki_eventfd = NULL;
return ret;
}
}

ret = put_user(KIOCB_KEY, &user_iocb->aio_key);
if (unlikely(ret))
return ret;

req->ki_res.obj = (u64)(unsigned long)user_iocb;
req->ki_res.data = iocb->aio_data;
req->ki_res.res = 0;
req->ki_res.res2 = 0;

switch (iocb->aio_lio_opcode) {
case IOCB_CMD_PREAD:
return aio_read(&req->rw, iocb, false, compat);
case IOCB_CMD_PWRITE:
return aio_write(&req->rw, iocb, false, compat);
case IOCB_CMD_PREADV:
return aio_read(&req->rw, iocb, true, compat);
case IOCB_CMD_PWRITEV:
return aio_write(&req->rw, iocb, true, compat);
case IOCB_CMD_FSYNC:
return aio_fsync(&req->fsync, iocb, false);
case IOCB_CMD_FDSYNC:
return aio_fsync(&req->fsync, iocb, true);
case IOCB_CMD_POLL:
return aio_poll(req, iocb);
default:
pr_debug("EINVAL: no operation provided\n");
return -EINVAL;
}
}

io_submit() 每次调用可以批量提交多个 iocb 请求,内部对每个请求调用 __io_submit_one,分配 aio_kiocb,填充后提交到相应的文件操作实现。

3.3 io_getevents() 轮询机制

提交后,应用通过 io_getevents() 收割完成事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* fs/aio.c */
static long read_events(struct kioctx *ctx, long min_nr, long nr,
struct io_event __user *event,
ktime_t until)
{
long ret = 0;

/*
* Note that aio_read_events() is being called as the conditional
* in a wait_event() loop. When the conditional is satisfied,
* the loop exits; otherwise we wait until an event is reaped.
*/
if (!wait_event_interruptible_hrtimeout(ctx->wait,
aio_read_events(ctx, min_nr, nr, event, &ret), until))
return ret;

if (!ret && !time_after(jiffies, ctx->mmap_base))
ret = -EINTR;
return ret;
}

完成事件写入 io_event 结构:

1
2
3
4
5
6
struct io_event {
__u64 data; /* 用户设置的 aio_data,用于关联请求 */
__u64 obj; /* 指向原始 iocb 的指针 */
__s64 res; /* 操作结果(>=0 成功,<0 errno)*/
__s64 res2; /* 二级结果(如 preadv2 flags) */
};

3.4 Linux AIO 的局限性

Linux 内核 AIO 存在若干根本性限制,这也是 io_uring 诞生的主要动因:

  1. 只支持 O_DIRECT:对 Buffered IO 的 aio_read/aio_write 实际上并不是真正异步的——当页缓存缺页时会同步阻塞在工作队列线程里,只是把阻塞转移到了内核线程,并未消除。
  2. 每次 syscall 开销大io_submit 每次需要从用户空间拷贝 iocb 结构(每个 64 字节),无法利用共享内存避免拷贝。
  3. io_getevents 轮询开销:需要进入内核态才能获取完成事件。
  4. 不支持网络 IO:Linux AIO 仅适用于文件描述符,不支持 socket。
  5. 不支持 fsync(早期版本):无法异步地刷盘。

四、io_uring:现代异步 IO 框架

io_uring 由 Jens Axboe 在 2019 年引入(Linux 5.1),彻底解决了 Linux AIO 的局限性。其核心思路是通过共享内存环形队列实现用户态与内核态的零拷贝通信。

4.1 struct io_ring_ctx 核心上下文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/* io_uring/io_uring.c(简化) */
struct io_ring_ctx {
/* 不可变字段(初始化后不再修改) */
struct {
unsigned int flags;
unsigned int compat : 1;
unsigned int drain_next : 1;
unsigned int restricted : 1;
unsigned int off_timeout_used : 1;
unsigned int drain_active : 1;
} ____cacheline_aligned_in_smp;

/* 提交队列(SQ)相关 */
struct {
struct mutex uring_lock;
u32 *sq_array; /* SQ 索引数组(共享内存) */
struct io_uring_sqe *sq_sqes; /* SQE 环(共享内存) */
unsigned cached_sq_head;
unsigned sq_entries;
struct io_wq_work_list defer_list;
} ____cacheline_aligned_in_smp;

/* 完成队列(CQ)相关 */
struct {
unsigned cached_cq_tail;
unsigned cq_entries;
struct io_ev_fd __rcu *io_ev_fd; /* eventfd 通知 */
struct wait_queue_head cq_wait;
unsigned cq_extra;
} ____cacheline_aligned_in_smp;

struct io_rings *rings; /* 指向共享内存中的环头 */

/* 注册资源 */
struct io_rsrc_data *file_data; /* 注册文件表 */
struct io_rsrc_data *buf_data; /* 注册缓冲区表 */

/* SQPOLL 内核线程 */
struct io_sq_data *sq_data;

/* 工作队列 */
struct io_wq *io_wq;

/* 其他字段... */
};

io_ring_ctx 按缓存行对齐拆分,SQ 和 CQ 各占独立缓存行,避免多核并发时的伪共享(false sharing)。

4.2 SQE 与 CQE 共享内存设计

io_uring 的精髓在于:内核与用户空间共享同一块物理内存,通过生产者-消费者模型通信,无需系统调用即可提交和收割大量 IO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
用户空间                          内核空间
┌──────────────────────────────────────────────┐
│ 共享内存(mmap) │
│ │
│ SQ Ring CQ Ring │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ head (R:内核)│ │ head (R:用户)│ │
│ │ tail (W:用户)│ │ tail (W:内核)│ │
│ │ sq_array[] │ │ cqes[] │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ SQE Array(独立 mmap) │
│ ┌────┬────┬────┬────┐ │
│ │SQE0│SQE1│SQE2│SQE3│ ← 用户填写 │
│ └────┴────┴────┴────┘ │
└──────────────────────────────────────────────┘

SQE(Submission Queue Entry)结构,每个 64 字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
struct io_uring_sqe {
__u8 opcode; /* IORING_OP_READ/WRITE/... */
__u8 flags; /* IOSQE_* 标志 */
__u16 ioprio;
__s32 fd; /* 目标文件描述符(或注册文件索引) */
union {
__u64 off; /* 偏移量(pread/pwrite 用) */
__u64 addr2;
};
union {
__u64 addr; /* 缓冲区地址(或注册缓冲区索引) */
__u64 splice_off_in;
};
__u32 len; /* 缓冲区长度 */
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
__u16 poll_events;
__u32 poll32_events;
__u32 sync_range_flags;
__u32 msg_flags;
__u32 timeout_flags;
__u32 accept_flags;
__u32 cancel_flags;
__u32 open_flags;
__u32 statx_flags;
__u32 fadvise_advice;
__u32 splice_flags;
__u32 rename_flags;
__u32 unlink_flags;
__u32 hardlink_flags;
__u32 xattr_flags;
__u32 msg_ring_flags;
__u32 uring_cmd_flags;
};
__u64 user_data; /* 用户自定义标识,原样返回 CQE */
union {
__u16 buf_index; /* 注册缓冲区索引 */
__u16 buf_group;
} __attribute__((packed));
__u16 personality;
union {
__s32 splice_fd_in;
__u32 file_index;
__u32 optlen;
struct {
__u16 addr_len;
__u16 __pad3[1];
};
};
/* ... */
};

CQE(Completion Queue Entry)结构,每个 16 字节:

1
2
3
4
5
struct io_uring_cqe {
__u64 user_data; /* 对应 SQE 的 user_data */
__s32 res; /* 操作结果 */
__u32 flags; /* IORING_CQE_F_* */
};

4.3 io_uring_setup() 与初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/* io_uring/io_uring.c */
static struct io_ring_ctx *io_ring_ctx_alloc(struct io_uring_params *p)
{
struct io_ring_ctx *ctx;
int hash_bits;

ctx = kzalloc(sizeof(*ctx), GFP_KERNEL);
if (!ctx)
return NULL;

xa_init(&ctx->io_bl_xa);

hash_bits = ilog2(p->cq_entries);
hash_bits = clamp(hash_bits, 1, 8);
ctx->cancel_table.hbs = hash_bits;
ctx->cancel_table.hash = kcalloc(1U << hash_bits,
sizeof(struct hlist_head), GFP_KERNEL);
if (!ctx->cancel_table.hash)
goto err;

ctx->cancel_table_locked.hbs = hash_bits;
ctx->cancel_table_locked.hash = kcalloc(1U << hash_bits,
sizeof(struct hlist_head), GFP_KERNEL);
if (!ctx->cancel_table_locked.hash)
goto err;

if (percpu_ref_init(&ctx->refs, io_ring_ctx_ref_free,
0, GFP_KERNEL))
goto err;

ctx->flags = p->flags;
init_waitqueue_head(&ctx->sqo_sq_wait);
INIT_LIST_HEAD(&ctx->sqd_list);
init_waitqueue_head(&ctx->poll_wait);
INIT_LIST_HEAD(&ctx->cq_overflow_list);
/* ... 更多初始化 ... */
return ctx;
err:
io_ring_ctx_free(ctx);
return NULL;
}

4.4 IORING_SETUP_SQPOLL 内核轮询线程模式

开启 IORING_SETUP_SQPOLL 后,内核创建一个内核线程(io_sq_thread)持续轮询 SQ 环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* io_uring/sqpoll.c(简化) */
static int io_sq_thread(void *data)
{
struct io_sq_data *sqd = data;
struct io_ring_ctx *ctx;
unsigned long timeout = 0;
char buf[TASK_COMM_LEN];
DEFINE_WAIT(wait);

snprintf(buf, sizeof(buf), "iou-sqp-%d", sqd->task_pid);
set_task_comm(current, buf);

while (!test_bit(IO_SQ_THREAD_SHOULD_STOP, &sqd->state)) {
int ret;
bool cap_entries, sqt_spin = false;

/* 轮询所有关联的 ring ctx */
list_for_each_entry(ctx, &sqd->ctx_list, sqd_list) {
if (io_sq_have_work(ctx))
sqt_spin |= io_sq_thread_handle_c(ctx, &cap_entries);
}

if (sqt_spin || !time_after(jiffies, timeout)) {
io_run_task_work();
cond_resched();
if (sqt_spin)
timeout = jiffies + sqd->sq_thread_idle;
continue;
}

/* 超过 idle 时间,进入睡眠等待新任务 */
prepare_to_wait(&sqd->wait, &wait, TASK_INTERRUPTIBLE);
/* ... */
}
return 0;
}

SQPOLL 模式下,应用提交 SQE 只需写共享内存,内核线程自动发现并处理,完全零系统调用。这对高 IOPS 场景(NVMe SSD,百万级 IOPS)效果显著,系统调用开销本身可能成为瓶颈。

4.5 固定缓冲区与固定文件

注册固定资源可以避免每次 IO 时重复的 get_user_pages(锁定内存页)和 fget(引用计数)开销:

1
2
3
4
5
6
7
8
9
10
11
/* 注册固定缓冲区 */
struct iovec iov[2];
iov[0].iov_base = buf0;
iov[0].iov_len = BUF_SIZE;
iov[1].iov_base = buf1;
iov[1].iov_len = BUF_SIZE;
io_uring_register(ring_fd, IORING_REGISTER_BUFFERS, iov, 2);

/* 使用固定缓冲区时,SQE 中用 buf_index 代替地址 */
sqe->opcode = IORING_OP_READ_FIXED;
sqe->buf_index = 0; /* 使用第 0 个注册缓冲区 */

注册文件(IORING_REGISTER_FILES)类似,将 fd 数组预先注册,SQE 中 flags |= IOSQE_FIXED_FILEfd 字段为数组索引而非真实 fd,绕过每次 fget/fput 的引用计数操作。

4.6 链式请求(IOSQE_IO_LINK)

io_uring 支持将多个 SQE 链接为有序序列,前一个完成后才提交下一个:

1
2
3
4
5
6
7
8
9
10
11
12
/* 读取文件 → 处理 → 写入另一个文件,串行执行 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, src_fd, buf, len, 0);
sqe->flags |= IOSQE_IO_LINK; /* 链接到下一个 SQE */
sqe->user_data = 1;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, dst_fd, buf, len, 0);
sqe->user_data = 2; /* 最后一个不设 IOSQE_IO_LINK */

io_uring_submit(&ring);
/* 内核保证先执行 read,read 完成后才执行 write */

链式请求中任意一步失败,后续步骤会以 -ECANCELED 取消,类似事务语义。

4.7 用户态使用示例(liburing)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <liburing.h>
#include <fcntl.h>
#include <string.h>

#define QUEUE_DEPTH 64
#define BLOCK_SZ 4096

int main(void)
{
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
char buf[BLOCK_SZ];
int fd, ret;

/* 初始化 io_uring,深度为 64 */
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);

fd = open("/tmp/testfile", O_RDONLY | O_DIRECT);

/* 获取 SQE 并填写读请求 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, BLOCK_SZ, 0);
sqe->user_data = 42; /* 自定义标识 */

/* 提交(写 SQ tail,触发内核处理) */
io_uring_submit(&ring);

/* 等待完成事件 */
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret == 0 && cqe->res > 0) {
printf("读取 %d 字节,user_data=%llu\n",
cqe->res, (unsigned long long)cqe->user_data);
}

/* 标记 CQE 已消费(推进 CQ head) */
io_uring_cqe_seen(&ring, cqe);

io_uring_queue_exit(&ring);
close(fd);
return 0;
}

五、性能对比与调优

5.1 Direct IO vs Buffered IO 适用场景

维度 Buffered IO Direct IO
缓存效果 热数据命中后接近内存速度 无缓存,每次都落盘
内存占用 页缓存占用物理内存 仅用户缓冲区
适用场景 随机小读写、反复访问相同数据 数据库 Buffer Pool、流式大文件读写
对齐要求 严格(512B 或 4096B)
写安全性 fsync 保证持久化 数据直达磁盘(仍需考虑磁盘缓存)
顺序读写吞吐 接近(预读机制补偿) 略低(无预读,需自行管理)

推荐使用 Direct IO 的典型场景

  • 数据库引擎(PostgreSQL 的 effective_io_concurrency,MySQL InnoDB 的 innodb_flush_method=O_DIRECT
  • 视频转码、备份等大文件单次流式读写,防止污染页缓存
  • 实时数据采集,对时延有精确要求

5.2 AIO vs io_uring 性能对比

Linux AIO 和 io_uring 在不同场景下的典型性能差异(参考 Jens Axboe 的 benchmark 数据):

指标 Linux AIO io_uring(默认) io_uring(SQPOLL)
最大 IOPS(4K 随机读,NVMe) ~600K ~800K ~1200K+
每 IO 系统调用次数 2(submit+getevents) 1(submit,collect 可批量) 0(SQPOLL 模式)
支持 Buffered IO 伪异步 真异步 真异步
支持网络 IO 是(recv/send/accept)
支持 fsync 否(早期)
固定缓冲区

5.3 fio 测试命令示例

测试 Buffered IO 顺序写吞吐量

1
2
3
4
5
6
fio --name=buffered-seq-write \
--rw=write --bs=1M --size=4G \
--numjobs=4 --iodepth=1 \
--ioengine=sync \
--filename=/data/testfile \
--group_reporting

测试 Direct IO 随机读 IOPS

1
2
3
4
5
6
fio --name=direct-rand-read \
--rw=randread --bs=4k --size=4G \
--numjobs=4 --iodepth=32 \
--ioengine=libaio --direct=1 \
--filename=/data/testfile \
--group_reporting

测试 io_uring 随机读写(混合 70/30)

1
2
3
4
5
6
7
8
9
10
11
fio --name=io-uring-mixed \
--rw=randrw --rwmixread=70 \
--bs=4k --size=4G \
--numjobs=4 --iodepth=128 \
--ioengine=io_uring \
--hipri=1 \ # 使用 polling 模式
--sqthread_poll=1 \ # 开启 SQPOLL
--registerfiles=1 \ # 注册文件
--fixedbufs=1 \ # 注册缓冲区
--filename=/dev/nvme0n1 \ # 直接测试裸设备
--group_reporting

解读关键指标

  • IOPS:每秒完成的 IO 操作次数,评估随机 IO 能力
  • BW:带宽(MB/s),评估顺序 IO 吞吐
  • lat (usec):延迟,avg 是平均值,99.00th 是 P99 尾延迟——对数据库场景尤为重要
  • clat:完成延迟(Completion Latency),从 IO 提交到完成的时间

5.4 调优建议

内核参数

1
2
3
4
5
6
7
8
# 提升 AIO 最大并发请求数(默认 65536)
echo 1048576 > /proc/sys/fs/aio-max-nr

# 调整调度器为 none(NVMe SSD 不需要 IO 调度器)
echo none > /sys/block/nvme0n1/queue/scheduler

# 增大设备队列深度
echo 256 > /sys/block/nvme0n1/queue/nr_requests

io_uring SQPOLL 注意事项:SQPOLL 内核线程会绑定在特定 CPU 上持续运行,适合专用 IO 服务器。混合负载场景下应通过 IORING_SETUP_SQ_AFF 绑定隔离的 CPU core,避免争抢业务线程的 CPU。


总结

本文系统梳理了 Linux IO 模型的演进脉络:从传统的同步阻塞 IO,到 Linux AIO 尝试异步化(但受制于 O_DIRECT 限制),再到 io_uring 通过共享内存环形队列实现真正的高性能零开销异步 IO 框架。

Direct IO 解决了数据库等场景的双重缓冲问题,而 io_uring 则从根本上消除了异步 IO 的系统调用开销,并将异步能力从文件扩展到网络、定时器、进程管理等几乎所有内核操作。理解这些机制的实现细节,是构建高性能存储系统的必要基础。

下一篇将深入探讨 Linux 文件系统的 VFS 层设计,以及 ext4、XFS 等具体文件系统的日志机制(Journaling)实现。

IO 调度器(I/O Scheduler)是 Linux 块层中承上启下的核心组件:它介于文件系统/虚拟内存子系统发出的 bio 请求与底层硬件驱动之间,负责对请求进行排序、合并、仲裁,以求在吞吐量、延迟、公平性三个维度上达到系统预期的平衡点。本文基于 Linux 6.4-rc1 内核源码,深入剖析 mq-deadline、BFQ 和 Kyber 三个现代调度器的设计思想与核心实现。

Read more »

前言

在过去几十年间,Linux 生态系统中的文件系统经历了从 ext2 到 ext4、从 XFS 到 ZFS 的漫长演化。Btrfs(B-Tree File System,发音为 “Butter FS” 或 “Better FS”)是 Oracle 在 2007 年主导开发的下一代写时复制(Copy-on-Write,CoW)文件系统,旨在填补 Linux 原生高级文件系统的空白。它于 2009 年合并进 Linux 主线内核(2.6.29),经过十余年的持续发展,已经成为 SUSE Linux Enterprise Server 的默认文件系统,也是 Fedora 33 之后的默认选择。

本文基于 Linux 6.4-rc1 内核源码(路径:fs/btrfs/)进行深度剖析,目标是从内核数据结构和核心算法层面理解 Btrfs 的工作原理。


一、设计哲学:以 CoW 和 B-Tree 为基石

1.1 写时复制(Copy-on-Write)

CoW 是 Btrfs 的灵魂。传统文件系统(如 ext4)在修改数据时采用”就地写入”(in-place update)策略:直接覆盖原有块。这种方式在掉电或崩溃时极易导致数据不一致,需要 journal(日志)来补救。

Btrfs 的策略截然不同:任何修改都不覆盖原有数据块,而是在新位置写入,再更新指针。这带来了以下天然优势:

  • 崩溃一致性:旧数据始终有效,新数据写完才更新超级块中的根指针,天然原子。
  • 快照成本为零:快照只是增加了对现有树节点的引用计数,不需要复制数据。
  • 数据完整性:每次写入都产生新块,可在写入时顺便计算校验和。

1.2 B-Tree 无处不在

Btrfs 用一棵 B-Tree 来存储所有文件系统元数据,包括目录项、inode、文件 extent、空闲空间、设备信息等。B-Tree 的键是一个三元组 (objectid, type, offset),这个统一的键空间让 Btrfs 能够将所有元数据组织在同一套搜索逻辑下,极大简化了代码复杂度。

与传统 B+ 树不同,Btrfs 的树节点分为两类:

  • 内部节点(Node):只存储键和指向子节点的物理地址指针。
  • 叶子节点(Leaf):存储实际的元数据项(item),每个 item 包含键和可变长度的数据。

二、核心数据结构

2.1 键(Key):统一的寻址空间

Btrfs 中所有数据都通过一个三元组键来定位,内核中有两种表示形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// include/uapi/linux/btrfs_tree.h

/*
* btrfs_disk_key is in disk byte order. struct btrfs_key is always
* in cpu native order. Otherwise they are identical and their sizes
* should be the same (ie both packed)
*/
struct btrfs_disk_key {
__le64 objectid;
__u8 type;
__le64 offset;
} __attribute__ ((__packed__));

struct btrfs_key {
__u64 objectid;
__u8 type;
__u64 offset;
} __attribute__ ((__packed__));

btrfs_disk_key 用于磁盘存储(小端序),btrfs_key 用于内存操作(CPU 原生序)。这一区分避免了频繁的字节序转换开销,内核在 ctree.c 中也针对小端架构做了优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// fs/btrfs/ctree.c

#ifdef __LITTLE_ENDIAN
/*
* Compare two keys, on little-endian the disk order is same as CPU order and
* we can avoid the conversion.
*/
static int comp_keys(const struct btrfs_disk_key *disk_key,
const struct btrfs_key *k2)
{
const struct btrfs_key *k1 = (const struct btrfs_key *)disk_key;
return btrfs_comp_cpu_keys(k1, k2);
}
#endif

int __pure btrfs_comp_cpu_keys(const struct btrfs_key *k1, const struct btrfs_key *k2)
{
if (k1->objectid > k2->objectid)
return 1;
if (k1->objectid < k2->objectid)
return -1;
if (k1->type > k2->type)
return 1;
if (k1->type < k2->type)
return -1;
if (k1->offset > k2->offset)
return 1;
if (k1->offset < k2->offset)
return -1;
return 0;
}

键的比较先按 objectid,再按 type,最后按 offset,三级排序确保所有同类型数据在 B-Tree 中聚集存放,提升访问局部性。

2.2 树节点:Leaf 与 Node

叶子节点(Leaf)和内部节点(Node)共享同一个头部结构 btrfs_header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// include/uapi/linux/btrfs_tree.h

/*
* Every tree block (leaf or node) starts with this header.
*/
struct btrfs_header {
/* These first four must match the super block */
__u8 csum[BTRFS_CSUM_SIZE];
/* FS specific uuid */
__u8 fsid[BTRFS_FSID_SIZE];
/* Which block this node is supposed to live in */
__le64 bytenr;
__le64 flags;

/* Allowed to be different from the super from here on down */
__u8 chunk_tree_uuid[BTRFS_UUID_SIZE];
__le64 generation;
__le64 owner;
__le32 nritems;
__u8 level;
} __attribute__ ((__packed__));

注意头部开头的 csum 字段:每一个树块都有自己的校验和,这是 Btrfs 数据完整性的基础。level 字段为 0 表示叶子节点,大于 0 表示内部节点。

叶子节点存储实际的 item,其布局是”两端向中间生长”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// include/uapi/linux/btrfs_tree.h

/*
* A leaf is full of items. offset and size tell us where to find the item in
* the leaf (relative to the start of the data area)
*/
struct btrfs_item {
struct btrfs_disk_key key;
__le32 offset;
__le32 size;
} __attribute__ ((__packed__));

/*
* Leaves have an item area and a data area:
* [item0, item1....itemN] [free space] [dataN...data1, data0]
*
* The data is separate from the items to get the keys closer together during
* searches.
*/
struct btrfs_leaf {
struct btrfs_header header;
struct btrfs_item items[];
} __attribute__ ((__packed__));

item 数组从叶子头部向后增长,而 item 对应的变长数据从尾部向前增长,两者在中间汇聚。这种布局使得 item 的键(8+1+8=17 字节)紧密排列在叶子开头,二分查找时的缓存命中率极高。

内部节点只存储键指针对(Key-Pointer Pair):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// include/uapi/linux/btrfs_tree.h

/*
* All non-leaf blocks are nodes, they hold only keys and pointers to other
* blocks.
*/
struct btrfs_key_ptr {
struct btrfs_disk_key key;
__le64 blockptr;
__le64 generation;
} __attribute__ ((__packed__));

struct btrfs_node {
struct btrfs_header header;
struct btrfs_key_ptr ptrs[];
} __attribute__ ((__packed__));

blockptr 是子节点的逻辑字节地址,generation 记录子节点最后一次被修改时所在的事务 ID,用于 CoW 判断和校验。

2.3 根(Root):每棵树的入口

每棵 B-Tree 对应一个 btrfs_root 结构,它是内存中对树的抽象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// fs/btrfs/ctree.h

/*
* in ram representation of the tree. extent_root is used for all allocations
* and for the extent tree extent_root root.
*/
struct btrfs_root {
struct rb_node rb_node;

struct extent_buffer *node; /* 当前根节点(写操作用) */
struct extent_buffer *commit_root; /* 已提交的根节点(读操作用) */
struct btrfs_root *log_root;
struct btrfs_root *reloc_root;

unsigned long state;
struct btrfs_root_item root_item;
struct btrfs_key root_key;
struct btrfs_fs_info *fs_info;

spinlock_t inode_lock;
struct rb_root inode_tree; /* 内存中 inode 的红黑树 */

/* red-black tree that keeps track of in-memory inodes */
struct radix_tree_root delayed_nodes_tree;

refcount_t refs;
/* ... 以及大量的锁、列表、计数器 ... */
};

nodecommit_root 的区别是 Btrfs 事务机制的关键:在事务提交完成之前,读操作使用 commit_root(稳定视图),写操作使用 node(当前最新版本)。

2.4 超级结构:btrfs_fs_info

btrfs_fs_info 是整个文件系统实例的核心控制块,包含了所有重要子系统的句柄:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// fs/btrfs/fs.h

struct btrfs_fs_info {
u8 chunk_tree_uuid[BTRFS_UUID_SIZE];
unsigned long flags;
struct btrfs_root *tree_root; /* 存放其他树根的"根之树" */
struct btrfs_root *chunk_root; /* 物理块组管理树 */
struct btrfs_root *dev_root; /* 设备信息树 */
struct btrfs_root *fs_root; /* 默认子卷树 */
struct btrfs_root *quota_root;
struct btrfs_root *uuid_root;
struct btrfs_root *data_reloc_root;
struct btrfs_root *block_group_root;

/* The log root tree is a directory of all the other log roots */
struct btrfs_root *log_root_tree;

/* The tree that holds the global roots (csum, extent, etc) */
rwlock_t global_root_lock;
struct rb_root global_root_tree;

spinlock_t trans_lock;
struct list_head trans_list;
struct btrfs_transaction *running_transaction;

u64 generation;
u64 last_trans_committed;

unsigned long compress_type:4;
unsigned int compress_level;
u32 commit_interval;
/* ... */
};

tree_root 是”树中之树”(Tree of Trees),它的每一个 item 记录了一棵子卷树或系统树的根节点位置。Btrfs 通过这种递归结构实现了几乎无限数量的子卷/快照管理。

2.5 路径:btrfs_path

在 B-Tree 中搜索时,需要记录从根到叶子的完整路径,以便后续插入、删除时能直接在路径上操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// fs/btrfs/ctree.h

/*
* btrfs_paths remember the path taken from the root down to the leaf.
* level 0 is always the leaf, and nodes[1...BTRFS_MAX_LEVEL] will point
* to any other levels that are present.
*
* The slots array records the index of the item or block pointer
* used while walking the tree.
*/
struct btrfs_path {
struct extent_buffer *nodes[BTRFS_MAX_LEVEL];
int slots[BTRFS_MAX_LEVEL];
u8 locks[BTRFS_MAX_LEVEL];
u8 reada;
u8 lowest_level;

unsigned int search_for_split:1;
unsigned int keep_locks:1;
unsigned int skip_locking:1;
unsigned int search_commit_root:1;
unsigned int need_commit_sem:1;
unsigned int skip_release_on_error:1;
unsigned int search_for_extension:1;
unsigned int nowait:1;
};

nodes[0] 是叶子节点,nodes[1]nodes[2] 等是对应层级的内部节点。slots[i] 记录在第 i 层节点中选中的槽位序号。locks[i] 记录各层持有的锁类型(读锁或写锁)。


三、B-Tree 核心操作

3.1 查找:btrfs_search_slot

btrfs_search_slot 是 Btrfs 中最核心的函数,几乎所有对文件系统的读写操作最终都通过它来定位数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// fs/btrfs/ctree.c

/*
* btrfs_search_slot - look for a key in a tree and perform necessary
* modifications to preserve tree invariants.
*
* @trans: Handle of transaction, used when modifying the tree
* @p: Holds all btree nodes along the search path
* @root: The root node of the tree
* @key: The key we are looking for
* @ins_len: Indicates purpose of search:
* >0 for inserts it's size of item inserted
* <0 for deletions
* 0 for plain searches, not modifying the tree
* @cow: boolean should CoW operations be performed.
*
* If @key is found, 0 is returned and you can find the item in the leaf level
* of the path (level 0)
*
* If @key isn't found, 1 is returned and the leaf level of the path (level 0)
* points to the slot where it should be inserted
*/
int btrfs_search_slot(struct btrfs_trans_handle *trans, struct btrfs_root *root,
const struct btrfs_key *key, struct btrfs_path *p,
int ins_len, int cow)

函数的主循环从根节点开始,逐层向下,每层通过 btrfs_bin_search 做二分查找定位子节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// fs/btrfs/ctree.c

int btrfs_bin_search(struct extent_buffer *eb, int first_slot,
const struct btrfs_key *key, int *slot)
{
unsigned long p;
int item_size;
u32 low = first_slot;
u32 high = btrfs_header_nritems(eb);
int ret;
const int key_size = sizeof(struct btrfs_disk_key);

if (btrfs_header_level(eb) == 0) {
p = offsetof(struct btrfs_leaf, items);
item_size = sizeof(struct btrfs_item);
} else {
p = offsetof(struct btrfs_node, ptrs);
item_size = sizeof(struct btrfs_key_ptr);
}

while (low < high) {
unsigned long oip;
unsigned long offset;
struct btrfs_disk_key *tmp;
struct btrfs_disk_key unaligned;
int mid;

mid = (low + high) / 2;
offset = p + mid * item_size;
oip = offset_in_page(offset);

if (oip + key_size <= PAGE_SIZE) {
const unsigned long idx = get_eb_page_index(offset);
char *kaddr = page_address(eb->pages[idx]);
oip = get_eb_offset_in_page(eb, offset);
tmp = (struct btrfs_disk_key *)(kaddr + oip);
} else {
read_extent_buffer(eb, &unaligned, offset, key_size);
tmp = &unaligned;
}

ret = comp_keys(tmp, key);
if (ret < 0)
low = mid + 1;
else if (ret > 0)
high = mid;
else {
*slot = mid;
return 0;
}
}
*slot = low;
return 1;
}

值得注意的是,这里有一个微优化:优先尝试直接访问 extent buffer 的 page(避免跨页拷贝),只有当键跨越了页边界时才用 read_extent_buffer 拷贝到临时变量。

在写操作时(cow=1),btrfs_search_slot 在下行过程中遇到需要修改的节点,会调用 btrfs_cow_block 进行 CoW 处理,并对不再需要的层级释放锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// fs/btrfs/ctree.c (btrfs_search_slot 主循环简化版)

while (b) {
level = btrfs_header_level(b);

if (cow) {
if (!should_cow_block(trans, root, b))
goto cow_done;
/* ... 确保持有足够级别的写锁 ... */
err = btrfs_cow_block(trans, root, b,
p->nodes[level + 1],
p->slots[level + 1], &b,
BTRFS_NESTING_COW);
if (err) {
ret = err;
goto done;
}
}
cow_done:
p->nodes[level] = b;
/* ... 向下一层走 ... */
}

3.2 节点的分裂与合并

当向叶子节点插入 item 时,若叶子空间不足,split_leaf 会创建一个新叶子并将一半 item 迁移过去(同时触发 CoW)。删除时,若节点中的 item 数量低于阈值,balance_level 会尝试从相邻节点借 item 或合并节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// fs/btrfs/ctree.c

/*
* node level balancing, used to make sure nodes are in proper order for
* item deletion. We balance from the top down, so we have to make sure
* that a deletion won't leave a node completely empty later on.
*/
static noinline int balance_level(struct btrfs_trans_handle *trans,
struct btrfs_root *root,
struct btrfs_path *path, int level)
{
/* ... 尝试从左/右兄弟节点移入 item,若节点为空则释放 ... */
if (btrfs_header_nritems(mid) == 1) {
/*
* we're not allowed to leave a node with one item in the
* tree during a delete. A deletion from lower in the tree
* could try to delete the only pointer in this node.
* So, pull some keys from the left.
*/
wret = balance_node_right(trans, mid, left);
}
if (btrfs_header_nritems(mid) == 0) {
btrfs_clear_buffer_dirty(trans, mid);
btrfs_tree_unlock(mid);
del_ptr(root, path, level + 1, pslot);
root_sub_used(root, mid->len);
btrfs_free_tree_block(trans, btrfs_root_id(root), mid, 0, 1);
}
/* ... */
}

四、写时复制(CoW)机制详解

4.1 何时需要 CoW

should_cow_block 函数决定一个树块是否需要被 CoW:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// fs/btrfs/ctree.c

static inline int should_cow_block(struct btrfs_trans_handle *trans,
struct btrfs_root *root,
struct extent_buffer *buf)
{
if (btrfs_is_testing(root->fs_info))
return 0;

/* Ensure we can see the FORCE_COW bit */
smp_mb__before_atomic();

/*
* We do not need to cow a block if
* 1) this block is not created or changed in this transaction;
* 2) this block does not belong to TREE_RELOC tree;
* 3) the root is not forced COW.
*
* What is forced COW:
* when we create snapshot during committing the transaction,
* after we've finished copying src root, we must COW the shared
* block to ensure the metadata consistency.
*/
if (btrfs_header_generation(buf) == trans->transid &&
!btrfs_header_flag(buf, BTRFS_HEADER_FLAG_WRITTEN) &&
!(root->root_key.objectid != BTRFS_TREE_RELOC_OBJECTID &&
btrfs_header_flag(buf, BTRFS_HEADER_FLAG_RELOC)) &&
!test_bit(BTRFS_ROOT_FORCE_COW, &root->state))
return 0;
return 1;
}

核心逻辑:如果一个块已经在当前事务中被分配或修改(generation == trans->transid),且还没有被写回磁盘(!WRITTEN),则不需要 CoW,可以直接修改。否则必须 CoW。

这个优化极为重要:同一事务内对同一块的多次修改不会产生多余的 CoW 开销。

4.2 CoW 的实现:__btrfs_cow_block

实际的 CoW 工作由 __btrfs_cow_block 完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// fs/btrfs/ctree.c

/*
* does the dirty work in cow of a single block. The parent block (if
* supplied) is updated to point to the new cow copy. The new buffer is marked
* dirty and returned locked.
*/
static noinline int __btrfs_cow_block(struct btrfs_trans_handle *trans,
struct btrfs_root *root,
struct extent_buffer *buf,
struct extent_buffer *parent, int parent_slot,
struct extent_buffer **cow_ret,
u64 search_start, u64 empty_size,
enum btrfs_lock_nesting nest)
{
/* ... */
level = btrfs_header_level(buf);

/* 分配一个新树块 */
cow = btrfs_alloc_tree_block(trans, root, parent_start,
root->root_key.objectid, &disk_key, level,
search_start, empty_size, nest);

/* 将旧块内容完整复制到新块 */
copy_extent_buffer_full(cow, buf);

/* 更新新块的头部元数据 */
btrfs_set_header_bytenr(cow, cow->start);
btrfs_set_header_generation(cow, trans->transid); /* 标记为当前事务 */
btrfs_set_header_backref_rev(cow, BTRFS_MIXED_BACKREF_REV);
btrfs_clear_header_flag(cow, BTRFS_HEADER_FLAG_WRITTEN |
BTRFS_HEADER_FLAG_RELOC);
btrfs_set_header_owner(cow, root->root_key.objectid);

/* 更新反向引用,处理共享块的引用计数 */
ret = update_ref_for_cow(trans, root, buf, cow, &last_ref);

if (buf == root->node) {
/* 如果 CoW 的是根节点,更新 root->node 指针 */
rcu_assign_pointer(root->node, cow);
btrfs_free_tree_block(trans, btrfs_root_id(root), buf,
parent_start, last_ref);
} else {
/* 否则更新父节点中的指针 */
btrfs_set_node_blockptr(parent, parent_slot, cow->start);
btrfs_set_node_ptr_generation(parent, parent_slot, trans->transid);
btrfs_mark_buffer_dirty(parent);
btrfs_free_tree_block(trans, btrfs_root_id(root), buf,
parent_start, last_ref);
}

btrfs_mark_buffer_dirty(cow);
*cow_ret = cow;
return 0;
}

这段代码展示了 CoW 的完整流程:

  1. 分配新块(btrfs_alloc_tree_block
  2. 复制旧块内容(copy_extent_buffer_full
  3. 更新新块头部,将 generation 设为当前事务 ID
  4. 更新引用计数(旧块引用减少,新块引用增加)
  5. 将父节点中指向旧块的指针改为指向新块
  6. 释放旧块(可能是真正释放,也可能只是解除当前根的引用)

4.3 共享块的引用计数

当多棵树(如快照和原始子卷)共享同一个树块时,btrfs_block_can_be_shared 会检测到这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// fs/btrfs/ctree.c

/*
* check if the tree block can be shared by multiple trees
*/
int btrfs_block_can_be_shared(struct btrfs_root *root,
struct extent_buffer *buf)
{
/*
* Tree blocks not in shareable trees and tree roots are never shared.
* If a block was allocated after the last snapshot and the block was
* not allocated by tree relocation, we know the block is not shared.
*/
if (test_bit(BTRFS_ROOT_SHAREABLE, &root->state) &&
buf != root->node && buf != root->commit_root &&
(btrfs_header_generation(buf) <=
btrfs_root_last_snapshot(&root->root_item) ||
btrfs_header_flag(buf, BTRFS_HEADER_FLAG_RELOC)))
return 1;
return 0;
}

如果一个树块的 generation 小于等于 last_snapshot(最后一次快照时的事务 ID),说明它可能被某个快照引用,必须走完整的引用计数路径。


五、快照与子卷(Snapshot / Subvolume)

5.1 子卷:独立的文件系统树

Btrfs 的子卷(Subvolume)是一棵独立的 B-Tree,有自己的根节点,可以像独立文件系统一样被挂载。每个子卷在 tree_root 中有对应的 btrfs_root_item,通过唯一的 objectid 标识。

快照(Snapshot)本质上是对某个子卷树根节点的浅拷贝:两者共享所有树块,通过引用计数追踪共享关系。写时复制保证修改任何一方都不会影响另一方。

5.2 快照创建的内核实现

快照创建发生在事务提交阶段,由 create_pending_snapshot 完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// fs/btrfs/transaction.c

static noinline int create_pending_snapshot(struct btrfs_trans_handle *trans,
struct btrfs_pending_snapshot *pending)
{
/* ... */
/* 为新快照分配一个新的 objectid */
pending->error = btrfs_get_free_objectid(tree_root, &objectid);

/* 记录源子卷的最后快照事务 ID */
btrfs_set_root_last_snapshot(&root->root_item, trans->transid);
memcpy(new_root_item, &root->root_item, sizeof(*new_root_item));

/* 对源子卷的根节点做一次 CoW,确保后续修改都走 CoW 路径 */
old = btrfs_lock_root_node(root);
ret = btrfs_cow_block(trans, root, old, NULL, 0, &old,
BTRFS_NESTING_COW);

/* 将 CoW 后的根节点复制一份,作为快照的根 */
ret = btrfs_copy_root(trans, root, old, &tmp, objectid);

/* 设置 FORCE_COW 标志,后续对源子卷的任何修改都必须 CoW */
set_bit(BTRFS_ROOT_FORCE_COW, &root->state);
smp_wmb();

/* 将快照的根节点写入 tree_root */
btrfs_set_root_node(new_root_item, tmp);
key.offset = trans->transid;
ret = btrfs_insert_root(trans, tree_root, &key, new_root_item);

/* 添加目录项,使快照可以通过文件系统路径访问 */
ret = btrfs_add_root_ref(trans, objectid,
parent_root->root_key.objectid,
btrfs_ino(BTRFS_I(parent_inode)), index,
&fname.disk_name);
/* ... */
}

这段代码揭示了快照创建的精髓:整个操作代价几乎为零,只是:

  1. 给源子卷根节点做一次 CoW(保证后续修改独立)
  2. 复制根节点作为快照根(btrfs_copy_root
  3. 在 tree_root 中插入一条记录

不涉及任何数据块的复制,无论子卷有多大,快照的创建时间都是 O(1)。

5.3 btrfs_pending_snapshot 结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// fs/btrfs/transaction.h

struct btrfs_pending_snapshot {
struct dentry *dentry;
struct inode *dir;
struct btrfs_root *root;
struct btrfs_root_item *root_item;
struct btrfs_root *snap;
struct btrfs_qgroup_inherit *inherit;
struct btrfs_path *path;
struct btrfs_block_rsv block_rsv;
int error;
dev_t anon_dev;
bool readonly;
struct list_head list;
};

快照请求被挂到当前事务的 pending_snapshots 链表上,在事务提交时统一处理。这保证了快照创建的原子性。


六、数据校验和(Checksum)机制

6.1 支持的校验和算法

Btrfs 支持四种校验和算法,在 ctree.c 中以静态数组定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
// fs/btrfs/ctree.c

static const struct btrfs_csums {
u16 size;
const char name[10];
const char driver[12];
} btrfs_csums[] = {
[BTRFS_CSUM_TYPE_CRC32] = { .size = 4, .name = "crc32c" },
[BTRFS_CSUM_TYPE_XXHASH] = { .size = 8, .name = "xxhash64" },
[BTRFS_CSUM_TYPE_SHA256] = { .size = 32, .name = "sha256" },
[BTRFS_CSUM_TYPE_BLAKE2] = { .size = 32, .name = "blake2b",
.driver = "blake2b-256" },
};
算法 长度 特点
CRC32c 4 字节 默认算法,硬件加速,速度最快
xxHash64 8 字节 非加密哈希,速度极快
SHA256 32 字节 加密哈希,安全性高
BLAKE2b-256 32 字节 加密哈希,兼顾速度与安全

校验和覆盖:

  • 元数据:每个树块(leaf 或 node)的 btrfs_header 中的 csum 字段覆盖整个块。
  • 数据块:文件数据的校验和存储在 checksum tree(csum tree)中,以 (inode, offset) 为键索引。

6.2 元数据树块的校验

每个树块在读入内存时(btree_read_folio_end_io_hook)和写出磁盘前都会验证校验和。如果校验失败,内核会报告 I/O 错误并拒绝使用该块,结合 RAID 功能可以自动从副本恢复。


七、事务机制

7.1 事务状态机

Btrfs 的事务系统是保障崩溃一致性的核心。在 transaction.c 的注释中,完整描述了事务的状态转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
No running transaction
|
V
Transaction N [TRANS_STATE_RUNNING]
| 新事务,所有写操作都可以加入
V
Transaction N [TRANS_STATE_COMMIT_START]
| 某个调用者触发了 commit,等待所有写者退出
V
Transaction N [TRANS_STATE_COMMIT_DOING]
| 执行重量级工作:运行 delayed refs,创建 pending snapshots,更新 qgroups
V
Transaction N [TRANS_STATE_UNBLOCKED]
| 所有树已修改完毕,开始将脏页写回磁盘
| 此时 Transaction N+1 可以开始运行
V
Transaction N [TRANS_STATE_SUPER_COMMITTED]
| 超级块已写入(原子操作,此后文件系统一致)
V
Transaction N [TRANS_STATE_COMPLETED]
所有工作完成,事务结构体被释放

7.2 btrfs_transaction 数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// fs/btrfs/transaction.h

struct btrfs_transaction {
u64 transid;
atomic_t num_extwriters; /* 外部写者数量(必须在 commit 前归零) */
atomic_t num_writers; /* 总写者数量(必须在 commit 前归零) */
refcount_t use_count;

unsigned long flags;
enum btrfs_trans_state state;
int aborted;

struct list_head list;
struct extent_io_tree dirty_pages;
time64_t start_time;
wait_queue_head_t writer_wait;
wait_queue_head_t commit_wait;
struct list_head pending_snapshots; /* 待创建的快照 */
struct list_head dirty_bgs; /* 脏块组 */
struct extent_io_tree pinned_extents;/* 被 pin 的 extent(不可释放) */

struct btrfs_delayed_ref_root delayed_refs; /* 延迟引用更新 */
struct btrfs_fs_info *fs_info;
};

7.3 加入事务:join_transaction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// fs/btrfs/transaction.c

static noinline int join_transaction(struct btrfs_fs_info *fs_info,
unsigned int type)
{
struct btrfs_transaction *cur_trans;

spin_lock(&fs_info->trans_lock);
loop:
/* The file system has been taken offline. No new transactions. */
if (BTRFS_FS_ERROR(fs_info)) {
spin_unlock(&fs_info->trans_lock);
return -EROFS;
}

cur_trans = fs_info->running_transaction;
if (cur_trans) {
if (TRANS_ABORTED(cur_trans)) {
spin_unlock(&fs_info->trans_lock);
return cur_trans->aborted;
}
if (btrfs_blocked_trans_types[cur_trans->state] & type) {
spin_unlock(&fs_info->trans_lock);
return -EBUSY;
}
refcount_inc(&cur_trans->use_count);
atomic_inc(&cur_trans->num_writers);
extwriter_counter_inc(cur_trans, type);
spin_unlock(&fs_info->trans_lock);
return 0;
}
/* ... 若无运行中事务,创建新事务 ... */
}

btrfs_blocked_trans_types 数组定义了不同状态下哪些类型的事务连接被阻止,这是实现流控和有序提交的关键。

7.4 事务提交的关键步骤

完整的 btrfs_commit_transaction 流程包括:

  1. 等待外部写者退出wait_event on num_extwriters
  2. 运行 delayed refs(将延迟的 extent 引用修改写入 extent tree)
  3. 创建 pending snapshots(调用 create_pending_snapshot
  4. commit_cowonly_roots(更新 extent tree、chunk tree 等非可共享树)
  5. switch_commit_roots(将各子卷的 commit_root 切换为当前 node
  6. 写出脏页btrfs_write_and_wait_transaction
  7. 写超级块write_all_supers,先写备份,再写主超级块)

超级块写入使用原子写策略:先将新超级块写到所有设备,再将最新事务 ID 写入超级块头部的固定偏移(64KB 处)。由于超级块大小(4KB)不超过最小 I/O 单位,这个写操作是原子的,确保崩溃后要么使用新版本要么使用旧版本,不会出现中间状态。


八、RAID 支持

8.1 RAID 配置矩阵

Btrfs 在 volumes.c 中通过 btrfs_raid_array 定义了所有支持的 RAID 级别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// fs/btrfs/volumes.c

const struct btrfs_raid_attr btrfs_raid_array[BTRFS_NR_RAID_TYPES] = {
[BTRFS_RAID_RAID10] = {
.sub_stripes = 2,
.devs_min = 2,
.tolerated_failures = 1,
.ncopies = 2,
.nparity = 0,
.raid_name = "raid10",
},
[BTRFS_RAID_RAID1] = {
.devs_max = 2,
.devs_min = 2,
.tolerated_failures = 1,
.ncopies = 2,
.nparity = 0,
.raid_name = "raid1",
},
[BTRFS_RAID_RAID1C3] = {
.devs_max = 3,
.devs_min = 3,
.tolerated_failures = 2,
.ncopies = 3,
.nparity = 0,
.raid_name = "raid1c3",
},
[BTRFS_RAID_RAID1C4] = {
.devs_max = 4,
.devs_min = 4,
.tolerated_failures = 3,
.ncopies = 4,
.nparity = 0,
.raid_name = "raid1c4",
},
[BTRFS_RAID_DUP] = {
.devs_max = 1,
.devs_min = 1,
.tolerated_failures = 0,
.ncopies = 2, /* 同一设备上写两份 */
.nparity = 0,
.raid_name = "dup",
},
[BTRFS_RAID_RAID0] = {
.devs_min = 1,
.tolerated_failures = 0,
.ncopies = 1,
.nparity = 0,
.raid_name = "raid0",
},
[BTRFS_RAID_RAID5] = {
.devs_min = 2,
.tolerated_failures = 1,
.ncopies = 1,
.nparity = 1,
.raid_name = "raid5",
},
[BTRFS_RAID_RAID6] = {
.devs_min = 3,
.tolerated_failures = 2,
.ncopies = 1,
.nparity = 2,
.raid_name = "raid6",
},
};

8.2 Btrfs RAID 的特殊性

与传统 Linux MD RAID 不同,Btrfs 的 RAID 是在文件系统层实现的,有以下重要特性:

元数据和数据可以独立配置 RAID 级别。例如可以让元数据走 RAID1(两份冗余),数据走 RAID0(条带化,追求速度):

1
mkfs.btrfs -m raid1 -d raid0 /dev/sda /dev/sdb

Btrfs RAID 能做端到端校验。scrub 操作会读出 RAID 各副本并比较校验和,发现不一致时能从好的副本修复,这是 MD RAID 无法做到的。

RAID5/6 目前有已知问题。Btrfs RAID5/6 存在写洞(write hole)问题,即在条带写入过程中掉电可能导致数据不一致。生产环境中不推荐将 RAID5/6 用于重要数据(截至 Linux 6.4)。

8.3 块组与条带分配

Btrfs 将存储空间划分为块组(Block Group),每个块组有固定的类型(DATA、METADATA、SYSTEM)和 RAID 配置。btrfs_bg_flags_to_raid_index 将块组标志转换为 RAID 索引:

1
2
3
4
5
6
7
8
9
10
// fs/btrfs/volumes.c

enum btrfs_raid_types __attribute_const__
btrfs_bg_flags_to_raid_index(u64 flags)
{
const u64 profile = (flags & BTRFS_BLOCK_GROUP_PROFILE_MASK);
if (!profile)
return BTRFS_RAID_SINGLE;
return BTRFS_BG_FLAG_TO_INDEX(profile);
}

九、压缩支持

9.1 透明压缩

Btrfs 支持对文件数据进行透明压缩,在写入时自动压缩,读取时自动解压。支持三种算法:

1
2
3
// fs/btrfs/compression.c

static const char* const btrfs_compress_types[] = { "", "zlib", "lzo", "zstd" };

压缩分发逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// fs/btrfs/compression.c

static int compression_compress_pages(int type, struct list_head *ws,
struct address_space *mapping, u64 start, struct page **pages,
unsigned long *out_pages, unsigned long *total_in,
unsigned long *total_out)
{
switch (type) {
case BTRFS_COMPRESS_ZLIB:
return zlib_compress_pages(ws, mapping, start, pages,
out_pages, total_in, total_out);
case BTRFS_COMPRESS_LZO:
return lzo_compress_pages(ws, mapping, start, pages,
out_pages, total_in, total_out);
case BTRFS_COMPRESS_ZSTD:
return zstd_compress_pages(ws, mapping, start, pages,
out_pages, total_in, total_out);
case BTRFS_COMPRESS_NONE:
default:
*out_pages = 0;
return -E2BIG;
}
}

9.2 算法比较

算法 压缩率 速度 适用场景
zlib 较慢 冷数据,存档
lzo 极快 实时压缩,热数据
zstd 快(可调 level 1-15) 通用推荐

挂载时通过 compress=zstd:3 等选项指定算法和级别。若某个文件压缩后体积比原始大(不可压缩数据如图片、视频),Btrfs 会自动给该文件标记 NOCOMPRESS 属性,后续写入跳过压缩流程,避免浪费 CPU。

9.3 压缩与 CoW 的结合

压缩数据写入流程:

  1. 用户写入数据,进入 ordered extent 队列
  2. 后台工作线程尝试压缩
  3. 若压缩成功,以压缩后的尺寸分配 extent,写入磁盘
  4. 在 extent tree 中记录压缩类型和原始/压缩大小

由于 Btrfs 是 CoW 的,每次写入都分配新 extent,压缩和非压缩的数据可以共存,也无需担心就地更新压缩数据的复杂性。


十、常见运维操作

10.1 balance:数据重新分布

btrfs balance 是 Btrfs 最重要也最复杂的运维操作。它遍历所有块组,对每个块组中的所有 extent 进行重新分配(relocate),用途包括:

  • 添加/移除设备后重新平衡数据分布
  • 转换 RAID 级别(例如将 single 转为 raid1)
  • 碎片整理(减少元数据碎片)
  • 缩减文件系统(先 balance 将数据从尾部移走,再 resize)
1
2
3
4
5
# 将元数据从 single 转换为 raid1
btrfs balance start -mconvert=raid1 /mountpoint

# 只 balance 使用率低于 10% 的块组(快速减少块组数量)
btrfs balance start -dusage=10 /mountpoint

balance 操作可以暂停和恢复,适合在生产系统上分阶段执行。

10.2 scrub:数据完整性检查

btrfs scrub 读取文件系统中所有数据和元数据,验证校验和,并在 RAID 配置下从副本修复损坏数据:

1
2
btrfs scrub start /mountpoint
btrfs scrub status /mountpoint

scrub 是 Btrfs 数据完整性保障的重要工具,建议定期运行(例如每月一次)。它能发现磁盘静默数据损坏(silent data corruption),这是传统文件系统无法检测的问题。

10.3 send/receive:增量备份

btrfs sendbtrfs receive 实现了高效的增量数据传输,基于快照差异:

1
2
3
4
5
6
7
# 创建初始快照并发送到备份设备
btrfs subvolume snapshot -r /data /data_snap1
btrfs send /data_snap1 | btrfs receive /backup

# 后续增量备份
btrfs subvolume snapshot -r /data /data_snap2
btrfs send -p /data_snap1 /data_snap2 | btrfs receive /backup

增量 send 只传输两个快照之间的差异(新增、修改、删除的文件),效率极高。内核通过比较两棵 B-Tree(通过遍历 commit_root)来生成差异流。

10.4 子卷管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 创建子卷
btrfs subvolume create /mountpoint/mysubvol

# 创建可写快照
btrfs subvolume snapshot /mountpoint/mysubvol /mountpoint/mysnap

# 创建只读快照(用于备份或 send)
btrfs subvolume snapshot -r /mountpoint/mysubvol /mountpoint/mysnap_ro

# 列出所有子卷
btrfs subvolume list /mountpoint

# 删除子卷(异步,后台回收空间)
btrfs subvolume delete /mountpoint/mysnap

十一、性能特点与适用场景

11.1 性能优势

顺序写入性能优秀:由于 CoW 天然地将随机写转化为顺序追加(总是在新位置写入),对 HDD 友好,可以减少磁头寻道。

元数据操作高效:统一的 B-Tree 结构,所有元数据操作都是 O(log n)。单个大目录的遍历比 ext4 更快(B-Tree vs. linear hash)。

快照/克隆近乎零成本:快照、克隆文件(reflink)的创建时间不随数据量增长,始终是 O(1)。

内置压缩提升有效存储:对于文本、日志、代码等可压缩数据,zstd 压缩可将实际存储量降低 40%-70%,同时因为读取更少字节,在某些场景下实际读速度反而更快。

11.2 性能局限

随机小写开销较高:CoW 意味着每次写都要分配新块并更新元数据,比 ext4 的就地写入有额外开销。对于数据库等频繁随机写的工作负载,通常建议使用 nodatacow 挂载选项或将数据库文件放在关闭了 CoW 的子目录。

元数据碎片:长期运行后,由于 CoW 不断分配新块,元数据可能产生碎片,导致 B-Tree 节点分散。定期 balance 可以缓解此问题。

RAID56 不适合生产:如前所述,RAID5/6 的写洞问题尚未完全解决。

11.3 适用场景

场景 推荐配置 理由
桌面/工作站 默认单设备 + zstd 快照便于系统回滚,压缩节省空间
容器宿主机 多设备 raid1 子卷隔离容器,快照快速回滚
NAS / 媒体服务器 多设备 raid1 + scrub 数据完整性保障,增量备份高效
开发服务器 单设备 + 快照 代码仓库快照,便于实验性操作
高并发数据库 nodatacow + 关闭压缩 避免 CoW 开销,接近裸盘性能

总结

Btrfs 是一个设计理念超前的文件系统,其以 B-Tree + CoW 为核心的架构带来了快照、压缩、校验、RAID 的完整集成。通过本文的源码分析,可以看到:

  1. 统一的 B-Tree 键空间btrfs_key)是架构简洁性的来源
  2. should_cow_block 的优化使同一事务内的多次修改不产生额外 CoW 开销
  3. 快照创建的 O(1) 复杂度来自于 CoW + 引用计数的天然配合
  4. 事务状态机RUNNING -> COMMIT_START -> COMMIT_DOING -> ...)保证了崩溃一致性
  5. 端到端校验和(元数据每块、数据每 extent)提供了比传统文件系统强得多的数据完整性保障

随着 Linux 内核持续演进(6.4 及以后),Btrfs 的稳定性和性能还在不断提升,是现代 Linux 系统中极具价值的文件系统选择。

Linux 存储与文件系统深度剖析(五):Ext4 文件系统源码分析

Ext4 是 Linux 世界中使用最为广泛的文件系统之一。从 1992 年诞生的 Ext2 到今天仍在亿级服务器上运行的 Ext4,这个文件系统家族经历了三十余年的演进,积累了大量为生产环境验证的设计智慧。本文基于 Linux 6.4-rc1 内核源码(fs/ext4/fs/jbd2/),从磁盘格式到内核实现,逐层深入剖析 Ext4 的核心机制。


1. 历史演进:从 Ext2 到 Ext4

1.1 Ext2:奠定基础(1993)

Ext2(Second Extended Filesystem)由 Rémy Card 于 1993 年为 Linux 设计,确立了沿用至今的基本磁盘布局:块组(Block Group)划分、inode 表、块位图、inode 位图。Ext2 没有日志,崩溃后需要 e2fsck 做全盘一致性检查,在大容量磁盘上这可能耗时数小时。

1.2 Ext3:引入日志(2001)

Ext3 在 Ext2 的基础上叠加了 JBD(Journaling Block Device)日志层,提供三种日志模式:

模式 说明 性能 一致性
journal 数据+元数据都写日志 最慢 最强
ordered 只日志元数据,数据在元数据提交前落盘 中等 强(默认)
writeback 只日志元数据,数据顺序无保证 最快

Ext3 的磁盘格式与 Ext2 完全兼容,只需在超级块中设置日志特性位即可将 Ext2 升级为 Ext3。

1.3 Ext4:突破限制(2008)

Ext4 于 2008 年随 Linux 2.6.28 正式合并主线,主要突破:

  • Extent 树取代间接块映射,大文件性能大幅提升,最大单文件 16 TiB
  • 支持 48 位块地址,最大卷 1 EiB
  • 延迟分配(Delalloc) 显著减少碎片
  • 持久化预分配(Persistent Preallocation)
  • 在线碎片整理
  • HTree 目录索引(实际源自 Ext3,但 Ext4 普遍启用)
  • 日志校验和,JBD2 替代 JBD
  • 纳秒时间戳,扩展至 2446 年
  • 元数据校验和(crc32c)

2. 磁盘布局

2.1 整体结构

Ext4 将分区划分为若干块组(Block Group),每组大小默认 128 MiB(4K 块时 32768 块)。磁盘头部保留 1024 字节的 boot sector,其后是超级块,然后是块组描述符表,最后是各块组本身。

1
2
3
4
5
6
+----------+----------+------------+----------+----------+- - -
| Boot | Super | Group Desc | Group 0 | Group 1 |
| Sector | Block | Table | Data | Data |
| (1024B) | (1024B+) | (N blocks) | ... | ... |
+----------+----------+------------+----------+----------+- - -
0 1024 2048 ...

每个块组内部布局(以 4K 块为例):

1
2
3
4
5
+----------+----------+----------+----------+---------+
| Block | Inode | Inode | Data | Data |
| Bitmap | Bitmap | Table | Blocks | Blocks |
| (1 blk) | (1 blk) | (N blks) | ... | ... |
+----------+----------+----------+----------+---------+

2.2 超级块:struct ext4_super_block

超级块存于偏移 1024 字节处,是整个文件系统的”身份证”。以下是内核中的完整磁盘结构定义(fs/ext4/ext4.h,第 1230 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
struct ext4_super_block {
/*00*/ __le32 s_inodes_count; /* Inodes count */
__le32 s_blocks_count_lo; /* Blocks count */
__le32 s_r_blocks_count_lo; /* Reserved blocks count */
__le32 s_free_blocks_count_lo; /* Free blocks count */
/*10*/ __le32 s_free_inodes_count; /* Free inodes count */
__le32 s_first_data_block; /* First Data Block */
__le32 s_log_block_size; /* Block size: 1024 << s_log_block_size */
__le32 s_log_cluster_size; /* Allocation cluster size */
/*20*/ __le32 s_blocks_per_group; /* # Blocks per group */
__le32 s_clusters_per_group; /* # Clusters per group */
__le32 s_inodes_per_group; /* # Inodes per group */
__le32 s_mtime; /* Mount time */
/*30*/ __le32 s_wtime; /* Write time */
__le16 s_mnt_count; /* Mount count */
__le16 s_max_mnt_count; /* Maximal mount count */
__le16 s_magic; /* Magic signature: 0xEF53 */
__le16 s_state; /* File system state */
__le16 s_errors; /* Behaviour when detecting errors */
__le16 s_minor_rev_level; /* minor revision level */
/*40*/ __le32 s_lastcheck; /* time of last check */
__le32 s_checkinterval; /* max. time between checks */
__le32 s_creator_os; /* OS */
__le32 s_rev_level; /* Revision level */
/*50*/ __le16 s_def_resuid; /* Default uid for reserved blocks */
__le16 s_def_resgid; /* Default gid for reserved blocks */
/* EXT4_DYNAMIC_REV superblocks only: */
__le32 s_first_ino; /* First non-reserved inode */
__le16 s_inode_size; /* size of inode structure */
__le16 s_block_group_nr; /* block group # of this superblock */
__le32 s_feature_compat; /* compatible feature set */
/*60*/ __le32 s_feature_incompat; /* incompatible feature set */
__le32 s_feature_ro_compat; /* readonly-compatible feature set */
/*68*/ __u8 s_uuid[16]; /* 128-bit uuid for volume */
/*78*/ char s_volume_name[EXT4_LABEL_MAX]; /* volume name */
/*88*/ char s_last_mounted[64]; /* directory where last mounted */
/*D0*/ __u8 s_journal_uuid[16]; /* uuid of journal superblock */
/*E0*/ __le32 s_journal_inum; /* inode number of journal file */
__le32 s_journal_dev; /* device number of journal file */
__le32 s_last_orphan; /* start of list of inodes to delete */
__le32 s_hash_seed[4]; /* HTREE hash seed */
__u8 s_def_hash_version; /* Default hash version to use */
__le16 s_desc_size; /* size of group descriptor */
/*100*/ __le32 s_default_mount_opts;
__le32 s_first_meta_bg; /* First metablock block group */
__le32 s_mkfs_time; /* When the filesystem was created */
__le32 s_jnl_blocks[17]; /* Backup of the journal inode */
/* 64bit support valid if EXT4_FEATURE_INCOMPAT_64BIT */
/*150*/ __le32 s_blocks_count_hi; /* Blocks count (high 32 bits) */
__le32 s_r_blocks_count_hi; /* Reserved blocks count */
__le32 s_free_blocks_count_hi; /* Free blocks count */
__le16 s_min_extra_isize; /* All inodes have at least # bytes */
__le16 s_want_extra_isize; /* New inodes should reserve # bytes */
__le32 s_flags; /* Miscellaneous flags */
__le16 s_raid_stride; /* RAID stride */
__le16 s_mmp_update_interval; /* # seconds to wait in MMP checking */
__le64 s_mmp_block; /* Block for multi-mount protection */
__le32 s_raid_stripe_width; /* blocks on all data disks (N*stride)*/
__u8 s_log_groups_per_flex; /* FLEX_BG group size */
__u8 s_checksum_type; /* metadata checksum algorithm used */
__le64 s_kbytes_written; /* nr of lifetime kilobytes written */
__le32 s_error_count; /* number of fs errors */
__le32 s_first_error_time; /* first time an error happened */
__le32 s_first_error_ino; /* inode involved in first error */
__le64 s_first_error_block; /* block involved of first error */
__u8 s_first_error_func[32]; /* function where the error happened */
__le32 s_first_error_line; /* line number where error happened */
/* ... */
__le32 s_checksum_seed; /* crc32c(uuid) if csum_seed set */
};

几个关键字段解析:

  • **s_magic**:魔数 0xEF53,内核挂载时首先校验此值。
  • **s_log_block_size**:实际块大小 = 1024 << s_log_block_size,合法值 0/1/2/3 对应 1K/2K/4K/8K。
  • s_feature_compat/incompat/ro_compat:三级特性位。incompat 中存在内核不认识的位时,必须拒绝挂载ro_compat 中有未知位时只能只读挂载;compat 中有未知位可以正常挂载(向后兼容)。
  • **s_journal_inum**:日志文件对应的 inode 号,通常为 inode 8。
  • **s_last_orphan**:崩溃前未完成删除的 inode 链表头,挂载时由 ext4_orphan_cleanup() 处理。
  • **s_error_***:内核会将首次和最近一次错误的函数名、行号、涉及的块/inode 持久化到超级块,tune2fs -l 可以查看。

2.3 块组描述符:struct ext4_group_desc

每个块组的元数据地址由块组描述符表记录,定义于 fs/ext4/ext4.h,第 338 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct ext4_group_desc
{
__le32 bg_block_bitmap_lo; /* Blocks bitmap block */
__le32 bg_inode_bitmap_lo; /* Inodes bitmap block */
__le32 bg_inode_table_lo; /* Inodes table block */
__le16 bg_free_blocks_count_lo; /* Free blocks count */
__le16 bg_free_inodes_count_lo; /* Free inodes count */
__le16 bg_used_dirs_count_lo; /* Directories count */
__le16 bg_flags; /* EXT4_BG_flags (INODE_UNINIT, etc) */
__le32 bg_exclude_bitmap_lo; /* Exclude bitmap for snapshots */
__le16 bg_block_bitmap_csum_lo; /* crc32c(s_uuid+grp_num+bbitmap) LE */
__le16 bg_inode_bitmap_csum_lo; /* crc32c(s_uuid+grp_num+ibitmap) LE */
__le16 bg_itable_unused_lo; /* Unused inodes count */
__le16 bg_checksum; /* crc16(sb_uuid+group+desc) */
/* 以下字段仅在启用 64BIT 特性时存在 */
__le32 bg_block_bitmap_hi; /* Blocks bitmap block MSB */
__le32 bg_inode_bitmap_hi; /* Inodes bitmap block MSB */
__le32 bg_inode_table_hi; /* Inodes table block MSB */
__le16 bg_free_blocks_count_hi; /* Free blocks count MSB */
__le16 bg_free_inodes_count_hi; /* Free inodes count MSB */
__le16 bg_used_dirs_count_hi; /* Directories count MSB */
__le16 bg_itable_unused_hi; /* Unused inodes count MSB */
__le32 bg_exclude_bitmap_hi; /* Exclude bitmap block MSB */
__le16 bg_block_bitmap_csum_hi; /* crc32c(s_uuid+grp_num+bbitmap) BE */
__le16 bg_inode_bitmap_csum_hi; /* crc32c(s_uuid+grp_num+ibitmap) BE */
__u32 bg_reserved;
};

bg_flags 字段中有几个重要标志位:

1
2
3
#define EXT4_BG_INODE_UNINIT  0x0001  /* Inode 表/位图未初始化(新建组优化)*/
#define EXT4_BG_BLOCK_UNINIT 0x0002 /* 块位图未初始化 */
#define EXT4_BG_INODE_ZEROED 0x0004 /* 磁盘上的 inode 表已初始化为零 */

INODE_UNINITBLOCK_UNINIT 是 Ext4 的延迟初始化(lazy init)特性:mke2fs 只初始化第一个块组,其余块组在内核后台线程中异步初始化,大容量磁盘格式化因此能在秒级完成。

2.4 inode 结构:struct ext4_inode

inode 是文件系统的核心抽象,存储文件的元数据和数据块地址。Ext4 的磁盘 inode 定义于 fs/ext4/ext4.h,第 714 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct ext4_inode {
__le16 i_mode; /* File mode: 类型+权限位 */
__le16 i_uid; /* Low 16 bits of Owner Uid */
__le32 i_size_lo; /* Size in bytes (低32位) */
__le32 i_atime; /* Access time */
__le32 i_ctime; /* Inode Change time */
__le32 i_mtime; /* Modification time */
__le32 i_dtime; /* Deletion Time */
__le16 i_gid; /* Low 16 bits of Group Id */
__le16 i_links_count; /* Links count */
__le32 i_blocks_lo; /* Blocks count (以512字节扇区为单位) */
__le32 i_flags; /* File flags: EXTENTS_FL, INLINE_DATA_FL 等 */
union {
struct { __le32 l_i_version; } linux1;
/* ... */
} osd1;
__le32 i_block[EXT4_N_BLOCKS]; /* 数据块指针区域,共60字节 */
__le32 i_generation; /* File version (for NFS) */
__le32 i_file_acl_lo; /* File ACL */
__le32 i_size_high; /* Size in bytes (高32位) */
__le32 i_obso_faddr; /* Obsoleted fragment address */
union {
struct {
__le16 l_i_blocks_high;
__le16 l_i_file_acl_high;
__le16 l_i_uid_high;
__le16 l_i_gid_high;
__le16 l_i_checksum_lo; /* crc32c(uuid+inum+inode) LE */
__le16 l_i_reserved;
} linux2;
/* ... */
} osd2;
__le16 i_extra_isize; /* 扩展区域大小,用于存额外字段 */
__le16 i_checksum_hi; /* crc32c(uuid+inum+inode) BE */
__le32 i_ctime_extra; /* 纳秒+epoch扩展 ctime */
__le32 i_mtime_extra; /* 纳秒+epoch扩展 mtime */
__le32 i_atime_extra; /* 纳秒+epoch扩展 atime */
__le32 i_crtime; /* File Creation time */
__le32 i_crtime_extra; /* 纳秒+epoch扩展 crtime */
__le32 i_version_hi; /* 64位版本号高32位 */
__le32 i_projid; /* Project ID */
};

关键设计点:

  1. i_block[EXT4_N_BLOCKS]:共 15 个 __le32,占 60 字节。对于传统间接块映射(Ext2/3 遗留),前 12 个是直接块指针,第 13/14/15 个分别是一级/二级/三级间接块指针。对于启用 Extent 树的 Ext4 文件(EXT4_EXTENTS_FL),这 60 字节改为存储 extent 头和 extent 叶子节点,完全不同的语义。

  2. 纳秒时间戳i_ctime 等字段存 Unix 秒数(32位),i_ctime_extransec << 2 | epoch 编码:低2位为 epoch 扩展位,可将时间范围延伸至 2446 年;高30位为纳秒。

  3. **i_extra_isize**:Ext4 支持大 inode(256字节起),i_extra_isize 记录 EXT4_GOOD_OLD_INODE_SIZE(128字节)之后额外使用的字节数,存放扩展字段(crtime、版本号、校验和等)。


3. Extent 树机制

Ext4 最重要的性能改进之一是用 B+ 树形式的 Extent 树替代了 Ext2/3 的多级间接块指针。

3.1 核心数据结构

定义于 fs/ext4/ext4_extents.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
* This is the extent on-disk structure.
* It's used at the bottom of the tree (leaf nodes).
*/
struct ext4_extent {
__le32 ee_block; /* first logical block extent covers */
__le16 ee_len; /* number of blocks covered by extent */
__le16 ee_start_hi; /* high 16 bits of physical block */
__le32 ee_start_lo; /* low 32 bits of physical block */
};

/*
* This is index on-disk structure.
* It's used at all the levels except the bottom (internal nodes).
*/
struct ext4_extent_idx {
__le32 ei_block; /* index covers logical blocks from 'block' */
__le32 ei_leaf_lo; /* pointer to the physical block of the next
* level. leaf or next index could be there */
__le16 ei_leaf_hi; /* high 16 bits of physical block */
__u16 ei_unused;
};

/*
* Each block (leaves and indexes), even inode-stored has header.
*/
struct ext4_extent_header {
__le16 eh_magic; /* probably will support different formats */
__le16 eh_entries; /* number of valid entries */
__le16 eh_max; /* capacity of store in entries */
__le16 eh_depth; /* has tree real underlying blocks?
* depth == 0 means the tree is a leaf */
__le32 eh_generation; /* generation of the tree */
};

#define EXT4_EXT_MAGIC cpu_to_le16(0xf30a)
#define EXT4_MAX_EXTENT_DEPTH 5

ext4_extent(叶子节点) 是一个”运行”(run)描述符:从逻辑块 ee_block 开始的 ee_len 个连续块,映射到物理块 (ee_start_hi << 32) | ee_start_lo。注意 ee_len 的最高位(MSB)有特殊含义:

  • ee_len <= 0x8000:已初始化的 extent
  • ee_len > 0x8000:未写(unwritten/preallocated)extent,实际长度为 ee_len & 0x7FFF

最大初始化 extent 覆盖 32768 块(EXT_INIT_MAX_LEN = 0x8000),即 4K 块时 128 MiB。

ext4_extent_idx(内部节点) 存储索引,ei_block 是该子树覆盖的起始逻辑块,ei_leaf_lo/hi 指向子节点所在物理块。

树的根始终存在 inode 的 i_block[0..14](60字节)中:前 12 字节是 ext4_extent_header,后 48 字节最多放 3 个 ext4_extent(叶子)或 ext4_extent_idx(内部节点)。

路径查找使用辅助结构 ext4_ext_path

1
2
3
4
5
6
7
8
9
struct ext4_ext_path {
ext4_fsblk_t p_block; /* physical block of this level */
__u16 p_depth; /* depth of this level */
__u16 p_maxdepth;
struct ext4_extent *p_ext; /* pointer to extent (leaf) */
struct ext4_extent_idx *p_idx; /* pointer to index (internal) */
struct ext4_extent_header *p_hdr; /* header of this block */
struct buffer_head *p_bh; /* buffer head for this block */
};

3.2 ext4_find_extent:树遍历实现

核心查找函数 ext4_find_extent()fs/ext4/extents.c,第 883 行)从根向叶子走一遍 B+ 树,每层调用二分搜索:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
struct ext4_ext_path *
ext4_find_extent(struct inode *inode, ext4_lblk_t block,
struct ext4_ext_path **orig_path, int flags)
{
struct ext4_extent_header *eh;
struct buffer_head *bh;
struct ext4_ext_path *path = orig_path ? *orig_path : NULL;
short int depth, i, ppos = 0;
int ret;
gfp_t gfp_flags = GFP_NOFS;

if (flags & EXT4_EX_NOFAIL)
gfp_flags |= __GFP_NOFAIL;

eh = ext_inode_hdr(inode); /* 从 inode.i_block 取树根 header */
depth = ext_depth(inode); /* 当前树深度 */
if (depth < 0 || depth > EXT4_MAX_EXTENT_DEPTH) {
EXT4_ERROR_INODE(inode, "inode has invalid extent depth: %d", depth);
ret = -EFSCORRUPTED;
goto err;
}

if (!path) {
/* account possible depth increase */
path = kcalloc(depth + 2, sizeof(struct ext4_ext_path), gfp_flags);
if (unlikely(!path))
return ERR_PTR(-ENOMEM);
path[0].p_maxdepth = depth + 1;
}
path[0].p_hdr = eh;
path[0].p_bh = NULL;

i = depth;
/* 对深度为0的树(全在inode内)可以直接缓存 extent */
if (!(flags & EXT4_EX_NOCACHE) && depth == 0)
ext4_cache_extents(inode, eh);

/* 从根向叶子遍历内部节点 */
while (i) {
/* 在当前层的 idx 数组中二分查找 */
ext4_ext_binsearch_idx(inode, path + ppos, block);
path[ppos].p_block = ext4_idx_pblock(path[ppos].p_idx);
path[ppos].p_depth = i;
path[ppos].p_ext = NULL;

/* 读取子节点对应的磁盘块 */
bh = read_extent_tree_block(inode, path[ppos].p_idx, --i, flags);
if (IS_ERR(bh)) {
ret = PTR_ERR(bh);
goto err;
}

eh = ext_block_hdr(bh);
ppos++;
path[ppos].p_bh = bh;
path[ppos].p_hdr = eh;
}

path[ppos].p_depth = i;
path[ppos].p_ext = NULL;
path[ppos].p_idx = NULL;

/* 在叶子节点做二分搜索找到对应 extent */
ext4_ext_binsearch(inode, path + ppos, block);
if (path[ppos].p_ext)
path[ppos].p_block = ext4_ext_pblock(path[ppos].p_ext);

ext4_ext_show_path(inode, path);
return path;

err:
ext4_free_ext_path(path);
if (orig_path) *orig_path = NULL;
return ERR_PTR(ret);
}

3.3 叶子层二分搜索:ext4_ext_binsearch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static void
ext4_ext_binsearch(struct inode *inode,
struct ext4_ext_path *path, ext4_lblk_t block)
{
struct ext4_extent_header *eh = path->p_hdr;
struct ext4_extent *r, *l, *m;

if (eh->eh_entries == 0) {
/* 空叶子:在 split/add 路径中会遇到 */
return;
}

l = EXT_FIRST_EXTENT(eh) + 1; /* 从第二个 extent 开始搜 */
r = EXT_LAST_EXTENT(eh);

while (l <= r) {
m = l + (r - l) / 2;
if (block < le32_to_cpu(m->ee_block))
r = m - 1;
else
l = m + 1;
}

/* path->p_ext 指向不大于 block 的最后一个 extent */
path->p_ext = l - 1;
}

函数返回后,path->p_ext 指向起始逻辑块不超过目标 block 的那个 extent。调用者还需检查 block < ee_block + ee_len 来确认 block 确实落在该 extent 范围内,否则说明是个”洞”(hole)。


4. 块组管理

4.1 块分配器:mballoc

Ext4 使用多块分配器mballocfs/ext4/mballoc.c)一次申请多个连续块,减少碎片。分配请求用 struct ext4_allocation_request 描述:

1
2
3
4
5
6
7
8
9
10
11
struct ext4_allocation_request {
struct inode *inode; /* 目标 inode */
unsigned int len; /* 希望分配的块数 */
ext4_lblk_t logical; /* 目标逻辑块号 */
ext4_lblk_t lleft; /* 最近已分配的左侧逻辑块 */
ext4_lblk_t lright; /* 最近已分配的右侧逻辑块 */
ext4_fsblk_t goal; /* 物理块 hint(优先尝试目标) */
ext4_fsblk_t pleft; /* 左侧逻辑块对应的物理块 */
ext4_fsblk_t pright; /* 右侧逻辑块对应的物理块 */
unsigned int flags; /* EXT4_MB_HINT_* 标志 */
};

flags 字段的核心标志:

1
2
3
4
5
6
#define EXT4_MB_HINT_MERGE       0x0001  /* 优先尝试合并到相邻 extent */
#define EXT4_MB_HINT_METADATA 0x0004 /* 分配元数据块 */
#define EXT4_MB_HINT_DATA 0x0020 /* 分配数据块 */
#define EXT4_MB_HINT_NOPREALLOC 0x0040 /* 不预分配 */
#define EXT4_MB_DELALLOC_RESERVED 0x0400 /* 延迟分配预留块 */
#define EXT4_MB_STREAM_ALLOC 0x0800 /* 流式分配(顺序写优化)*/

mballoc 的核心策略是”伙伴系统”(buddy system):每个块组维护一个伙伴位图,记录不同大小的空闲连续块位置。分配时按 cr(criteria,标准)从 0 到 3 逐步放宽条件:

  • cr=0:只分配大碎片(≥ 请求大小),延迟分配优先路径
  • cr=1:平均碎片大小匹配,红黑树查找
  • cr=2:顺序扫描块组
  • cr=3:任意可用块

4.2 灵活块组(Flex_BG)

flex_bg 特性启用时,相邻的若干块组(默认 16 个)合并为一个”超级块组”(flex group)。超级块组内的块位图和 inode 位图集中存放在第一个块组,数据块则紧随其后连续分布,大幅减少了寻道次数。


5. JBD2 日志机制与崩溃一致性

5.1 JBD2 架构

Ext4 使用 JBD2(Journaling Block Device 2)提供日志能力,JBD2 是独立内核子系统,也可被其他文件系统(如 OCFS2)使用。核心实体:

journal_tinclude/linux/jbd2.h,第 770 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct journal_s
{
unsigned long j_flags; /* General journaling state flags */
int j_errno; /* Outstanding error on journal */
struct mutex j_abort_mutex; /* Lock for aborting procedure */
struct buffer_head *j_sb_buffer; /* Superblock buffer */
journal_superblock_t *j_superblock; /* Superblock structure */
rwlock_t j_state_lock; /* Protect scalar fields */
int j_barrier_count; /* Processes waiting for barrier lock */
struct mutex j_barrier; /* The barrier lock itself */
transaction_t *j_running_transaction; /* Current running transaction */
transaction_t *j_committing_transaction; /* Being committed */
transaction_t *j_checkpoint_transactions;/* Checkpointed transactions */
/* ... */
unsigned long j_commit_interval; /* commit interval in jiffies */
struct task_struct *j_task; /* kjournald2 thread */
int j_max_transaction_buffers; /* max buffers per transaction */
/* ... */
};

transaction_tinclude/linux/jbd2.h,第 560 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct transaction_s
{
journal_t *t_journal; /* 所属日志 */
tid_t t_tid; /* 事务序号 */
enum {
T_RUNNING, /* 正在接收修改 */
T_LOCKED, /* 准备提交,不再接收 */
T_SWITCH, /* 切换到新事务 */
T_FLUSH, /* 刷新数据 */
T_COMMIT, /* 提交阶段 */
T_COMMIT_DFLUSH, /* 提交数据刷盘 */
T_COMMIT_JFLUSH, /* 提交日志刷盘 */
T_COMMIT_CALLBACK, /* 执行提交后回调 */
T_FINISHED /* 已完成 */
} t_state;
unsigned long t_log_start; /* 事务在日志中的起始位置 */
int t_nr_buffers; /* t_buffers 链表中的 buffer 数 */
struct journal_head *t_reserved_list; /* 已预留但未修改的 buffer */
struct journal_head *t_buffers; /* 已修改的元数据 buffer */
struct journal_head *t_forget; /* 可在 checkpoint 后释放的 buffer */
struct journal_head *t_checkpoint_list; /* 等待 checkpoint 的 buffer */
struct journal_head *t_shadow_list; /* IO 过程中的 shadow buffer */
struct list_head t_inode_list; /* 关联 inode 列表 */
spinlock_t t_handle_lock; /* 保护 handle 相关信息 */
/* ... */
};

5.2 事务生命周期

JBD2 事务遵循严格的状态机,一次完整的写操作流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
应用程序写入


ext4_journal_start() ──→ 获取 handle_t,绑定到 running transaction


jbd2_journal_get_write_access(handle, bh)
│ 将 buffer 的原始内容(或修改后)拷贝到日志 shadow

修改 buffer_head 内容


jbd2_journal_dirty_metadata(handle, bh)
│ 标记 buffer 为"脏元数据",加入 t_buffers 链表

jbd2_journal_stop(handle)
│ 减少 handle 引用计数,若为0则可触发事务提交

kjournald2 线程

├── 写 Journal Descriptor Block(描述本次事务涉及的所有块)
├── 写 Journal Data(所有脏的元数据块内容)
├── 写 Journal Commit Block(表示事务完整写入日志)


checkpoint(异步)

└── 将日志中的块真正写回文件系统原始位置,释放日志空间

jbd2_journal_start() 函数定义于 fs/jbd2/transaction.c

1
2
3
4
handle_t *jbd2_journal_start(journal_t *journal, int nblocks)
{
return jbd2__journal_start(journal, nblocks, 0, 0, GFP_NOFS, 0, 0);
}

参数 nblocks 是本次操作预期修改的日志块数量上限,JBD2 会为此在日志中预留空间。

5.3 三种数据模式的实现

data=ordered(默认)模式下,fs/ext4/ext4_jbd2.h 中的宏 EXT4_ORDERED_DATA_MODE 控制以下逻辑:提交事务的元数据之前,必须确保文件的数据页先于元数据落盘——这通过将 inode 加入 t_inode_list 并在提交时强制 writeback 实现,防止日志重放后看到指向未初始化块的元数据。


6. 读写实现:从 VFS 到磁盘

6.1 读路径:ext4_file_read_iter

定义于 fs/ext4/file.c,第 130 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static ssize_t ext4_file_read_iter(struct kiocb *iocb, struct iov_iter *to)
{
struct inode *inode = file_inode(iocb->ki_filp);

/* 若文件系统已强制关闭(forced shutdown),直接返回 EIO */
if (unlikely(ext4_forced_shutdown(EXT4_SB(inode->i_sb))))
return -EIO;

/* 零长度读无需更新 atime */
if (!iov_iter_count(to))
return 0;

#ifdef CONFIG_FS_DAX
/* 若是 DAX(直接访问 PMEM)模式,走 DAX 路径 */
if (IS_DAX(inode))
return ext4_dax_read_iter(iocb, to);
#endif
/* Direct I/O:绕过 page cache */
if (iocb->ki_flags & IOCB_DIRECT)
return ext4_dio_read_iter(iocb, to);

/* 默认:buffered I/O,经由 page cache */
return generic_file_read_iter(iocb, to);
}

generic_file_read_iter 在 VFS 层实现,最终通过 mapping->a_ops->read_folio() 触发 Ext4 的 ext4_read_folio(),后者通过 ext4_map_blocks() 将逻辑块号翻译为物理块号,再提交 bio 到块层。

6.2 写路径:ext4_file_write_iter

定义于 fs/ext4/file.c,第 696 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static ssize_t
ext4_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
struct inode *inode = file_inode(iocb->ki_filp);

if (unlikely(ext4_forced_shutdown(EXT4_SB(inode->i_sb))))
return -EIO;

#ifdef CONFIG_FS_DAX
if (IS_DAX(inode))
return ext4_dax_write_iter(iocb, from);
#endif
/* Direct I/O 写 */
if (iocb->ki_flags & IOCB_DIRECT)
return ext4_dio_write_iter(iocb, from);
else
/* Buffered 写(包含延迟分配路径) */
return ext4_buffered_write_iter(iocb, from);
}

ext4_buffered_write_iter 调用 generic_perform_write,后者对每个页面调用 address_space_operations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static ssize_t ext4_buffered_write_iter(struct kiocb *iocb,
struct iov_iter *from)
{
ssize_t ret;
struct inode *inode = file_inode(iocb->ki_filp);

if (iocb->ki_flags & IOCB_NOWAIT)
return -EOPNOTSUPP;

inode_lock(inode); /* 持有 inode 写锁 */
ret = ext4_write_checks(iocb, from); /* 检查大小、quota 等 */
if (ret <= 0)
goto out;

current->backing_dev_info = inode_to_bdi(inode);
ret = generic_perform_write(iocb, from); /* 核心写入循环 */
current->backing_dev_info = NULL;

out:
inode_unlock(inode);
if (likely(ret > 0)) {
iocb->ki_pos += ret;
ret = generic_write_sync(iocb, ret); /* 若 O_SYNC 则等待落盘 */
}
return ret;
}

generic_perform_write 的每次迭代都调用:

  1. ext4_da_write_begin():准备页面,标记延迟分配
  2. 将用户数据拷贝进页面
  3. ext4_da_write_end():完成写入,更新 i_disksize

6.3 逻辑块到物理块映射:ext4_map_blocks

ext4_map_blocks()fs/ext4/inode.c,第 478 行)是读写路径的核心枢纽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int ext4_map_blocks(handle_t *handle, struct inode *inode,
struct ext4_map_blocks *map, int flags)
{
struct extent_status es;
int retval;

map->m_flags = 0;

/* 首先查询内存中的 extent status 缓存(避免磁盘读) */
if (!(EXT4_SB(inode->i_sb)->s_mount_state & EXT4_FC_REPLAY) &&
ext4_es_lookup_extent(inode, map->m_lblk, NULL, &es)) {
if (ext4_es_is_written(&es) || ext4_es_is_unwritten(&es)) {
/* 已写或未写的真实 extent,直接返回物理地址 */
map->m_pblk = ext4_es_pblock(&es) +
map->m_lblk - es.es_lblk;
map->m_flags |= ext4_es_is_written(&es) ?
EXT4_MAP_MAPPED : EXT4_MAP_UNWRITTEN;
retval = es.es_len - (map->m_lblk - es.es_lblk);
if (retval > map->m_len)
retval = map->m_len;
map->m_len = retval;
} else if (ext4_es_is_delayed(&es) || ext4_es_is_hole(&es)) {
/* 延迟分配或空洞:返回0,上层按需分配 */
map->m_pblk = 0;
retval = 0;
}
/* ... */
goto found;
}

/* cache miss:持 i_data_sem 读锁查询 extent 树 */
/* 若 flags 含 EXT4_GET_BLOCKS_CREATE,则可能触发实际块分配 */
/* ... */
}

ext4_map_blocks 三步查询策略:

  1. Extent Status Tree(内存缓存):高速 RB 树,存储已查询过的 extent 状态(written/unwritten/delayed/hole)。
  2. Extent 树(磁盘 B+ 树):调用 ext4_ext_map_blocks()ext4_find_extent() 查找。
  3. 块分配:若需要创建新块,调用 mballoc 分配物理块,再通过 ext4_ext_insert_extent() 插入 extent 树。

7. 延迟分配(Delayed Allocation)

延迟分配是 Ext4 性能优化的核心特性,通过挂载选项 delalloc(默认开启)启用。

7.1 原理

传统文件系统在 write() 时立即分配物理块。Ext4 延迟分配将块分配推迟到 writeback(脏页回写)时,好处在于:

  1. 写入更多数据后,分配器对局部性有更好的判断,可以分配连续的大块
  2. 若文件被删除前从未发生回写(如临时文件),完全避免了磁盘分配

7.2 实现:ext4_da_write_beginext4_da_get_block_prep

延迟分配写入由 ext4_da_write_begin() 启动(fs/ext4/inode.c,第 2875 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
static int ext4_da_write_begin(struct file *file, struct address_space *mapping,
loff_t pos, unsigned len,
struct page **pagep, void **fsdata)
{
int ret, retries = 0;
struct folio *folio;
pgoff_t index;
struct inode *inode = mapping->host;

if (unlikely(ext4_forced_shutdown(EXT4_SB(inode->i_sb))))
return -EIO;

index = pos >> PAGE_SHIFT;

/* 若文件系统已满或正在进行 fsverity,退回到非延迟分配模式 */
if (ext4_nonda_switch(inode->i_sb) || ext4_verity_in_progress(inode)) {
*fsdata = (void *)FALL_BACK_TO_NONDELALLOC;
return ext4_write_begin(file, mapping, pos, len, pagep, fsdata);
}
*fsdata = (void *)0;

/* 处理 inline data(小文件数据存 inode 内)*/
if (ext4_test_inode_state(inode, EXT4_STATE_MAY_INLINE_DATA)) {
ret = ext4_da_write_inline_data_begin(mapping, inode, pos, len,
pagep, fsdata);
if (ret < 0) return ret;
if (ret == 1) return 0;
}

retry:
folio = __filemap_get_folio(mapping, index, FGP_WRITEBEGIN,
mapping_gfp_mask(mapping));
if (IS_ERR(folio))
return PTR_ERR(folio);

folio_wait_stable(folio);

/* ext4_da_get_block_prep:标记块为 delayed,不实际分配 */
ret = __block_write_begin(&folio->page, pos, len, ext4_da_get_block_prep);
if (ret < 0) {
folio_unlock(folio);
folio_put(folio);
if (pos + len > inode->i_size)
ext4_truncate_failed_write(inode);
if (ret == -ENOSPC &&
ext4_should_retry_alloc(inode->i_sb, &retries))
goto retry;
return ret;
}
*pagep = &folio->page;
return ret;
}

ext4_da_get_block_prep() 是关键:它调用 ext4_da_map_blocks(),如果块还未分配,只在 extent status tree 中标记一个 delayed 状态,不分配任何物理块,并将 buffer 标记为 BH_Delay

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int ext4_da_get_block_prep(struct inode *inode, sector_t iblock,
struct buffer_head *bh, int create)
{
struct ext4_map_blocks map;
int ret = 0;

BUG_ON(create == 0);
BUG_ON(bh->b_size != inode->i_sb->s_blocksize);

map.m_lblk = iblock;
map.m_len = 1;

/* 查询现有映射:若已分配则直接返回;若是空洞则创建 delayed 记录 */
ret = ext4_da_map_blocks(inode, iblock, &map, bh);
if (ret <= 0)
return ret;

map_bh(bh, inode->i_sb, map.m_pblk);
ext4_update_bh_state(bh, map.m_flags);

if (buffer_unwritten(bh)) {
set_buffer_new(bh);
set_buffer_mapped(bh);
}
return 0;
}

真正的块分配发生在 writeback 路径的 ext4_writepages()mpage_prepare_extent_to_map()ext4_map_blocks() 中,此时一次为多个连续的 delayed 块分配物理空间。


8. 目录索引:HTree(dx_root)

8.1 线性目录 vs HTree

Ext2/3 中目录是简单的线性链表,每次查找要从头遍历所有目录项,在大目录(数千文件)中性能极差。Ext4 的 HTree(Hash Tree)将目录实现为基于文件名哈希的 B+ 树,查找复杂度降为 O(log n)。

8.2 核心数据结构(fs/ext4/namei.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* HTree 的一个索引项:哈希值 → 数据块号 */
struct dx_entry
{
__le32 hash; /* 文件名哈希值 */
__le32 block; /* 指向的目录数据块号 */
};

/* 目录树根节点(位于目录的第一个数据块) */
struct dx_root
{
struct fake_dirent dot; /* "." 目录项(占位) */
char dot_name[4];
struct fake_dirent dotdot; /* ".." 目录项(占位) */
char dotdot_name[4];
struct dx_root_info
{
__le32 reserved_zero;
u8 hash_version; /* 哈希函数版本:half-MD4/tea/siphash 等 */
u8 info_length; /* = 8,本结构体大小 */
u8 indirect_levels; /* 树深度(0 = 单层索引)*/
u8 unused_flags;
} info;
struct dx_entry entries[]; /* 索引项数组(紧随其后)*/
};

/* 内部节点(非根的索引块) */
struct dx_node
{
struct fake_dirent fake;
struct dx_entry entries[];
};

/* 每个 htree 块末尾的尾部(存 checksum) */
struct dx_tail {
u32 dt_reserved;
__le32 dt_checksum; /* crc32c(uuid+inum+dirblock) */
};

查找时流程:计算文件名哈希 → 在 dx_root.entries[] 中二分查找对应数据块 → 读取该数据块,线性扫描目录项匹配文件名。

哈希版本由超级块 s_def_hash_version 指定,默认为 half-MD4(Half-MD4,快速且分布均匀)。


9. 性能调优挂载选项

Ext4 提供丰富的挂载选项,以下是关键参数(来自 fs/ext4/super.c):

9.1 数据写入模式

选项 含义
data=journal 数据和元数据都写日志,最安全,最慢
data=ordered 默认;元数据写日志,数据在元数据前落盘
data=writeback 元数据写日志,数据写序不保证

9.2 日志相关

选项 含义
journal_async_commit 异步提交日志,减少写延迟
journal_checksum 启用日志块校验和(默认开)
commit=N 日志提交间隔(秒),默认 5 秒
noload 挂载时不加载日志(只读模式用)

9.3 分配与预分配

选项 含义
delalloc 延迟块分配(默认开)
nodelalloc 禁用延迟分配(数据库场景)
noauto_da_alloc 关闭 close 时的强制 delalloc 落盘
max_batch_time=N mballoc 批量分配最大等待时间(微秒)
min_batch_time=N mballoc 批量分配最小等待时间(微秒)

9.4 IO 特性

选项 含义
barrier / nobarrier 写屏障控制(默认开);SSD 可以安全关闭
discard / nodiscard trim/discard 支持(SSD 推荐开)
dioread_nolock Direct I/O 读时不持 inode 锁,提升并发读性能

9.5 错误处理

选项 含义
errors=continue 遇错继续(不推荐)
errors=remount-ro 遇错重挂载为只读(默认)
errors=panic 遇错 kernel panic

9.6 推荐生产配置示例

1
2
3
4
5
6
7
8
# 普通服务器(高可靠性)
mount -o defaults,data=ordered,barrier=1,journal_async_commit /dev/sda1 /data

# SSD 优化
mount -o defaults,discard,nobarrier,delalloc,data=ordered /dev/nvme0n1 /data

# 数据库(需 Direct I/O,避免延迟分配带来的不确定性)
mount -o defaults,nodelalloc,data=writeback,nobarrier /dev/sdb1 /dbdata

10. 常见问题排查

10.1 e2fsck:文件系统一致性检查

1
2
3
4
5
6
7
8
# 强制全面检查(卸载后运行)
e2fsck -f -v /dev/sda1

# 自动修复(生产环境慎用,建议先 -n 查看)
e2fsck -p /dev/sda1

# 只检查不修复
e2fsck -n /dev/sda1

e2fsck 按 5 个阶段检查:块/inode 位图、inode 结构、目录连通性、目录引用计数、块引用计数。超级块 s_error_counts_first_error_funcs_first_error_line 等字段可以帮助定位历史错误来源。

10.2 debugfs:低级调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 交互式进入(只读)
debugfs /dev/sda1

# 查看 inode 信息
debugfs -R "stat <inode_number>" /dev/sda1

# 查看 extent 树
debugfs -R "extents <inode_number>" /dev/sda1

# 查看超级块
debugfs -R "stats" /dev/sda1

# dump 指定文件内容
debugfs -R "dump <inode_number> /tmp/recovered" /dev/sda1

常用 debugfs 命令:

命令 用途
stat <inum> 显示 inode 详细信息
extents <inum> 显示 extent 树结构
htree <dir_inum> 显示目录 HTree 结构
logdump dump 日志内容(需 -f 挂载)
lsdel 列出已删除的 inode
undelete <inum> <name> 恢复已删除文件(有限支持)

10.3 tune2fs:调整文件系统参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 查看所有参数(包含错误历史)
tune2fs -l /dev/sda1

# 修改挂载间隔和最大挂载次数
tune2fs -i 0 -c 0 /dev/sda1

# 设置保留块比例(默认 5%,大盘可降低)
tune2fs -m 1 /dev/sda1

# 启用/禁用特性
tune2fs -O extents,uninit_bg,dir_index /dev/sda1

# 查看日志大小
tune2fs -l /dev/sda1 | grep -i journal

10.4 常见告警与处理

告警:EXT4-fs error (device sda1): ext4_find_extent:880: inode has invalid extent depth

原因:extent 树头部损坏,eh_depth 超过 EXT4_MAX_EXTENT_DEPTH(5)

处理:e2fsck -f /dev/sda1,若无法修复则需从备份恢复。

告警:EXT4-fs warning: maximal mount count reached

原因:挂载次数达到 s_max_mnt_count(默认 -1 表示不检查,但旧版本会有此限制)。

处理:tune2fs -c 0 /dev/sda1 取消限制,或 e2fsck -f 清零计数。

告警:EXT4-fs (sda1): delayed block allocation failed for inode … No space left

原因:延迟分配时发现空间不足,ext4_nonda_switch() 触发非延迟模式但仍分配失败。

处理:清理磁盘空间;检查 df -i 是否 inode 耗尽;检查 reserved block 占比(tune2fs -m)。


11. 总结

Ext4 是一个在 30 年演进中积累大量工程智慧的成熟文件系统。其核心设计决策:

  1. Extent 树 以 60 字节的 inode 内嵌空间为根,5 层 B+ 树可寻址 1 EiB 空间,查找效率高,碎片少。
  2. JBD2 日志 提供元数据原子性,三种数据模式灵活适配不同一致性需求。
  3. 延迟分配 通过推迟物理块分配到 writeback 时机,实现更大范围的连续分配,显著减少随机写碎片。
  4. HTree 目录索引 将目录查找从 O(n) 降为 O(log n),数万文件的目录查找毫秒级完成。
  5. 元数据校验和 结合 JBD2 日志校验,从磁盘静默错误到日志重放错误均有保护。

对于大多数通用场景,Ext4 的默认配置(data=ordereddelallocbarrier)是合理的起点。在高性能场景可开启 discardjournal_async_commit;数据库等需要精确控制 IO 语义的场景则应关闭 delalloc,配合 Direct I/O 使用。


参考源码(Linux 6.4-rc1):

  • fs/ext4/ext4.h — 核心数据结构定义(ext4_super_blockext4_group_descext4_inode
  • fs/ext4/ext4_extents.h — Extent 树结构体
  • fs/ext4/extents.c — Extent 树实现(ext4_find_extentext4_ext_binsearch
  • fs/ext4/inode.c — inode 操作、ext4_map_blocks、延迟分配
  • fs/ext4/file.cext4_file_read_iterext4_file_write_iter
  • fs/ext4/namei.c — 目录操作、HTree(dx_rootdx_entry
  • fs/ext4/super.c — 超级块挂载、挂载选项解析
  • fs/ext4/mballoc.c — 多块分配器
  • fs/jbd2/journal.c — JBD2 日志核心
  • fs/jbd2/transaction.c — 事务管理(jbd2_journal_start
  • include/linux/jbd2.hjournal_ttransaction_t 定义

前言

在 Linux I/O 栈中,块设备层(Block Layer)是连接文件系统与底层硬件驱动的关键枢纽。无论是 ext4 的 writepage、XFS 的 journal 写入,还是数据库的 Direct I/O,最终都会落到这一层,转化为标准的 I/O 请求发送给设备驱动。

本文基于 Linux 6.4-rc1(ac9a78681b92)内核源码,从数据结构到代码执行路径,深度解析块设备层的工作原理。理解这一层,是做存储性能分析、I/O 调度调优乃至驱动开发的必要基础。


一、块设备层架构概述

1.1 传统单队列架构(legacy single-queue)

在 Linux 3.13 以前,块设备层采用单一请求队列(request_queue)设计。所有 CPU 的 I/O 请求都需要竞争同一把 queue_lock 自旋锁,然后将请求插入电梯调度器(如 CFQ、Deadline)。这一设计在 HDD 时代游刃有余——机械盘的 seek time 比锁竞争开销高出几个数量级,调度器对 I/O 的合并与排序能显著提升吞吐量。

然而,随着 NVMe SSD 的普及,设备延迟已经降至微秒级,单队列的软件开销反而成了瓶颈:

  • 全局锁竞争:在 NUMA 多核系统上,锁争用带来严重的 cache-line bouncing
  • 单队列深度不足:高端 NVMe 设备支持 64K+ 的队列深度,单队列无法利用
  • 调度器开销:为慢速设备设计的调度算法在快速 NVMe 上反而增加了延迟

1.2 blk-mq 多队列架构

为此,Jens Axboe 在 2013-2014 年引入了 blk-mq(block multi-queue)架构(block/blk-mq.c,版权归 Jens Axboe 和 Christoph Hellwig 所有),从根本上重构了块设备层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Application (read/write syscall)


VFS / Page Cache


File System (ext4/xfs/btrfs)
│ submit_bio()

┌─────────────────────────────────────────┐
│ Block Layer (blk-mq) │
│ │
│ Per-CPU Software Queue (blk_mq_ctx) │
│ CPU0 │ CPU1 │ CPU2 │ ... │ CPUn │
│ ↘ ↓ ↙ │
│ Hardware Queue (blk_mq_hw_ctx) │
│ HQ0 HQ1 HQ2 ... HQm │
└─────────────────────────────────────────┘
│ queue_rq()

Block Driver (nvme/scsi/virtio-blk)


Physical Device

核心设计思想是两级队列

  1. 软件队列(Software Queue,blk_mq_ctx:每个 CPU 绑定一个,用于接收该 CPU 提交的 I/O 请求,无需全局加锁。
  2. 硬件队列(Hardware Queue,blk_mq_hw_ctx:对应设备的实际硬件队列(如 NVMe 的 Submission Queue),一个或多个软件队列映射到一个硬件队列。

blk-mq 同样支持 I/O 调度器(mq-deadline、bfq、kyber),但调度粒度从全局变为了硬件队列级别,大幅减少了锁竞争。


二、核心数据结构深度分析

2.1 struct bio —— I/O 操作的基本单元

bio(Block I/O)是块设备层最核心的数据结构,定义在 include/linux/blk_types.h 中。它描述了一次 I/O 操作的所有信息:目标设备、起始扇区、数据缓冲区列表、操作类型及完成回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* include/linux/blk_types.h */
struct bio {
struct bio *bi_next; /* request queue link */
struct block_device *bi_bdev;
blk_opf_t bi_opf; /* bottom bits REQ_OP, top bits req_flags */
unsigned short bi_flags; /* BIO_* below */
unsigned short bi_ioprio;
blk_status_t bi_status;
atomic_t __bi_remaining;

struct bvec_iter bi_iter;

blk_qc_t bi_cookie;
bio_end_io_t *bi_end_io;
void *bi_private;
#ifdef CONFIG_BLK_CGROUP
struct blkcg_gq *bi_blkg;
struct bio_issue bi_issue;
#ifdef CONFIG_BLK_CGROUP_IOCOST
u64 bi_iocost_cost;
#endif
#endif
/* ... integrity, encryption ... */
unsigned short bi_vcnt; /* how many bio_vec's */
unsigned short bi_max_vecs; /* max bvl_vecs we can hold */
atomic_t __bi_cnt; /* pin count */
struct bio_vec *bi_io_vec; /* the actual vec list */
struct bio_set *bi_pool;
struct bio_vec bi_inline_vecs[]; /* inline vecs for small I/O */
};

关键字段逐一解析:

字段 类型 说明
bi_next struct bio * 当多个 bio 被合并到一个 request 时,通过此指针形成链表
bi_bdev struct block_device * 目标块设备(含分区信息),通过 bi_bdev->bd_disk 可得 gendisk
bi_opf blk_opf_t(u32) 低 8 位为操作类型(enum req_op),高 24 位为标志位(REQ_SYNCREQ_FUA 等)
bi_flags unsigned short BIO 状态标志,如 BIO_CLONED(克隆 bio)、BIO_CHAIN(链式 bio)等
bi_status blk_status_t I/O 完成状态,BLK_STS_OK(0)表示成功,其余为各类错误码
__bi_remaining atomic_t 引用计数,链式 bio 时最后一个完成才触发 bi_end_io
bi_iter struct bvec_iter I/O 迭代器,记录当前扇区位置(bi_sector)、已处理字节数(bi_done)等
bi_cookie blk_qc_t 提交后返回的 cookie,用于 polling 模式(REQ_POLLED)查询完成状态
bi_end_io 函数指针 I/O 完成回调,驱动完成后调用,用于通知上层(page cache、文件系统等)
bi_private void * 供调用者保存私有上下文,内核不使用
bi_vcnt unsigned short bi_io_vec 数组中有效的 bio_vec 数量
bi_io_vec struct bio_vec * 数据段列表,每个 bio_vec 描述一个物理页面片段(page, offset, len)
bi_inline_vecs[] flexible array 尾部内联的 vec 空间,避免小 I/O 的二次内存分配

操作类型(enum req_op 是理解 bio 语义的关键:

1
2
3
4
5
6
7
8
9
10
11
/* include/linux/blk_types.h */
enum req_op {
REQ_OP_READ = 0, /* 从设备读扇区 */
REQ_OP_WRITE = 1, /* 向设备写扇区 */
REQ_OP_FLUSH = 2, /* 刷新写缓存 */
REQ_OP_DISCARD = 3, /* 丢弃扇区(TRIM/UNMAP) */
REQ_OP_SECURE_ERASE = 5, /* 安全擦除 */
REQ_OP_WRITE_ZEROES = 9, /* 写零 */
REQ_OP_ZONE_APPEND = 13, /* 追加写(ZNS 设备) */
/* ... zone management ops ... */
};

注意操作号的奇偶性有意义:奇数为写方向(TO device),偶数为读方向(FROM device),这由 op_is_write() 内联函数利用 op & 1 快速判断。

2.2 struct request —— 调度器视角的 I/O 请求

bio 是文件系统/VFS 层与块设备层的接口,而 struct request 是块设备层内部的调度单元。一个 request 可能包含多个连续地址的 bio(经过合并后)。它定义在 include/linux/blk-mq.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/* include/linux/blk-mq.h */
struct request {
struct request_queue *q;
struct blk_mq_ctx *mq_ctx; /* 提交 request 的软件队列 */
struct blk_mq_hw_ctx *mq_hctx; /* 分发到的硬件队列 */

blk_opf_t cmd_flags; /* op and common flags */
req_flags_t rq_flags; /* RQF_* 内部状态标志 */

int tag; /* 硬件队列 tag(分配时赋值) */
int internal_tag; /* 调度器内部 tag */

unsigned int timeout;

unsigned int __data_len; /* 总数据长度(字节) */
sector_t __sector; /* 当前扇区游标 */

struct bio *bio; /* bio 链表头 */
struct bio *biotail; /* bio 链表尾 */

union {
struct list_head queuelist; /* 在调度器队列中的链表节点 */
struct request *rq_next; /* 在 plug list 中的下一个 */
};

struct block_device *part; /* 目标分区 */
u64 start_time_ns; /* 请求分配时间(用于延迟统计) */
u64 io_start_time_ns; /* I/O 下发到设备的时间 */

unsigned short nr_phys_segments; /* DMA scatter-gather 段数 */
unsigned short ioprio;

enum mq_rq_state state; /* MQ_RQ_IDLE / IN_FLIGHT / COMPLETE */
atomic_t ref;

union {
struct hlist_node hash; /* 调度器合并哈希表节点 */
struct llist_node ipi_list; /* 跨 CPU 完成通知 */
};

union {
struct rb_node rb_node; /* 调度器红黑树节点 */
struct bio_vec special_vec; /* WRITE SAME 等特殊 payload */
void *completion_data;
};

union {
struct { struct io_cq *icq; void *priv[2]; } elv; /* 调度器私有 */
struct { unsigned int seq; struct list_head list;
rq_end_io_fn *saved_end_io; } flush; /* flush 序列 */
};

rq_end_io_fn *end_io; /* 完成回调 */
void *end_io_data;
};

几个关键设计细节:

  • taginternal_tag 的区别:tag 是真正下发给硬件的编号(从 blk_mq_tags 的 sbitmap 分配),internal_tag 是在使用调度器时由调度器分配的”预分配”编号。只有请求真正下发时才分配硬件 tag。
  • state 的三个取值(MQ_RQ_IDLE → MQ_RQ_IN_FLIGHT → MQ_RQ_COMPLETE)用原子读写保护,驱动通过 blk_mq_start_request() 将状态置为 IN_FLIGHT,完成时置为 COMPLETE
  • start_time_nsio_start_time_ns 的差值就是在软件层(调度器)排队等待的时间,这正是 iostat -xawait 减去 svctm 的部分。

req_flags_t(RQF_*) 描述请求的内部生命周期状态:

1
2
3
4
5
#define RQF_STARTED       (1 << 1)   /* 驱动已开始处理 */
#define RQF_FLUSH_SEQ (1 << 4) /* 属于 flush 序列 */
#define RQF_MQ_INFLIGHT (1 << 6) /* 计入 inflight 统计 */
#define RQF_IO_STAT (1 << 13) /* 计入磁盘 I/O 统计 */
#define RQF_ELV (1 << 22) /* 队列上挂有电梯调度器 */

2.3 struct request_queue —— 块设备的全局控制中心

request_queue 是每个块设备(或分区组)的核心管理结构,定义在 include/linux/blkdev.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/* include/linux/blkdev.h (节选) */
struct request_queue {
struct request *last_merge; /* 最近一次合并的 request,加速后续合并查找 */
struct elevator_queue *elevator; /* 绑定的 I/O 调度器实例 */

struct percpu_ref q_usage_counter; /* 引用计数,freeze 时变为 0 */

struct blk_queue_stats *stats;
struct rq_qos *rq_qos; /* QoS 控制链(throttle、wbt 等) */

const struct blk_mq_ops *mq_ops; /* 驱动操作函数表 */

/* 软件队列:每个 CPU 一个 */
struct blk_mq_ctx __percpu *queue_ctx;

unsigned int queue_depth;

/* 硬件队列:通过 XArray 索引 */
struct xarray hctx_table;
unsigned int nr_hw_queues;

void *queuedata; /* 驱动私有数据 */
unsigned long queue_flags; /* QUEUE_FLAG_* */

int id;
spinlock_t queue_lock;
struct gendisk *disk;

unsigned long nr_requests; /* 最大请求数 */
unsigned int rq_timeout;
struct timer_list timeout;
struct work_struct timeout_work;

struct queue_limits limits; /* 设备限制(max_sectors, seg_size 等) */

/* flush 机制 */
struct blk_flush_queue *fq;

/* requeue 队列:无法立即下发时暂存 */
struct list_head requeue_list;
spinlock_t requeue_lock;
struct delayed_work requeue_work;

int mq_freeze_depth; /* 冻结深度计数 */
wait_queue_head_t mq_freeze_wq;
struct mutex mq_freeze_lock;
/* ... */
};

request_queue 中的 struct queue_limits limits 非常重要,它记录了设备的物理约束,直接影响 I/O 分割和合并的策略:

  • max_sectors:单次 I/O 最大扇区数
  • max_segments:最大 scatter-gather 段数
  • max_segment_size:单个 DMA 段的最大字节数
  • logical_block_size:逻辑块大小(通常 512B 或 4096B)
  • physical_block_size:物理块大小(影响合并对齐)
  • discard_granularity:TRIM/DISCARD 的粒度

nr_hw_queues 决定了多队列的并行度。对于 NVMe SSD,这个值通常等于 CPU 核心数(每个 CPU 一个硬件队列);对于虚拟设备(如 virtio-blk)通常是 1。

2.4 struct blk_mq_hw_ctx —— 硬件队列状态机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/* include/linux/blk-mq.h (节选) */
struct blk_mq_hw_ctx {
struct {
spinlock_t lock;
struct list_head dispatch; /* 待下发但资源不足时暂存的请求 */
unsigned long state; /* BLK_MQ_S_STOPPED / TAG_ACTIVE / SCHED_RESTART */
} ____cacheline_aligned_in_smp;

struct delayed_work run_work; /* 延迟运行硬件队列的 work */
cpumask_var_t cpumask; /* 该 hctx 对应的 CPU 集合 */
int next_cpu;
int next_cpu_batch;

unsigned long flags; /* BLK_MQ_F_* */

void *sched_data; /* I/O 调度器私有数据 */
struct request_queue *queue;
struct blk_flush_queue *fq;
void *driver_data;

struct sbitmap ctx_map; /* 标记哪些软件队列有待处理请求 */

struct blk_mq_ctx *dispatch_from; /* 无调度器时的分发起点(轮转) */
unsigned int dispatch_busy; /* 硬件繁忙度(EWMA 估算) */

unsigned short type; /* HCTX_TYPE_DEFAULT/READ/POLL */
unsigned short nr_ctx; /* 关联的软件队列数量 */
struct blk_mq_ctx **ctxs; /* 软件队列数组 */

struct blk_mq_tags *tags; /* 硬件 tag 集合 */
struct blk_mq_tags *sched_tags; /* 调度器 tag 集合 */

unsigned long queued; /* 累计入队请求数(调试统计) */
unsigned long run; /* 累计下发请求数 */

unsigned int numa_node;
unsigned int queue_num; /* hctx 编号 */
atomic_t nr_active; /* tag set 共享时的活跃请求数 */
/* ... debugfs, cpuhp ... */
};

ctx_map 是一个 sbitmap,每个 bit 对应一个软件队列(blk_mq_ctx)。当某个软件队列有新请求时,对应 bit 被置位(blk_mq_hctx_mark_pending());硬件队列运行时遍历所有置位的软件队列取出请求。这个设计避免了逐个遍历所有 CPU 队列的开销。

2.5 struct blk_mq_ctx —— 软件队列(Per-CPU)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* block/blk-mq.h */
struct blk_mq_ctx {
struct {
spinlock_t lock;
struct list_head rq_lists[HCTX_MAX_TYPES]; /* DEFAULT/READ/POLL 三种类型 */
} ____cacheline_aligned_in_smp;

unsigned int cpu;
unsigned short index_hw[HCTX_MAX_TYPES]; /* 该 ctx 在 hctx->ctx_map 中的 bit 位 */
struct blk_mq_hw_ctx *hctxs[HCTX_MAX_TYPES]; /* 关联的硬件队列 */

struct request_queue *queue;
struct blk_mq_ctxs *ctxs;
struct kobject kobj;
} ____cacheline_aligned_in_smp;

____cacheline_aligned_in_smp 确保每个 CPU 的软件队列数据独占一条 cache line,消除 false sharing。rq_lists 按请求类型分三个链表,允许驱动(通过 enum hctx_type)针对不同操作类型(如 READ vs POLL)映射到不同的硬件队列。


三、bio 的完整生命周期

3.1 bio 分配:bio_alloc_bioset()

bio 通常通过 bio_alloc_bioset() 从内存池(mempool)分配,而不是直接用 kmalloc。这是为了保证在内存紧张时,I/O 路径仍能前进而不死锁。核心逻辑在 block/bio.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* block/bio.c */
struct bio *bio_alloc_bioset(struct block_device *bdev, unsigned short nr_vecs,
blk_opf_t opf, gfp_t gfp_mask,
struct bio_set *bs)
{
gfp_t saved_gfp = gfp_mask;
struct bio *bio;
void *p;

/* Per-CPU 缓存快速路径:REQ_ALLOC_CACHE 标志 + 小 bio */
if (opf & REQ_ALLOC_CACHE) {
if (bs->cache && nr_vecs <= BIO_INLINE_VECS) {
bio = bio_alloc_percpu_cache(bdev, nr_vecs, opf, gfp_mask, bs);
if (bio)
return bio;
} else {
opf &= ~REQ_ALLOC_CACHE;
}
}

/*
* 防死锁:当运行在 submit_bio_noacct() 上下文(即任何块驱动内部)时,
* 已有 bio 在当前任务的 bio_list 中待提交。若此时内存紧张并触发直接回收,
* 可能导致死锁。因此先尝试不阻塞分配,失败时将待提交 bio 转发给救援线程。
*/
if (current->bio_list &&
(!bio_list_empty(&current->bio_list[0]) ||
!bio_list_empty(&current->bio_list[1])) &&
bs->rescue_workqueue)
gfp_mask &= ~__GFP_DIRECT_RECLAIM;

p = mempool_alloc(&bs->bio_pool, gfp_mask);
if (!p && gfp_mask != saved_gfp) {
punt_bios_to_rescuer(bs); /* 将阻塞的 bio 交给 rescuer workqueue */
gfp_mask = saved_gfp;
p = mempool_alloc(&bs->bio_pool, gfp_mask);
}
if (unlikely(!p))
return NULL;

bio = p + bs->front_pad; /* front_pad 供上层(如 md、dm)存放私有数据 */

/* 根据 nr_vecs 决定是否需要从 bvec_pool 额外分配 bvec 数组 */
if (nr_vecs > BIO_INLINE_VECS) {
struct bio_vec *bvl = bvec_alloc(&bs->bvec_pool, &nr_vecs, gfp_mask);
/* ... */
bio_init(bio, bdev, bvl, nr_vecs, opf);
} else if (nr_vecs) {
bio_init(bio, bdev, bio->bi_inline_vecs, BIO_INLINE_VECS, opf);
} else {
bio_init(bio, bdev, NULL, 0, opf);
}

bio->bi_pool = bs;
return bio;
}
EXPORT_SYMBOL(bio_alloc_bioset);

bvec_slabs 的分级设计 值得关注——bio.c 定义了四种规格的 bvec slab:

1
2
3
4
5
6
static struct biovec_slab bvec_slabs[] __read_mostly = {
{ .nr_vecs = 16, .name = "biovec-16" },
{ .nr_vecs = 64, .name = "biovec-64" },
{ .nr_vecs = 128, .name = "biovec-128" },
{ .nr_vecs = BIO_MAX_VECS, .name = "biovec-max" },
};

小于等于 4 个 vec 的 bio 使用 bi_inline_vecs(内联在 bio 结构体尾部),无需额外分配。这对于大量小 I/O(如 4K 随机读写)的场景非常重要。

3.2 bio 提交:submit_bio()submit_bio_noacct()

上层(文件系统、Direct I/O)调用 submit_bio() 将 bio 送入块设备层:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* block/blk-core.c */
void submit_bio(struct bio *bio)
{
if (bio_op(bio) == REQ_OP_READ) {
task_io_account_read(bio->bi_iter.bi_size);
count_vm_events(PGPGIN, bio_sectors(bio));
} else if (bio_op(bio) == REQ_OP_WRITE) {
count_vm_events(PGPGOUT, bio_sectors(bio));
}

submit_bio_noacct(bio);
}
EXPORT_SYMBOL(submit_bio);

submit_bio() 做的事很简单:更新任务 I/O 统计(/proc/<pid>/io)和全局 VM 事件计数器,然后调用 submit_bio_noacct()

submit_bio_noacct() 是真正的入口,它会进行 block cgroup 检查、throttling、wbt(writeback throttling)等 QoS 处理。对于 blk-mq 设备,最终调用 blk_mq_submit_bio()

3.3 blk_mq_submit_bio() —— blk-mq 提交路径

这是 blk-mq 架构的提交核心,位于 block/blk-mq.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/* block/blk-mq.c */
void blk_mq_submit_bio(struct bio *bio)
{
struct request_queue *q = bdev_get_queue(bio->bi_bdev);
struct blk_plug *plug = blk_mq_plug(bio);
const int is_sync = op_is_sync(bio->bi_opf);
struct blk_mq_hw_ctx *hctx;
struct request *rq;
unsigned int nr_segs = 1;
blk_status_t ret;

/* 1. bounce buffer 处理(高端内存设备的 DMA 限制) */
bio = blk_queue_bounce(bio, q);

/* 2. 按设备限制分割 bio(超过 max_sectors 或 max_segments 则拆分) */
if (bio_may_exceed_limits(bio, &q->limits)) {
bio = __bio_split_to_limits(bio, &q->limits, &nr_segs);
if (!bio)
return;
}

/* 3. 数据完整性预处理(T10 PI/DIX) */
if (!bio_integrity_prep(bio))
return;

bio_set_ioprio(bio);

/* 4. 尝试合并到 plug list 中已有的 request(plug 合并,最快路径) */
rq = blk_mq_get_cached_request(q, plug, &bio, nr_segs);
if (!rq) {
if (!bio)
return;
/* 5. 分配新的 request */
rq = blk_mq_get_new_requests(q, plug, bio, nr_segs);
if (unlikely(!rq))
return;
}

trace_block_getrq(bio);
rq_qos_track(q, rq, bio);

/* 6. 将 bio 关联到 request */
blk_mq_bio_to_request(rq, bio, nr_segs);

/* 7. 内联加密 keyslot 获取 */
ret = blk_crypto_rq_get_keyslot(rq);
if (ret != BLK_STS_OK) {
bio->bi_status = ret;
bio_endio(bio);
blk_mq_free_request(rq);
return;
}

/* 8. flush 操作特殊处理 */
if (op_is_flush(bio->bi_opf)) {
blk_insert_flush(rq);
return;
}

/* 9. plug 路径:请求暂存在 plug list,unplug 时批量下发 */
if (plug) {
blk_add_rq_to_plug(plug, rq);
return;
}

/* 10. 直接下发路径 */
hctx = rq->mq_hctx;
if ((rq->rq_flags & RQF_ELV) ||
(hctx->dispatch_busy && (q->nr_hw_queues == 1 || !is_sync))) {
blk_mq_insert_request(rq, 0);
blk_mq_run_hw_queue(hctx, true);
} else {
blk_mq_run_dispatch_ops(q, blk_mq_try_issue_directly(hctx, rq));
}
}

plug 机制是性能优化的关键:调用者在一批 I/O 开始前调用 blk_start_plug(),所有 bio 被暂存在 per-task 的 plug 链表中(不加任何锁),I/O 结束后调用 blk_finish_plug() 触发 unplug,此时进行合并和批量下发。这本质上是将调度从内核侧延迟到应用侧,减少了加锁次数和 context switch。

3.4 bio 完成:bio_endio()

当设备驱动完成 I/O 后,会调用 blk_mq_end_request(),最终触发 bio_endio()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* block/bio.c */
void bio_endio(struct bio *bio)
{
again:
/* 链式 bio:只有所有子 bio 都完成才触发父 bio 的回调 */
if (!bio_remaining_done(bio))
return;

/* 数据完整性验证 */
if (!bio_integrity_endio(bio))
return;

rq_qos_done_bio(bio);

/* blktrace 追踪点 */
if (bio->bi_bdev && bio_flagged(bio, BIO_TRACE_COMPLETION)) {
trace_block_bio_complete(bdev_get_queue(bio->bi_bdev), bio);
bio_clear_flag(bio, BIO_TRACE_COMPLETION);
}

/* 尾递归优化:避免链式 bio 栈溢出 */
if (bio->bi_end_io == bio_chain_endio) {
bio = __bio_chain_endio(bio);
goto again;
}

blk_throtl_bio_endio(bio); /* throttle 记账 */
bio_uninit(bio); /* 释放 cgroup 引用等 */
if (bio->bi_end_io)
bio->bi_end_io(bio); /* 调用上层回调(如 page cache 的 end_bio_read) */
}
EXPORT_SYMBOL(bio_endio);

注意 bio_chain_endio 分支的尾递归优化:当多个 bio 通过 bio_chain() 串联时,如果用普通递归处理,深度链可能导致栈溢出。goto again 将递归转为循环,同时 __bio_chain_endio 返回父 bio 让循环继续处理,这是内核中防止栈溢出的经典技巧。


四、blk-mq 多队列架构详解

4.1 队列映射:CPU → 软件队列 → 硬件队列

blk-mq 使用两级映射:

  1. CPU → 软件队列(blk_mq_ctx:通过 per_cpu 机制,每个 CPU 直接访问自己的 queue_ctx
  2. 软件队列 → 硬件队列(blk_mq_hw_ctx:通过 ctx->hctxs[type],不同类型(DEFAULT/READ/POLL)的请求可以路由到不同硬件队列。

映射关系由 struct blk_mq_queue_map 描述:

1
2
3
4
5
struct blk_mq_queue_map {
unsigned int *mq_map; /* CPU ID → 硬件队列索引 */
unsigned int nr_queues;
unsigned int queue_offset;
};

默认映射算法(blk_mq_map_queues())将 CPU 均匀分散到硬件队列,NUMA 感知版本会优先将 CPU 映射到本 NUMA 节点的硬件队列,减少跨 NUMA 内存访问。

4.2 请求分发:blk_mq_dispatch_rq_list()

当硬件队列需要处理请求时,blk_mq_dispatch_rq_list() 负责将请求下发给驱动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/* block/blk-mq.c */
bool blk_mq_dispatch_rq_list(struct blk_mq_hw_ctx *hctx, struct list_head *list,
unsigned int nr_budgets)
{
struct request_queue *q = hctx->queue;
struct request *rq;
int queued = 0;
blk_status_t ret = BLK_STS_OK;
LIST_HEAD(zone_list);
bool needs_resource = false;

if (list_empty(list))
return false;

queued = 0;
do {
struct blk_mq_queue_data bd;

rq = list_first_entry(list, struct request, queuelist);
/* 准备分发(预算检查、tag 分配等) */
prep = blk_mq_prep_dispatch_rq(rq, !nr_budgets);
if (prep != PREP_DISPATCH_OK)
break;

list_del_init(&rq->queuelist);
bd.rq = rq;
bd.last = list_empty(list); /* 告知驱动是否是最后一个请求 */

if (nr_budgets)
nr_budgets--;

/* 调用驱动的 queue_rq 回调,真正下发给硬件 */
ret = q->mq_ops->queue_rq(hctx, &bd);
switch (ret) {
case BLK_STS_OK:
queued++;
break;
case BLK_STS_RESOURCE:
needs_resource = true;
fallthrough;
case BLK_STS_DEV_RESOURCE:
/* 设备资源不足,暂停并将未下发请求回放到 dispatch 链表 */
blk_mq_handle_dev_resource(rq, list);
goto out;
case BLK_STS_ZONE_RESOURCE:
/* ZNS 设备 zone 资源不足,移到 zone_list 继续处理其他 zone */
blk_mq_handle_zone_resource(rq, &zone_list);
needs_resource = true;
break;
default:
blk_mq_end_request(rq, ret);
}
} while (!list_empty(list));
out:
/* 将 zone_list 中的请求合并回 list */
if (!list_empty(&zone_list))
list_splice_tail_init(&zone_list, list);
/* ...处理未下发的请求,考虑是否需要 restart... */
}

bd.last 字段很有趣——它允许驱动实现”批量提交”优化。NVMe 驱动利用此标志决定何时真正 ring doorbell(更新提交队列尾指针),当 last=false 时只填写 SQ Entry 但暂不通知设备,last=true 时才一次性通知,大幅减少 MMIO 写操作次数(每次 MMIO 写耗时数百纳秒)。

4.3 请求完成:跨 CPU 的 IPI 机制

在 NUMA 系统或中断亲和性配置不当时,I/O 完成中断可能在与提交请求不同的 CPU 上触发。blk-mq 有两种完成路径:

  1. 本地完成blk_mq_complete_request() → 直接调用 rq->q->mq_ops->complete(rq)
  2. 跨 CPU 完成(IPI):通过 llistRAISE_SOFTIRQ(BLOCK_SOFTIRQ) 将完成通知发送到请求所在 CPU,再在 softirq 上下文处理
1
static DEFINE_PER_CPU(struct llist_head, blk_cpu_done);

blk_cpu_done 是 per-CPU 的无锁链表,跨 CPU 完成时将 request 通过 llist_add() 挂到目标 CPU 的链表上,然后发送 IPI 唤醒 softirq 处理。


五、请求合并机制深度分析

5.1 合并的类型

I/O 合并是块设备层的重要优化,将多个 bio/request 合并为一个大请求,减少 I/O 操作次数。blk-merge.c 实现了三种合并:

类型 说明 条件
后向合并(Back Merge) 新 bio 追加到 request 末尾 req_end_sector == bio_start_sector
前向合并(Front Merge) 新 bio 插入到 request 头部 bio_end_sector == req_start_sector
request 间合并(Elevator Merge) 两个 request 合并为一个 调度器负责,检查相邻性

5.2 合并判断:blk_try_merge()

1
2
3
4
5
6
7
8
9
10
11
/* block/blk-merge.c */
enum elv_merge blk_try_merge(struct request *rq, struct bio *bio)
{
if (blk_discard_mergable(rq))
return ELEVATOR_DISCARD_MERGE;
else if (blk_rq_pos(rq) + blk_rq_sectors(rq) == bio->bi_iter.bi_sector)
return ELEVATOR_BACK_MERGE; /* request 末尾扇区 == bio 起始扇区 */
else if (blk_rq_pos(rq) - bio_sectors(bio) == bio->bi_iter.bi_sector)
return ELEVATOR_FRONT_MERGE; /* bio 末尾扇区 == request 起始扇区 */
return ELEVATOR_NO_MERGE;
}

5.3 后向合并的完整检查:ll_back_merge_fn()

仅扇区相邻还不够,还需要通过硬件限制检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* block/blk-merge.c */
int ll_back_merge_fn(struct request *req, struct bio *bio, unsigned int nr_segs)
{
/* 检查是否会产生 DMA gap(虚拟边界约束) */
if (req_gap_back_merge(req, bio))
return 0;

/* 数据完整性 gap 检查(PI/DIF 数据) */
if (blk_integrity_rq(req) &&
integrity_req_gap_back_merge(req, bio))
return 0;

/* 内联加密:key context 必须匹配 */
if (!bio_crypt_ctx_back_mergeable(req, bio))
return 0;

/* 合并后不能超过设备最大 sectors 限制 */
if (blk_rq_sectors(req) + bio_sectors(bio) >
blk_rq_get_max_sectors(req, blk_rq_pos(req))) {
req_set_nomerge(req->q, req); /* 标记该 req 不可再合并,避免重复检查 */
return 0;
}

return ll_new_hw_segment(req, bio, nr_segs); /* 检查 segments 数量限制 */
}

5.4 调度器的哈希加速

为了快速找到可合并的 request,调度器维护一个以扇区号为键的哈希表(RQF_HASHED 标志表示请求在哈希表中)。每次 bio 提交时,通过 elv_merge() 在哈希表中 O(1) 查找末尾扇区匹配的 request,而不是遍历全部请求。

request_queuelast_merge 字段更进一步——它缓存了上一次合并的 request 指针,因为 I/O 模式往往具有时间局部性,顺序写入时后续 bio 极可能与同一个 request 合并。


六、块设备 I/O 统计:/proc/diskstats 的数据来源

iostat 的数据全部来自 /proc/diskstats,而 diskstats 的数据由块设备层在请求生命周期的关键节点记录。核心结构是 struct disk_stats(per-CPU)和 part_stat_* 系列宏。

diskstats 的字段与内核计数器映射:

1
2
3
4
5
6
7
8
9
/proc/diskstats 字段:
reads_completed → part_stat_add(part, ios[STAT_READ], 1)
sectors_read → part_stat_add(part, sectors[STAT_READ], ...)
time_reading_ms → part_stat_add(part, nsecs[STAT_READ], nsecs) / NSEC_PER_MSEC
reads_merged → part_stat_inc(part, merges[STAT_READ])
writes_completed → part_stat_add(part, ios[STAT_WRITE], 1)
...
io_in_progress → part_in_flight() ← 实时值,非累计
time_io_ms → part_stat_add(part, io_ticks, ...)

记录时机:

  • blk_account_io_start(rq):request 下发到设备时,递增 in_flight 计数,记录 start_time_ns
  • blk_account_io_done(rq, now):request 完成时,更新 iossectorsnsecs,递减 in_flight

await(平均 I/O 等待时间)= nsecs[READ] / ios[READ],包含排队时间和设备服务时间。svctm(已被 iostat 废弃,不再可靠)原本估算纯设备服务时间。

io_ticks 是设备繁忙时间的累计,当 in_flight > 0 时每个 tick 递增,对应 iostat 的 %util


七、实际调试技巧

7.1 blktrace:内核级 I/O 追踪

blktrace 利用内核 tracefs/relay 机制,可以捕获 bio/request 在块设备层每个阶段的事件(队列、合并、下发、完成等):

1
2
3
4
5
6
7
8
# 追踪 nvme0n1 设备 10 秒
blktrace -d /dev/nvme0n1 -w 10 -o /tmp/nvme_trace

# 分析追踪结果
blkparse /tmp/nvme_trace.blktrace.* | head -50

# 生成 I/O 模式可视化(需要 seekwatcher)
btt -i /tmp/nvme_trace.blktrace.0

blktrace 事件字母含义:

字母 阶段 说明
Q Queued bio 进入块设备层
G Get request 分配 request 结构
M Merge bio 被合并到已有 request
I Insert request 插入 I/O 调度器
D Issue request 下发给驱动
C Complete request 完成
P Plug 设备被 plugged
U Unplug 设备被 unplugged,触发批量下发

从 Q 到 D 的时间是软件栈延迟,D 到 C 是设备服务时间。

7.2 BPF 工具:biolatency 和 biosnoop

BCC(BPF Compiler Collection)提供了更灵活的 I/O 分析工具:

1
2
3
4
5
6
7
8
9
10
11
# 统计 I/O 延迟分布(直方图)
biolatency -d nvme0n1 10

# 实时追踪每个 I/O 操作
biosnoop -d nvme0n1

# 跟踪 blk-mq 请求队列深度
blkqueue # 需要 bpftrace

# 使用 bpftrace 自定义:统计 submit_bio 调用栈
bpftrace -e 'kprobe:submit_bio { @[kstack] = count(); }'

7.3 系统调优参数

调优块设备层的关键 sysfs 参数(以 nvme0n1 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 查看/设置 I/O 调度器
cat /sys/block/nvme0n1/queue/scheduler
echo mq-deadline > /sys/block/nvme0n1/queue/scheduler

# 队列深度(request_queue.nr_requests)
cat /sys/block/nvme0n1/queue/nr_requests
echo 256 > /sys/block/nvme0n1/queue/nr_requests

# 最大 I/O 大小(对应 queue_limits.max_sectors_kb)
cat /sys/block/nvme0n1/queue/max_sectors_kb

# read-ahead 大小
cat /sys/block/nvme0n1/queue/read_ahead_kb
echo 128 > /sys/block/nvme0n1/queue/read_ahead_kb

# 硬件队列数量(只读)
cat /sys/block/nvme0n1/mq/

# 查看各硬件队列的请求统计
cat /sys/kernel/debug/block/nvme0n1/hctx0/run
cat /sys/kernel/debug/block/nvme0n1/hctx0/queued

7.4 使用 debugfs 分析 blk-mq 状态

Linux 提供了详细的 blk-mq debugfs 接口(需要 CONFIG_BLK_DEBUG_FS=y):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 查看 request_queue 整体状态
cat /sys/kernel/debug/block/nvme0n1/state

# 查看硬件队列状态
ls /sys/kernel/debug/block/nvme0n1/
# 输出: hctx0 hctx1 ... hctxN

# 查看特定硬件队列的 dispatch 队列中的请求
cat /sys/kernel/debug/block/nvme0n1/hctx0/dispatch

# 查看软件队列
cat /sys/kernel/debug/block/nvme0n1/hctx0/ctx0/rq_list

# 查看 tag 使用情况
cat /sys/kernel/debug/block/nvme0n1/hctx0/tags

八、blk-mq 性能调优实战

8.1 选择合适的 I/O 调度器

1
2
3
4
none      → 无调度器,适合 NVMe 等极低延迟设备(高并发随机 I/O)
mq-deadline → deadline 的 blk-mq 版,适合混合负载,防止写饥饿
kyber → 基于 token 的轻量级调度器,适合低延迟 SSD(latency target)
bfq → 基于比例带宽分配,适合桌面/混合负载(公平性好但开销大)

对于 NVMe SSD 数据库服务器,通常推荐 nonemq-deadline(设置合理的 write_expire 以控制写入延迟)。

8.2 NUMA 感知调优

在多 NUMA 节点系统上,确保设备中断亲和性和进程调度在同一 NUMA 节点:

1
2
3
4
5
# 查看 nvme0n1 中断的 CPU 亲和性
cat /proc/irq/$(ls /sys/block/nvme0n1/device/msi_irqs/ | head -1)/smp_affinity_list

# 将 I/O 密集进程绑定到 NVMe 控制器所在 NUMA 节点
numactl --cpunodebind=0 --membind=0 fio --name=test ...

九、小结

本文从源码层面系统梳理了 Linux 块设备层的核心机制:

  1. 架构演进:从单队列到 blk-mq 多队列,解决了 NVMe 时代的扩展性问题
  2. 核心数据结构bio 描述单次 I/O,request 是调度器视角的单元,request_queue 是设备的控制中枢,blk_mq_hw_ctx/blk_mq_ctx 实现了两级队列的无锁化设计
  3. bio 生命周期:从 bio_alloc_bioset() 的内存池设计,到 blk_mq_submit_bio() 的 plug/unplug 批处理优化,再到 bio_endio() 的链式 bio 尾递归处理
  4. 请求合并:通过哈希加速和 last_merge 缓存,在满足 DMA 约束的前提下最大化合并效果
  5. 统计与调试/proc/diskstats 的数据来源,以及 blktrace、BPF 工具链的使用

下一篇将聚焦 I/O 调度器(mq-deadline、bfq、kyber)的实现原理,深入分析请求排序、带宽公平分配和延迟控制算法。


参考资料

0%