第三部分:Scheduler——vLLM 的大脑

简介

Scheduler 是 vLLM 的核心调度器。在每个微秒级的时间窗口内,它都要做出关键决策:处理哪些 request、计算多少个 token、何时 preempt request,以及如何最大化 GPU 利用率。本文将深入探讨 Scheduler 的算法与实现。

调度挑战

问题空间

在任意时刻,Scheduler 必须处理:

  • 等待中的 request:等待处理的新 prompt
  • 运行中的 request:正处于 decode 阶段的持续生成任务
  • 资源限制:有限的 GPU 内存和计算预算
  • 目标冲突:最小化延迟 vs. 最大化吞吐量
  • 动态负载:request 异步到达和完成

没有简单的解决方案

与传统批处理不同,LLM 推理服务面临独特挑战:

  1. 请求长度不固定:无法预测完成时间
  2. 内存随序列长度增长:不仅仅受计算限制
  3. prefill 与 decode 的不对称性:prefill 每个 token 耗时约为 decode 的 100 倍
  4. 共享 KV cache:内存决策影响所有 request

Continuous Batching

vLLM 的核心创新:continuous batching(又称迭代级批处理)。

传统静态批处理

1
2
3
4
5
6
7
8
9
10
11
12
13
# Static batching - wait for batch to fill
batch = []
while len(batch) < batch_size:
batch.append(wait_for_request())

# Process entire batch
outputs = model.forward(batch)

# Wait for ALL requests to finish
while any_request_incomplete(batch):
outputs = model.forward(batch)

# All done - start next batch

问题

  • 队头阻塞:快速 request 需等待慢速 request
  • 低 GPU 利用率:随着 request 完成,batch 规模不断缩小
  • 高延迟:必须等待 batch 填满

Continuous Batching

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Continuous batching - add/remove every iteration
running = []

while True:
# Remove finished requests
running = [r for r in running if not r.finished]

# Add new requests if there's capacity
while can_fit_new_request() and waiting_requests:
running.append(waiting_requests.pop())

# Process current batch
outputs = model.forward(running)

# Immediately continue - no waiting!

优点

  • 无队头阻塞:request 完成后立即离队
  • 持续高 GPU 利用率:始终维持最大 batch 大小
  • 更低延迟:新 request 可立即开始处理

Scheduler 架构

文件位置vllm/v1/core/sched/scheduler.py

核心数据结构

Scheduler 维护 requestswaitingrunningskipped_waiting,并持有 KVCacheManager / EncoderCacheManager 等资源管理器。其中 max_num_scheduled_tokens 的来源是:

1
2
3
4
5
self.max_num_scheduled_tokens = (
self.scheduler_config.max_num_scheduled_tokens
if self.scheduler_config.max_num_scheduled_tokens
else self.scheduler_config.max_num_batched_tokens
)

也就是说,max_num_batched_tokens 现在是 fallback,而不是唯一来源。

Request 状态

当前 V1 的 request 状态比“WAITING → RUNNING → FINISHED”更细:

  • WAITING
  • WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR
  • WAITING_FOR_REMOTE_KVS
  • WAITING_FOR_STREAMING_REQ
  • RUNNING
  • PREEMPTED
  • 多个 finished 子状态:FINISHED_STOPPEDFINISHED_LENGTH_CAPPEDFINISHED_ABORTEDFINISHED_IGNOREDFINISHED_ERRORFINISHED_REPETITION

因此,更准确的流转应理解为:

  • 新请求通常从 WAITING 进入 RUNNING
  • 被抢占的请求会变成 PREEMPTED 并重新入队
  • 恢复执行时是 PREEMPTED → RUNNING
  • 结束时会进入某个具体的 finished 子状态,而不是单一的 FINISHED

Request 队列

当前 RequestQueue 的核心 API 是:

1
2
3
4
queue.add_request(request)
request = queue.pop_request()
request = queue.peek_request()
queue.prepend_request(request)

如果启用 priority 策略,优先级大小规律也与很多直觉相反:数值越小,优先级越高PriorityRequestQueue 直接依赖 Request.__lt__,比较顺序是:

  1. priority(更小者优先)
  2. arrival_time(更早者优先)
  3. request_id

调度算法

整体流程

schedule() 的核心思路仍然是“先处理 running,再在条件允许时接纳 waiting”。SchedulerOutput 会分别携带:

  • scheduled_new_reqs
  • scheduled_cached_reqs
  • num_scheduled_tokens: dict[str, int]
  • total_num_scheduled_tokens
  • scheduled_spec_decode_tokens
  • scheduled_encoder_inputs
  • finished_req_ids
  • preempted_req_ids
  • 以及 prefix / encoder / connector 相关的附加信息

可以把主循环简化理解为:

1
2
3
4
5
6
7
8
def schedule(self) -> SchedulerOutput:
token_budget = self.max_num_scheduled_tokens

# 1. 先调度 running request
# 2. 必要时执行 preemption
# 3. 仅在本轮没有 preempt 时尝试接纳 waiting request
# 4. 构造包含 per-request token 预算、spec decode、
# encoder inputs 和 finished/preempted IDs 的 SchedulerOutput

第一步:Token Budget

token budget 的概念没有变化:它仍然是当前迭代最重要的“算力 / latency / activation memory”上限之一。当前实现用 self.max_num_scheduled_tokens 作为每轮 budget,并在 chunked prefill、running request 续跑、spec decode 等路径上共享这份预算。

第二步:调度运行中的 Request

当前 scheduler 不再显式维护“prefill phase / decode phase”两套完全独立的 API。更准确的做法是根据:

  • request.num_computed_tokens
  • request.num_tokens_with_spec
  • request.num_output_placeholders

之间的差值来决定本轮还需要为该 request 安排多少 token。

对 running request 来说,scheduler 主要会:

  1. 计算本轮 num_new_tokens
  2. 结合 long_prefill_token_threshold 等配置裁剪超长 prefill
  3. 为已命中的 prefix cache / 外部 KV transfer / speculative lookahead 计算额外上下文
  4. 调用 allocate_slots() 尝试拿到新 slot
  5. 若拿不到 slot,则按当前策略 preempt 其他 request 或 preempt 当前 request

因此,比起“给 request 写回 request.num_scheduled_tokens = ...”,当前实现更准确的说法是:每轮调度数量被记录在 SchedulerOutput.num_scheduled_tokens[request_id]

第三步:调度等待中的 Request

一个新 request 要进入本轮 batch,通常会经历这些检查:

  1. waitingskipped_waiting 中选择可调度的队列
  2. 处理 structured output grammar、remote KV、streaming 等阻塞状态
  3. 先调用 get_computed_blocks() 查找 prefix cache 命中
  4. 再用 can_fit_full_sequence() 判断完整序列是否可以被系统接纳
  5. 通过 allocate_slots() 为本轮真正需要的 token 分配 slot

这里有两个关键点:

  • scheduler 维护的是 waiting + skipped_waiting 两条队列,而不是单一 waiting queue
  • 只有当本轮没有发生 preemption 时,scheduler 才会继续接纳 waiting request

第四步:处理 Preemption

preemption 通过 _preempt_request() 完成,流程更接近:

1
2
3
4
5
6
def _preempt_request(self, request, timestamp):
self.kv_cache_manager.free(request)
self.encoder_cache_manager.free(request)
request.status = RequestStatus.PREEMPTED
request.num_preemptions += 1
self.waiting.prepend_request(request)

需要注意:

  • request 会以 PREEMPTED 状态重新入队,而不是简单地回到 WAITING
  • preemption 后确实需要重新调度,但如果 prefix cache 还能命中完整 block,后续恢复执行时不一定要“从零开始重算”
  • victim 选择也不是固定的“进度最少者优先”:在 priority 模式下,会先牺牲较差优先级的 running request;FCFS 模式下则更接近按当前运行队列尾部顺序处理

高级调度策略

Chunked Prefill

chunked prefill 仍然存在,但当前实现不是 self.chunk_size 这种字段驱动。更贴近源码的配置包括:

  • enable_chunked_prefill
  • long_prefill_token_threshold
  • max_num_partial_prefills
  • max_long_partial_prefills

因此,当前行为更像是:

  • 对超长 prefill request 截断本轮 num_new_tokens
  • 在 token budget 内把长 prompt 分摊到多轮
  • 让长 prompt 不至于一次耗尽整个 batch 配额

优先级调度

当前 vLLM 只支持两种调度策略:

  • fcfs
  • priority

priority 模式下数字越小越先执行。可以这样理解:

1
2
3
# Lower numeric value = higher scheduling priority
priority = 0 # interactive / urgent
priority = 10 # background

支持 Speculative Decoding

当前 scheduler 对 speculative decoding 的感知体现在:

  • request 上的 spec_token_ids
  • SchedulerOutput.scheduled_spec_decode_tokens
  • num_lookahead_tokens
  • 输出更新阶段对 draft token 的接受 / 拒绝处理

因此,比起 main_model.verify(draft_tokens) 这种直观伪代码,更准确的描述是:scheduler 会为 lookahead / draft token 预留 slot,并通过 scheduled_spec_decode_tokens 把本轮 spec decode 信息传给后续执行与输出更新路径。

Request 重排序

当前本地源码里并没有一个按“预估 prefix cache 命中率”对 waiting requests 进行重排序的 reorder_for_cache_hits() 实现。waiting 请求的顺序主要由 FCFS / priority queue 决定,再叠加 skipped_waiting 的恢复逻辑。

资源管理

KV Cache 分配

更贴近源码的准入流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
computed_blocks, num_cached_tokens = kv_cache_manager.get_computed_blocks(request)

if kv_cache_manager.can_fit_full_sequence(
request,
num_new_computed_tokens=num_cached_tokens,
new_computed_blocks=computed_blocks,
):
new_blocks = kv_cache_manager.allocate_slots(
request,
num_new_tokens,
num_new_computed_tokens=num_cached_tokens,
new_computed_blocks=computed_blocks,
)

当存在 sliding window、external KV transfer、spec decode 或 encoder-decoder cross-attention 时,allocate_slots() 还会综合这些上下文一起计算需要分配的 block 数量。

内存压力处理

当前 scheduler 的内存压力处理更接近“尝试为 running request 分配 slot,失败时根据当前策略 preempt”。prefix cache 的显式淘汰接口是 kv_cache_manager.evict_blocks(block_ids),它按 block id 清理缓存索引。

Encoder Cache 管理

多模态 / encoder-decoder 场景下,当前 request 并不是暴露 encoder_input_ids 这个简化字段。scheduler 会通过 request.mm_features_try_schedule_encoder_inputs(),以及 EncoderCacheManager.can_allocate() / allocate() / check_and_update_cache() 来判断 encoder side 是否有容量。

性能指标

Scheduler 统计信息

SchedulerStats 的重点字段包括:

  • num_running_reqs
  • num_waiting_reqs
  • kv_cache_usage
  • encoder_cache_usage
  • prefix_cache_stats
  • connector_prefix_cache_stats
  • kv_cache_eviction_events
  • spec_decoding_stats
  • perf_stats

而“本轮 preempt 了多少 request”这类信息,当前是放在 IterationStats.num_preempted_reqs 里,而不是 SchedulerStats.num_preempted

优化目标

因此,当前 scheduler 更像是在平衡这些目标:

  1. 最大化吞吐量:尽可能在 token budget 内安排更多有效计算
  2. 最小化延迟:优先保持 running request 的连续推进
  3. 控制长 prompt 影响:通过 chunked prefill / long prefill threshold 避免大 prompt 独占
  4. 减少无谓 preemption:让 KV cache 与 encoder cache 的压力保持可控
  5. 尽可能利用 prefix cache / external KV:减少重复 prefill 计算

示例:调度时间线

下面给出一个更接近当前实现语义的简化时间线。注意这里使用的是 scheduler_output.num_scheduled_tokens[req_id] 这个“每轮输出”,而不是给 request 对象写回一个持久字段。

初始状态

1
2
3
waiting = [A(prompt=100), B(prompt=50)]
running = []
token_budget = 200

迭代 1

  • AB 都还是新请求
  • scheduler 分别检查 prefix cache 命中、完整序列准入与 KV slot 分配
  • 若两者都能被接纳,则输出里会类似地记录:
1
2
3
4
scheduler_output.num_scheduled_tokens = {
"A": 100,
"B": 50,
}

迭代 2

  • AB 进入 running 集合
  • 若此时到来一个超长请求 C,scheduler 会先为 running request 预留预算,再根据 long_prefill_token_threshold 与剩余 budget 决定 C 这一轮最多能推进多少 token
  • 如果本轮发生了 preemption,则 waiting request 的接纳会被延后到下一轮

后续迭代

  • request 的自然完成不是在 schedule() 里直接调用某个 finish_request() helper,而是要等 model output 返回后,由 scheduler.update_from_output(...) 更新状态并判断 stop / length cap / error 等 finished 原因
  • B 被 preempt,scheduler 会把它以 PREEMPTED 状态重新入队;等资源恢复后,它会再次进入 RUNNING

调度策略

当前 vLLM 暴露的调度策略只有两种:

FCFS(先到先服务)

  • 队列类型:FCFSRequestQueue
  • 主要接口:add_request() / pop_request() / peek_request()
  • 优点:简单、可预测
  • 缺点:长 request 更容易拖慢后续 waiting request 的进入时机

基于优先级

  • 队列类型:PriorityRequestQueue
  • 排序规则:priority 数值越小越先执行;若相同则按 arrival_time,再按 request_id
  • 优点:可以显式优先保障交互式 / 重要流量
  • 缺点:如果优先级设计不当,低优先级 request 可能长期等待

排查 Scheduler 问题

高 Preemption 率

更贴近当前指标的观察方式是关注:

  • IterationStats.num_preempted_reqs
  • SchedulerStats.kv_cache_usage
  • SchedulerStats.encoder_cache_usage

如果 preemption 频繁发生,通常优先检查:

  1. KV cache 是否过小
  2. max_num_seqs 是否过高
  3. 是否启用了 chunked prefill,以及 long_prefill_token_threshold 是否合理

等待队列持续增长

如果 SchedulerStats.num_waiting_reqs 长时间偏高,通常意味着:

  1. token budget 太保守
  2. 长 prompt 占用了过多 budget
  3. priority / FCFS 策略与业务流量类型不匹配

高延迟

如果 TTFT 偏高,通常优先检查:

  1. 是否启用 chunked prefill
  2. max_num_scheduled_tokens 是否过大
  3. prefix cache / external KV 是否发挥作用
  4. 高优先级交互流量是否需要单独的 priority 策略

关键要点

  1. Continuous batching 通过每次迭代动态增减 request,消除了队头阻塞

  2. Token budget 控制 batch 大小,平衡延迟与吞吐量的权衡

  3. 运行中的 request 拥有优先权,以最小化 decode 延迟

  4. Preemption 是内存紧张时的最后手段:它会中断当前运行进度,但后续恢复时仍可能重新命中 prefix cache

  5. Chunked prefill 在处理长 prompt 与交互式 request 之间取得平衡

  6. 当前的 SchedulerOutput 是结构化输出,会同时携带 per-request token 分配、spec decode、encoder 输入与 finished/preempted request 信息

下一步

在第四部分中,我们将探索 Request Processing——request 如何从 tokenization 到流式输出在系统中流转,以及跨迭代的状态管理机制。

参考资料


Scheduler 是 vLLM 的大脑,每秒钟要做出成千上万个瞬间决策,在满足延迟 SLA 的同时最大化 GPU 利用率。接下来,我们将看到 request 如何在系统中端到端地流转。

第二部分:PagedAttention——核心创新

简介

PagedAttention 是 vLLM 的突破性创新,彻底改变了大语言模型的服务方式。在 PagedAttention 出现之前,高效服务 LLM 长期受到内存碎片化和浪费问题的困扰。本文将深入探讨 PagedAttention 的工作原理、重要意义,以及它在 vLLM 中的实现方式。

LLM 服务中的内存问题

理解 KV Cache

在 transformer 模型中,attention 层为每个 token 计算键(K)和值(V)向量。在生成过程中:

  1. Prefill 阶段:为所有提示词 token 计算 K、V
  2. Decode 阶段:对每个新 token,计算其 K、V,并对所有之前的 K、V 进行 attention 计算

挑战:我们必须存储所有之前的 K、V 张量(即”KV cache”),以避免重复计算。

内存大小:以 Llama-3-8B 为例:

  • 32 个 attention 层
  • 4096 隐藏维度
  • FP16 精度(2 字节)
  • 每个 token:32 层 × 4096 维度 × 2(K、V)× 2 字节 = 512 KB

对于 2048 个 token 的序列:仅 KV cache 就需要 1 GB!

碎片化问题

传统服务系统为每个请求的最大长度预先分配连续内存:

1
2
3
4
5
6
7
8
Request 1 (max 2048 tokens, actual 512):
[████░░░░░░░░░░░░] 75% wasted

Request 2 (max 2048 tokens, actual 1800):
[████████████████] 12% wasted

Request 3 (max 2048 tokens, actual 200):
[██░░░░░░░░░░░░░░] 90% wasted

问题

  1. 过度分配:必须按最坏情况的长度进行分配
  2. 碎片化:无法将一个请求的未使用空间分配给另一个请求
  3. 低吞吐量:内存成为瓶颈,而非计算

实际影响:没有 PagedAttention,你可能只能服务 10 个并发请求;而有了 PagedAttention,同样的内存可以服务 50 个以上的请求!

PagedAttention 的解决方案

PagedAttention 将虚拟内存的理念应用于 attention 计算:

核心思想:不再将 KV cache 连续存储,而是将其拆分为固定大小的 block。每个请求获得一个 block table,将逻辑位置映射到 physical block。

虚拟内存类比

虚拟内存(操作系统) PagedAttention(vLLM)
页面(Page) block(如 16 个 token)
页表(Page Table) block table
物理内存 GPU 内存
内存分配器 block pool
缺页错误(Page Fault) cache miss
写时复制(Copy-on-Write) prefix 共享

基于 block 的存储

取代连续分配的方式:

1
2
3
4
5
6
7
8
9
10
11
Request 1 (512 tokens, 32 blocks):
Blocks: [11, 3, 7, 12, ..., 89]
Scattered in GPU memory

Request 2 (1800 tokens, 113 blocks):
Blocks: [1, 2, 4, 5, ..., 105]
Can reuse freed blocks from other requests

Request 3 (200 tokens, 13 blocks):
Blocks: [6, 8, 9, ..., 18]
Minimal waste (only last block partially filled)

优势

  1. 无过度分配:按需分配 block
  2. 无碎片化:任何空闲 block 都可分配给任意请求
  3. 近零浪费:每个请求只有最后一个 block 可能是部分填充的
  4. 共享:共同前缀可共享 physical block

实现深度解析

block 结构

文件位置vllm/v1/core/kv_cache_utils.py

当前源码中的 KVCacheBlock 是一个 @dataclass(slots=True),核心字段包括:

1
2
3
4
5
6
7
8
@dataclass(slots=True)
class KVCacheBlock:
block_id: int
ref_cnt: int = 0
_block_hash: BlockHashWithGroupId | None = None
prev_free_block: KVCacheBlock | None = None
next_free_block: KVCacheBlock | None = None
is_null: bool = False

其中 block_hash 通过 property 暴露,实际携带的是“block hash + KV cache group id”的组合键,而不是一个简单的 int

物理存储

v1 不再假设单一固定的 torch.empty(..., float16) layout。实际实现会先根据 KVCacheConfig 分配原始缓存,再由不同 attention backend(如 FlashInfer、Triton、FlashAttention)重塑为各自需要的 KV layout。常见 shape 仍然围绕 num_blocks / 2(K,V) / block_size / num_kv_heads / head_size 展开,但具体维度顺序与 dtype 取决于 backend。

Block Pool

文件位置vllm/v1/core/block_pool.py

BlockPool 维护所有 KV cache block、空闲队列与 prefix cache 索引。构造函数的核心形态如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BlockPool:
def __init__(
self,
num_gpu_blocks: int,
enable_caching: bool,
hash_block_size: int,
enable_kv_cache_events: bool = False,
metrics_collector: KVCacheMetricsCollector | None = None,
):
self.blocks = [KVCacheBlock(idx) for idx in range(num_gpu_blocks)]
self.free_block_queue = FreeKVCacheBlockQueue(self.blocks)
self.cached_block_hash_to_block = BlockHashToBlockMap()

# block 0 被预留为特殊 null_block
self.null_block = self.free_block_queue.popleft()
self.null_block.is_null = True

这有两个容易忽略的细节:

  1. block_id=0 不是普通数据块,而是 null_block 占位块。
  2. cached_block_hash_to_block 不是简单的 dict[int, block],而是支持 hash 冲突的 BlockHashToBlockMap

分配与释放

BlockPool 的核心接口是 get_new_blocks()touch()free_blocks()

1
2
3
new_blocks = block_pool.get_new_blocks(num_blocks)
block_pool.touch(cached_blocks) # prefix cache 命中时增加引用
block_pool.free_blocks(reversed(blocks)) # 释放时通常按逆序归还
  • get_new_blocks() 会从 free queue 批量取出 block,并在需要时调用 _maybe_evict_cached_block() 清理旧的缓存元数据。
  • touch() 用于 prefix cache 命中路径:若命中的 block 仍在 free queue 中,会先移出再增加 ref_cnt
  • free_blocks() 按列表批量释放 block;调用方通常按逆序释放,以便让尾部 block 更容易被优先回收。

block table 与 KVCacheBlocks

当前 v1 更接近“按 KV cache group 组织 block 列表”的实现,而不是 [[7], [23], ...] 这种每个逻辑 block 再包一层列表的结构。

  • 请求侧使用 KVCacheBlocks.blocks: tuple[Sequence[KVCacheBlock], ...]
  • 调用 get_block_ids() 后,会得到按 KV cache group 分组的 block id 列表
  • kernel 侧最终消费的是按 group 构造的 block_table tensor

对单一 attention group 来说,可以把它理解为一个平坦的 physical block id 序列;有多个 group(如 attention + Mamba)时,外层再按 group 分组。

KVCacheManager

文件位置vllm/v1/core/kv_cache_manager.py

KVCacheManager 的职责不只是“给 request 分配新 block”,而是把 prefix cache 命中、完整序列准入、sliding window 裁剪、speculative lookahead,以及 encoder-decoder 的 cross-attention 都纳入同一套 slot 管理逻辑。

当前更重要的接口是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
computed_blocks, num_computed_tokens = kv_cache_manager.get_computed_blocks(request)

can_admit = kv_cache_manager.can_fit_full_sequence(
request,
num_new_computed_tokens=num_computed_tokens,
new_computed_blocks=computed_blocks,
)

new_blocks = kv_cache_manager.allocate_slots(
request,
num_new_tokens,
num_new_computed_tokens=num_computed_tokens,
new_computed_blocks=computed_blocks,
num_lookahead_tokens=num_lookahead_tokens,
num_external_computed_tokens=num_external_computed_tokens,
delay_cache_blocks=delay_cache_blocks,
num_encoder_tokens=num_encoder_tokens,
)

这套接口有几个关键点:

  • get_computed_blocks()allocate_slots() 是分开的两个阶段,scheduler 会先查 prefix cache,再决定是否准入。
  • allocate_slots() 返回 KVCacheBlocks | None,而不是 (blocks, num_cached_tokens)
  • allocate_slots() 还会在真正分配前调用 remove_skipped_blocks(),把 sliding window 或 sparse attention 下已经不需要的 block 替换成 null_block

Prefix Caching:跨请求共享

Prefix caching 仍然是 PagedAttention 最有价值的能力之一,但当前实现比“对 token tuple 做一次 Python hash”要复杂得多。

block 哈希

源码里的 block hash 是链式计算的:当前 block 的 hash 会把父 block 的 hash、当前 block token、以及 multimodal / LoRA / cache salt / prompt embed 等额外键一起纳入计算。返回值类型是 BlockHash(bytes 包装),不是简单的 int

可以把它理解为:

1
block_hash = H(parent_block_hash, current_block_tokens, extra_keys)

这让 vLLM 可以:

  • 只对完整 block建立 prefix cache 索引
  • 区分同一 token 序列在不同 KV cache group 中的缓存项
  • 在多模态、LoRA 或 prompt embedding 参与时避免误共享

cache 查找

当前 KVCacheManager.get_computed_blocks() 会调用 coordinator / manager 路径来寻找“最长可命中的前缀”,而不是直接遍历 request.block_hashes 并手动递增 ref_cnt

1
2
3
4
5
6
computed_blocks, num_new_computed_tokens = (
self.coordinator.find_longest_cache_hit(
request.block_hashes,
max_cache_hit_length,
)
)

几个细节很重要:

  • prefix cache 只会命中完整 block
  • 即使“整个 prompt 都命中”,也通常要重新计算最后一个 token 以得到 logits,因此 max_cache_hit_length 会被限制为 request.num_tokens - 1
  • 命中后的引用计数增加发生在 block_pool.touch(...) 路径,而不是在查找函数里直接修改

reference counting

reference counting 的基本含义没有变化:多个 request 可以共享同一个 prefix block,只有 ref_cnt == 0 时它才会回到 free queue,成为可复用或可淘汰对象。

cache 淘汰

当前实现不是“扫描 free queue 找第一个 ref_cnt == 0 的 cached block 并返回”的 evict_cached_block() 接口。更接近真实源码的过程是:

  1. get_new_blocks() 先从 free queue 取出候选 block
  2. 若该 block 仍带有 block_hash,则调用 _maybe_evict_cached_block(block) 把它从 cached_block_hash_to_block 中摘除
  3. 若需要显式按 block id 淘汰 prefix cache,则使用 evict_blocks(block_ids)

也就是说,淘汰是围绕“取出的 block 是否还挂着 cache 元数据”展开的。

使用 block 进行 attention 计算

Prefill 阶段

当前 v1 并没有统一公开的 paged_attention_prefill(...) Python API。prefill 由具体 attention backend 负责执行,例如 FlashInfer 路径会使用 BatchPrefillWithPagedKVCacheWrapper / BatchPrefillWithRaggedKVCacheWrapper,其他 backend 则会构造自己的 metadata 和 kernel 调用。

对外可以把它理解为两步:

  1. scheduler / attn metadata 根据 seq_lens 和 block tables 构建 batch 视图
  2. backend kernel 直接按 paged KV cache layout 完成 prefill attention

Decode 阶段

decode 阶段通常由具体 attention backend 负责。以 FlashInfer 路径为例,decode 侧会通过 BatchDecodeWithPagedKVCacheWrapper(或 TRT-LLM decode path)直接消费 paged KV cache。

因此,当前实现更准确的描述是:

  • prefill / decode 都是 backend-specific
  • block table 会在 metadata 构建阶段转成 tensor 形式
  • kernel 直接根据 paged KV layout 取 K/V,而不是先在 Python 层把 block 拼接成连续张量

多组 KV cache

vLLM 支持不同层使用不同 cache 规格,相关配置类型是 KVCacheGroupSpec / KVCacheConfig

概念上可以理解为:

  • KVCacheConfig 持有 num_blockskv_cache_tensorskv_cache_groups
  • 每个 KVCacheGroupSpec 绑定一组 layer 名称与对应的 kv_cache_spec
  • kv_cache_spec 可以是 FullAttentionSpecSlidingWindowSpecMambaSpec 等不同类型

因此,一个 request 的 block 记录也会天然按 group 分层,例如:

1
2
3
4
blocks = (
[attn_block_0, attn_block_1, attn_block_2],
[mamba_block_0, mamba_block_1, mamba_block_2],
)

这也是为什么 BlockHash 需要和 group id 绑定:同一段 token 在不同 KV cache group 中对应的是不同的物理缓存项。

性能影响

内存效率

使用 PagedAttention 之前(连续分配):

1
2
3
10 requests × 2048 max length × 512 KB/token = 10.5 GB
Actual usage: 10 requests × 500 avg length = 2.5 GB
Efficiency: 24%

使用 PagedAttention 之后(block 分配):

1
2
3
500 blocks allocated across all requests
Waste: ~10 blocks (partial last blocks)
Efficiency: 98%

结果:并发请求数提升 4 倍!

吞吐量提升

真实基准测试(Llama-3-8B,运行于 H100):

指标 未使用 PagedAttention 使用 PagedAttention 提升幅度
并发请求数 12 64 5.3x
吞吐量(tok/s) 1,500 8,000 5.3x
内存占用 60 GB 60 GB 持平
延迟(TTFT) 45ms 42ms -7%

Prefix Caching 收益

使用共同系统提示词(500 个 token)时:

请求次数 未使用缓存 使用缓存 加速比
第 1 次 10ms(prefill) 10ms 1x
第 2 次 10ms 1ms 10x
第 100 次 10ms 1ms 10x

缓存命中率:对于带有系统提示词的聊天机器人,通常在 60-80% 之间。

进阶主题

滑动窗口 attention

对 Mistral 这类 sliding-window 模型,当前实现不会简单地“切片旧 block 然后逐个 free”。更接近真实源码的流程是:

  • 先根据 total_computed_tokens 计算哪些 block 已经落到窗口之外
  • 调用 remove_skipped_blocks() 把这些位置替换为 null_block
  • 再把不再需要的真实 block 批量归还给 block pool

这样既保留了逻辑 block table 的形状,又避免了窗口外 token 继续占用可复用的缓存块。

speculative decoding

在当前 v1 中,speculative decoding 主要通过 num_lookahead_tokens、slot allocation 和 scheduler 的 scheduled_spec_decode_tokens 协同完成。

更准确地说:

  • scheduler 会为 lookahead token 预留 slot
  • allocate_slots() 只会把最终确认的 token 提交到 prefix cache
  • 被拒绝的 draft token 会在 slot mapping / 输出更新路径中被屏蔽,而不是靠手工 ref_cnt += 1 / 回滚 block 列表来管理

实现中的注意事项

hash 碰撞

当前实现通过 BlockHashToBlockMap 处理哈希碰撞。对于同一个 hash,值可能是单个 KVCacheBlock,也可能退化成 {block_id: KVCacheBlock} 这样的结构,而不是简单的 hash -> list[block]

不完整的 block

最后一个 block 仍然可能是不完整的,但当前实现不会在 KVCacheBlock 元数据里单独维护 num_tokens_in_last_block 这样的字段。vLLM 只对完整 block建立 prefix cache,并通过 seq_lens 等 batch metadata 在 attention 计算时屏蔽无效位置。

线程安全

线程安全方面,block_pool.py 并没有对外暴露 allocate/_allocate_unsafe 这类接口;调度路径更接近单一 owner 管理 block pool 状态,而不是通过显式锁 API 暴露给外部使用。

核心要点

  1. PagedAttention 将 KV cache 拆分为固定大小的 block,消除了碎片化和过度分配问题

  2. block table 将逻辑 block 映射到 physical block,类似于虚拟内存的页表

  3. prefix caching 跨请求共享 block,大幅减少了冗余计算

  4. reference counting 确保安全共享,防止过早释放内存

  5. 近零内存浪费在实践中实现了 4-5 倍的吞吐量提升

  6. attention 内核经过优化,可直接使用 block 索引存储进行计算

下一步

在第三部分,我们将探索 Scheduler——vLLM 的核心调度器,它负责决定处理哪些请求、何时进行抢占,以及如何通过 continuous batching 最大化 GPU 利用率。

参考资料


PagedAttention 是 vLLM 卓越性能的基础。在下一篇文章中,我们将看到 Scheduler 如何在此之上协调请求的执行。

第一部分:vLLM 架构概览

简介

在深入研究具体组件之前,我们需要先了解 vLLM 的整体架构。本文梳理了各主要组件及其交互方式,为后续各部分的深入探讨奠定基础。

全局视角

vLLM 的设计围绕以下几个核心原则:

  1. 关注点分离:不同职责(调度、执行、服务)由各自独立的组件负责处理
  2. 进程隔离:V1 架构采用多进程设计,以提升健壮性和 CPU 利用率
  3. 异步处理:请求在流水线中流转,不会产生阻塞
  4. 默认分布式:从底层设计上即支持多 GPU 执行

高层数据流

当你向 vLLM 发送一个请求时,整个流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
User Request (HTTP/gRPC)

API Server Process
↓ (ZMQ Socket)
Engine Core Process

Scheduler

GPU Worker Processes
↓ (NCCL/Collective Ops)
Model Execution

Output Processing
↓ (ZMQ Socket)
API Server Process

Streaming Response to User

V1 多进程架构

V1 架构(于 2024 年底引入)采用多进程设计,以实现更好的 CPU 利用率和进程隔离。下面逐一介绍各进程类型:

1. API Server 进程

职责:处理前端请求、I/O,以及与 engine core 的通信

主要任务

  • 接收并校验 OpenAI-compatible HTTP 请求
  • 对输入文本进行 tokenize
  • 加载多模态数据(图像、音频)
  • 将输出以流式方式返回给客户端
  • 处理 API 鉴权及其他前端请求逻辑

核心实现:OpenAI-compatible HTTP 服务位于 vllm/entrypoints/openai/api_server.py;gRPC 服务位于 vllm/entrypoints/grpc_server.py

API server 对于模型执行而言是无状态的。它不了解 GPU 内存、KV cache 或模型权重,只负责:

  1. 将用户请求转换为 EngineCoreRequest 对象
  2. 通过 ZMQ 将其发送给 engine core
  3. 接收返回的 EngineCoreOutput 对象
  4. 将其转换为 API 响应

进程数量:默认单 API server;在在线 data parallel 部署中,api_server_count 的默认值会随 internal / hybrid / external load-balancing 模式变化,并可由 --api-server-count 覆盖。

CPU 线程:使用 VLLM_MEDIA_LOADING_THREAD_COUNT 个线程(默认 8)并行加载媒体文件。

2. Engine Core 进程

职责:调度请求并协调模型执行

主要任务

  • 维护请求队列
  • 运行 scheduler 以决定计算内容
  • 管理 KV cache 分配
  • 协调 GPU worker
  • 处理请求抢占与换出

核心实现vllm/v1/engine/core.py

engine core 运行一个紧凑的循环(run_busy_loopcore.py:1138):

1
2
3
4
5
6
7
8
9
10
11
12
13
def run_busy_loop(self):
while self._handle_shutdown():
# 1. 从 input_queue 拉取新请求(add_request / abort_requests)
self._process_input_queue()
# 2. 调度 + 执行一次模型 step,将输出推入 output_queue
self._process_engine_step()

def _process_engine_step(self):
# step() 内部:scheduler.schedule() -> model_executor.execute_model()
outputs, model_executed = self.step_fn()
for output in outputs.items() if outputs else ():
self.output_queue.put_nowait(output)
self.post_step(model_executed)

进程数量:每个 data parallel rank 对应一个。设置 --data-parallel-size 4 时,将启动 4 个 engine core。

CPU 用量:运行忙循环以实现低延迟的调度决策。

3. GPU Worker 进程

职责:在 GPU 上执行模型前向传播

主要任务

  • 将模型权重加载到 GPU
  • 执行前向传播
  • 管理 GPU 内存
  • 运行 CUDA kernel(attention、FFN 等)
  • 参与集合通信操作(用于 tensor parallelism / pipeline parallelism)

核心实现vllm/v1/worker/gpu_worker.py

MultiprocExecutor 下,通常会看到“每个 GPU 一个 worker 进程”;但在单机单卡默认的 UniProcExecutor 路径中,worker 会运行在 EngineCore 进程内,而不是独立的 OS 进程。worker 负责:

  1. 加载其对应分片的模型权重
  2. 从对应的 engine core 接收执行请求
  3. 运行模型前向传播
  4. 将执行结果交回 executor / engine core 的后续处理路径

进程数量:取决于 executor 后端。对于 8 个 GPU、设置 --tensor-parallel-size 4 --data-parallel-size 2 这类多 GPU 部署,通常会看到 8 个 worker,由 2 个 engine core 分别协调 2 组 tensor parallel worker。

4. DP Coordinator 进程(按需启动)

职责:在 data parallel 部署中汇总引擎状态,并为前端负载均衡与 wave coordination 提供协调信息

主要任务

  • 汇总各 DP engine 的 waiting / running 队列统计
  • 将这些统计发布给 front-end client,供其做负载均衡决策
  • 维护 request wave / running state,并在需要时广播 START_DP_WAVE

核心实现vllm/v1/engine/coordinator.py

进程数量:当 --data-parallel-size > 1 时启动 1 个,否则不启动。

进程数量示例

下面来看几个具体示例。需要注意的是,实际 OS 进程数量会随 executor 后端与 load-balancing 模式变化;以下示例以当前 CLI 默认路径为参考:

示例 1:单 GPU

1
vllm serve meta-llama/Llama-3-8B

进程:

  • 1 个 API Server(当前进程)
  • 1 个 Engine Core
  • GPU worker 以内嵌方式运行在 EngineCore 进程内(UniProcExecutor
  • 共计:通常约 2 个 OS 进程

示例 2:Tensor Parallelism(4 个 GPU)

1
vllm serve meta-llama/Llama-3-70B --tensor-parallel-size 4

进程:

  • 1 个 API Server
  • 1 个 Engine Core
  • 4 个 GPU Worker(每个 GPU 各一个)
  • 共计:6 个进程

示例 3:Data Parallelism(4 个 GPU)

1
vllm serve meta-llama/Llama-3-8B --data-parallel-size 4

进程:

  • 1 个 launcher / manager
  • 4 个 API Server
  • 4 个 Engine Core(每个 DP rank 各一个)
  • 4 个 GPU Worker(每个 GPU 各一个)
  • 1 个 DP Coordinator
  • 共计:单机 internal-LB 部署下通常约 14 个进程

示例 4:混合并行(8 个 GPU)

1
vllm serve meta-llama/Llama-3-70B --tensor-parallel-size 2 --data-parallel-size 4

进程:

  • 1 个 launcher / manager
  • 4 个 API Server
  • 4 个 Engine Core(每个 DP rank 各一个)
  • 8 个 GPU Worker(每个 DP rank 2 个,每个 GPU 各一个)
  • 1 个 DP Coordinator
  • 共计:单机 internal-LB 部署下通常约 18 个进程

核心组件详解

LLMEngine

LLMEngine 类是离线推理(直接使用 Python API)的主要入口点。

位置vllm/v1/engine/llm_engine.py

主要职责:

  • 创建并管理 engine core
  • 通过 InputProcessor 处理输入
  • 通过 OutputProcessor 转换输出
  • 管理请求生命周期

使用示例

1
2
3
4
5
6
7
8
9
10
from vllm import LLM, SamplingParams

# Initialize engine
llm = LLM(model="meta-llama/Llama-3-8B")

# Generate
outputs = llm.generate(
["Hello, my name is"],
SamplingParams(temperature=0.8, top_p=0.95)
)

在底层,LLM 创建 LLMEngineLLMEngine 再创建 EngineCore,由其协调各 worker。

Scheduler

Scheduler 是 vLLM 的核心大脑,负责决策:

  • 每次迭代处理哪些请求
  • 每个请求计算多少 token
  • 何时以及对哪些请求进行抢占
  • 如何分配 KV cache 块

位置vllm/v1/core/sched/scheduler.py

Scheduler 维护三个队列:

  1. 等待队列(Waiting):等待开始处理的新请求
  2. 运行队列(Running):正在被处理的请求
  3. 暂跳队列(Skipped Waiting):因依赖关系而被临时跳过的请求

调度算法(简化版):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def schedule(self) -> SchedulerOutput:
scheduled_requests = []
token_budget = max_num_scheduled_tokens

# First, schedule running requests (priority)
for req in running:
if token_budget > 0:
num_tokens = min(req.remaining_tokens, token_budget)
scheduled_requests.append((req, num_tokens))
token_budget -= num_tokens

# Then, schedule waiting requests
for req in waiting:
if token_budget >= req.num_prompt_tokens:
scheduled_requests.append((req, req.num_prompt_tokens))
token_budget -= req.num_prompt_tokens
else:
break # Not enough budget

return SchedulerOutput(scheduled_requests)

我们将在第三部分深入探讨 scheduler。

KV Cache Manager

使用 PagedAttention 管理 attention key-value cache 的内存。

位置vllm/v1/core/kv_cache_manager.py

核心概念:

  • Block(块):固定大小的 KV cache 单元(例如 16 个 token)
  • Block Pool(块池):预分配的块集合
  • Block Table(块表):逻辑位置到物理块的映射
  • Prefix Cache(前缀缓存):用于共享公共 prompt 前缀的块

示例:若块大小为 16,共有 1000 个块,则可以服务于:

  • 1 个拥有 16,000 个 token 的请求,或
  • 16 个各有 1,000 个 token 的请求,或
  • 任何能放入 1000 个块的组合

我们将在第二部分深入剖析 PagedAttention。

Worker 与后端 Runner

worker 进程负责加载模型并执行前向传播。

Workervllm/v1/worker/gpu_worker.py):

  • 管理 GPU 设备
  • 初始化模型权重
  • 与其他 worker 协调(用于 tensor parallelism / pipeline parallelism)

具体后端的 model runner(例如 GPUModelRunner / CPUModelRunner / XPUModelRunner,以及较新的 GPU runner 路径):

  • 准备输入 tensor
  • 执行模型前向传播
  • 应用 CUDA graph 优化
  • 产出供 executor / engine core 后续处理的执行结果

请求生命周期

让我们跟踪一个完整请求在系统中的流转过程:

1. 请求到达

1
2
3
4
5
6
POST /v1/completions
{
"model": "meta-llama/Llama-3-8B",
"prompt": "The capital of France is",
"max_tokens": 50
}

2. API Server 处理

  • 校验请求
  • 对 prompt 进行 tokenize:[791, 3139, 315, 9822, 374]
  • 创建 EngineCoreRequest 对象
  • 通过 ZMQ 发送至 engine core

3. Engine Core 接收

  • 将请求加入 scheduler 的等待队列
  • 请求 ID 通常已在前端 InputProcessor 阶段分配/随机化,并随 EngineCoreRequest 一起传入
  • 初始化请求状态

4. 首次调度迭代

  • Scheduler 发现等待队列中的新请求
  • 检查 prefix cache / KV cache 状态
  • 为本轮需要计算的 token 分配 KV cache 块(块大小为 16 token 时,这个例子中通常只需要 1 个块)
  • 生成本轮 SchedulerOutput

5. Worker 执行

  • executor / worker 接收调度结果
  • 准备输入 tensor
  • 运行模型前向传播
  • 若需要采样,则通过 model_executor.sample_tokens(...) 生成本轮 token 输出

6. 输出处理

  • engine core 使用 scheduler.update_from_output(...) 更新请求状态
  • EngineCoreOutput 通过 ZMQ 发送给 API server

7. API Server 流式输出

  • 从 engine core 接收 token
  • Detokenize:”Paris”
  • 流式返回给客户端:data: {"text": "Paris", "finish_reason": null}

8. 后续迭代

  • 请求移入运行队列
  • Scheduler 持续分配 token
  • 每次迭代:计算 1 个新 token(decode 阶段)
  • 将每个 token 流式发送给客户端

9. 请求完成

  • 达到 max_tokens 上限或生成 EOS
  • 释放 KV cache 块(或保留在 prefix cache 中)
  • 向客户端发送最终完成结果
  • 从 scheduler 中移除请求

配置与初始化

vLLM 的配置统一集中在 VllmConfig 中:

1
2
3
4
5
6
7
8
9
from vllm.config import VllmConfig

vllm_config = VllmConfig(
model_config=ModelConfig(...),
cache_config=CacheConfig(...),
scheduler_config=SchedulerConfig(...),
parallel_config=ParallelConfig(...),
# ... more configs
)

许多核心组件会共享或持有同一个 VllmConfig,以确保配置一致性。

主要配置项

  • ModelConfig:模型名称、dtype、tokenizer
  • CacheConfig:KV cache 大小、块大小、prefix caching
  • SchedulerConfig:最大批量大小、调度策略
  • ParallelConfig:TP/PP/DP 规模、分布式后端

类层次结构

类层次结构遵循一致的模式:

1
2
3
4
5
6
7
8
9
10
11
12
LLMEngine
├── InputProcessor (tokenization, preprocessing)
├── EngineCore
│ ├── StructuredOutputManager
│ ├── Scheduler
│ │ ├── KVCacheManager
│ │ └── EncoderCacheManager
│ └── Executor
│ └── Workers / backend runners
│ └── GPUModelRunner / CPUModelRunner / XPUModelRunner
│ └── Model (nn.Module)
└── OutputProcessor (detokenization, streaming)

许多核心类会接收或持有 VllmConfig,但并非所有类都直接以它作为构造参数;例如 OutputProcessor 并不直接接收 VllmConfig

进程间通信

ZMQ Sockets

API server 与 engine core 通过 ZMQ 进行通信,消息编码采用基于 MsgpackEncoder / MsgpackDecoder 的 multipart frame:

  • front-end / API server 一侧会把 EngineCoreRequest 编码成 multipart 消息并通过 send_multipart(...) 发送
  • engine core 一侧通过 recv_multipart(...) 接收,再按消息类型用 MsgpackDecoder 解码
  • 在需要传输 tensor 的路径上,还会配合 tensor IPC,而不是把所有内容都塞进单个 pickle payload

为什么选择 ZMQ?

  • 高性能(微秒级延迟)
  • 灵活的通信模式(req-rep、pub-sub、push-pull)
  • 内置队列管理
  • 语言无关

NCCL 用于 GPU 通信

GPU worker 使用 NCCL 进行集合通信操作:

1
2
3
4
5
6
# Tensor parallelism: all-reduce across GPUs
torch.distributed.all_reduce(
tensor,
op=torch.distributed.ReduceOp.SUM,
group=tensor_parallel_group
)

通信模式

  • Tensor Parallel:在 attention/FFN 之后执行 All-reduce
  • Pipeline Parallel:各阶段之间执行发送/接收操作
  • Data Parallel:前向传播期间无需通信

内存布局

理解内存布局对于 vLLM 至关重要:

GPU 内存分布

1
2
3
4
5
Total GPU Memory: 80GB (H100)
├── Model Weights: 16GB (Llama-3-8B in FP16)
├── KV Cache: 60GB (dynamically allocated blocks)
├── Activation Memory: 2GB (for batch processing)
└── Framework Overhead: 2GB (PyTorch, CUDA)

KV Cache 内存

对于块大小为 16 token 的 8B 模型:

  • 每个块存储 32 个 attention 层的 K 和 V
  • 每块大小:16 token × 32 层 × 2(K,V)× 4096 维 × 2 字节 = 8.4 MB
  • 若 KV cache 分配 60GB:约 7,100 个块
  • 总容量:跨所有请求约 113,600 个 token

性能特征

H100 GPU 上的典型性能表现:

指标 数值
吞吐量(Throughput) ~8,000 tokens/sec(Llama-3-8B)
延迟(TTFT) ~20-50ms
延迟(TPOT) ~10-15ms
最大批量大小(Max Batch Size) ~256 并发请求
内存效率(Memory Efficiency) ~95%(相比不使用 PagedAttention 时的 ~60%)

下一步

现在我们已经了解了整体架构,接下来可以深入探讨各具体组件:

  • 第二部分:PagedAttention 与 KV cache 管理
  • 第三部分:Scheduler 的决策过程
  • 第四部分:请求处理与状态管理
  • 第五部分:分布式执行与并行策略

关键要点

  1. V1 采用多进程架构,以提升 CPU 利用率和故障隔离能力
  2. Scheduler 是核心协调者,负责决策每次迭代的计算内容
  3. KV cache 管理(PagedAttention)是内存效率的关键所在
  4. 进程数量随并行度扩展:但会受到 executor 后端、load-balancing 模式以及是否存在 launcher / manager 进程的影响
  5. ZMQ 实现了高效的进程间通信
  6. 许多核心组件共享或持有统一的 VllmConfig,以保持配置一致性

参考资料


在下一篇文章中,我们将详细探讨 PagedAttention,了解 vLLM 如何通过精巧的内存管理实现近乎零浪费的内存利用。

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.tess.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
tess/maintenance:
- jobGenType: Build
name: build-maintenance-controller
branch: master
dockerFile: Dockerfile
versionFile: VERSION
buildTime: kaniko
targets:
- onPr:
imageTags:
- hub.tess.io/maintenance/maintenance-controller:pr-${PULL_NUMBER}
- onTag:
tagRegex:
- ^v(\d)+\.(\d)+\.(\d)+
imageTags:
- hub.tess.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.tess.io/prowimages/kaniko:latest
args:
- --dockerfile=Dockerfile
- --context=/workspace/repo
- --destination=hub.tess.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.tess.io/prowimages/

仓库 2:prow-configs

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

位置/Users/tashen/prow-configs

结构

1
2
3
4
5
6
7
8
9
prow-configs/
├── jobs/
│ ├── ebaytess/ # 组织特定的作业
│ ├── ebayistio/
│ ├── 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.tess.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.tessprow/manual-trigger" \
    -H "Content-Type: application/json" \
    -d '{
    "org": "tess",
    "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.tess.io 的中央镜像仓库进行通信:

  • prow-images 构建并推送到 hub.tess.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 tessprow | 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 系统中极具价值的文件系统选择。

0%