Linux 网络内核协议栈深度剖析(二):网卡驱动与 NAPI 收发包机制
本文是 Linux 网络内核协议栈系列的第二篇,聚焦于网卡驱动框架与 NAPI(New API)收发包机制。我们将深入 Linux 6.4-rc1 源码,逐层拆解从网卡硬件中断到 sk_buff 进入协议栈的完整链路,并对称地分析发送路径。所有代码片段均直接取自内核源文件,确保与实际内核行为一致。
本文是 Linux 网络内核协议栈系列的第二篇,聚焦于网卡驱动框架与 NAPI(New API)收发包机制。我们将深入 Linux 6.4-rc1 源码,逐层拆解从网卡硬件中断到 sk_buff 进入协议栈的完整链路,并对称地分析发送路径。所有代码片段均直接取自内核源文件,确保与实际内核行为一致。
本系列基于 Linux 6.4-rc1 源码(commit
ac9a78681b92),所有代码片段均从真实源文件读取,路径为include/linux/skbuff.h与net/core/skbuff.c。
网络性能调优、eBPF 程序开发、内核模块编写,乃至排查一个”神秘的丢包”——这些工作的底层都有一个共同的核心:sk_buff(socket buffer)。它是 Linux 内核网络子系统中最关键的数据结构,每一个在内核中流动的网络报文都以 sk_buff 的形式存在。
本系列文章将从零到深,系统拆解 Linux 网络协议栈。第一篇先建立全局视图,再深入 sk_buff 的内存模型与操作原语。
从网卡收到一帧,到用户态 read() 返回数据,内核经历的完整调用链如下:
1 | 硬件中断 |
关键节点说明:
NAPI(New API) 是现代驱动的标准轮询机制。硬中断只负责触发 napi_schedule(),将 poll 工作推给软中断 NET_RX_SOFTIRQ,从而避免高速网卡的中断风暴。驱动在 poll 函数中用 napi_build_skb() 或 __napi_alloc_skb() 创建 skb,并调用 napi_gro_receive() 进入 GRO(Generic Receive Offload)聚合层。
netif_receive_skb() 是协议层的分发入口。它通过 ptype_base 哈希表查找与 skb->protocol 对应的协议处理函数(如 ip_rcv、ipv6_rcv)。
ip_rcv() 完成 IP 首部校验、路由查找(ip_route_input_noref()),然后决定报文去向:本地投递还是转发。本地投递进入 ip_local_deliver(),这里完成 IP 分片重组,再调用上层协议(TCP/UDP/ICMP)的接收函数。
tcp_v4_rcv() 根据四元组找到 socket,将报文挂入 sk->sk_receive_queue,并通过 sk->sk_data_ready 回调唤醒阻塞在 read() 的用户进程。
1 | 用户态 write() / sendmsg() |
发送路径的核心在于 TCP 在 sk_write_queue 中维护待发送的 skb。TCP 实现了可靠传输,skb 只有在收到 ACK 后才会通过 tcp_clean_rtx_queue() 释放。发送时,TCP 先调用 skb_clone() 产生一个 fclone(fast-clone),将克隆体交给 IP 层,自己保留原始 skb 用于重传。
sk_buff 是 Linux 内核网络子系统中报文的载体。它本身不直接存储报文数据,而是一个元数据描述符,通过指针指向实际的数据缓冲区。其设计目标是:在协议栈各层之间传递报文时,尽量避免数据拷贝,只修改指针和元数据。
skbuff.h 中的官方注释给出了最清晰的内存模型(源码第 706-728 行):
1 | /** |
四个核心指针在结构体末尾(源码第 1053-1057 行):
1 | /* These elements must be at the end, see alloc_skb() for details. */ |
各指针含义:
| 指针 | 含义 |
|---|---|
head |
分配的缓冲区起始地址,固定不变 |
data |
当前有效数据的起始位置(随 skb_push/pull 移动) |
tail |
当前有效数据的结束位置(随 skb_put 移动) |
end |
缓冲区末尾(紧接 skb_shared_info),固定不变 |
在 64 位系统(NET_SKBUFF_DATA_USES_OFFSET 宏定义时),tail 和 end 存储的是相对于 head 的偏移量,而非指针,节省内存并方便原子操作。
Headroom(头部预留空间) 是 data - head 的字节数,发送时用于各层协议依次 skb_push() 写入自己的首部,无需移动数据。Tailroom 是 end - tail 的字节数(不含 skb_shared_info),接收时用于 skb_put() 追加数据。
这三个字段是初学者最容易混淆的:
1 | unsigned int len, // 源码第 896 行 |
skb->len:报文的逻辑总长度,等于线性区域长度加上所有分片的长度。skb_headlen(skb) + skb->data_len。skb->data_len:仅计算非线性部分(page frags + frag_list)的字节数。线性 skb 的 data_len 为 0。truesize**:内核为这个 skb 实际占用的内存大小,包含 sk_buff 结构体本身、数据缓冲区以及 skb_shared_info。用于 socket 发送/接收缓冲区的内存计费(sk_wmem_alloc / sk_rmem_alloc)。1 | /* return minimum truesize of one skb containing X bytes of data */ |
truesize 的精确计算在 __finalize_skb_around() 中(net/core/skbuff.c 第 352-375 行):
1 | static inline void __finalize_skb_around(struct sk_buff *skb, void *data, |
1 | char cb[48] __aligned(8); // 源码第 880 行 |
这 48 字节是各协议层的私有暂存区,对上下层完全透明。内核使用宏来访问它:
IP 层(include/net/ip.h):
1 | struct inet_skb_parm { |
TCP 层(include/net/tcp.h):
1 | struct tcp_skb_cb { |
TCP 在发包时用 TCP_SKB_CB(skb)->seq 记录序列号,在收包时用 TCP_SKB_CB(skb)->header.h4 读取 IP 层留下的信息。由于 sizeof(struct tcp_skb_cb) <= 48,两者都合法地复用同一块内存。
1 | union { |
skb->sk 指向拥有这个报文的 socket。在发送路径中,skb 一直持有对 socket 的引用(用于内存计费);在接收路径进入 TCP/UDP 处理后也会被赋值。ip_defrag_offset 是 IP 分片重组阶段对 sk 字段的临时复用,无需额外内存。
1 | union { |
skb->dev 指向 skb 当前关联的网络设备(struct net_device)。在接收路径,它指向收包的网卡;在发送路径,它指向出口网卡。某些协议(如 UDP 接收路径)会将 dev 置 NULL 并用 dev_scratch 存储其他临时信息。
1 | union { |
_skb_refdst 存储指向 struct dst_entry 的指针(路由缓存条目)。最低位被用作标志位 SKB_DST_NOREF,表示是否持有引用计数:
1 | #define SKB_DST_NOREF 1UL |
在快速转发路径(如 ip_forward())中,内核使用 skb_dst_set_noref() 以 RCU 方式持有路由缓存,避免昂贵的引用计数操作。
GSO(Generic Segmentation Offload)和 GRO(Generic Receive Offload)是 Linux 将分段/聚合工作推迟到驱动层或硬件的机制,核心信息存储在 skb_shared_info(见下节)中:
1 | struct skb_shared_info { |
发送路径中,TCP 构造一个大 skb(可包含多个 MSS 的数据),设置 gso_size = tp->mss_cache、gso_segs = 数量,并设置 ip_summed = CHECKSUM_PARTIAL,由驱动或网卡完成实际切割和 checksum。
接收路径中,GRO 将多个小包聚合成一个大 skb,减少协议栈处理次数。skb->slow_gro 标志指示该 skb 在 GRO 合并时携带了额外状态(连接跟踪、路由等),需要在释放时特殊处理。
1 | __u8 ip_summed:2; // CHECKSUM_NONE/UNNECESSARY/COMPLETE/PARTIAL |
四种状态的含义(源码 include/linux/skbuff.h 第 99-250 行有完整文档):
| 值 | 场景 | 含义 |
|---|---|---|
CHECKSUM_NONE |
RX | 硬件未校验,软件需要验证 |
CHECKSUM_UNNECESSARY |
RX | 硬件已验证,无需软件重算 |
CHECKSUM_COMPLETE |
RX | 硬件提供了整包 checksum(存于 skb->csum) |
CHECKSUM_PARTIAL |
TX | 软件填写了伪首部,请求硬件完成最终 checksum |
TX 路径中,csum_start 是从 skb->head 开始计算 checksum 的起始偏移,csum_offset 是相对于 csum_start 的 checksum 写入位置。
skb_shared_info 紧跟在数据缓冲区的末尾(end 指针处):
1 | #define skb_shinfo(SKB) ((struct skb_shared_info *)(skb_end_pointer(SKB))) |
完整结构(源码第 574-599 行):
1 | struct skb_shared_info { |
frags[] 是 Scatter-Gather IO 的核心。每个 skb_frag_t(实为 struct bio_vec)描述一个页片段:
1 | typedef struct bio_vec skb_frag_t; |
网卡驱动通过 DMA 将数据直接写入这些页,frags[] 只存储(页, 偏移, 长度)三元组,完全避免数据拷贝。
frag_list 是一个 skb 链表,用于两种场景:
frag_list 链接在一起,skb->len 是全部分片的总长度。SKB_GSO_FRAGLIST 模式下,将多个小 skb 链成链表交给驱动。dataref 是 skb_shared_info 中最重要的计数器,分为两个 16 位字段:低 16 位是总引用数,高 16 位是 payload-only 引用数(nohdr clone)。skb_clone() 共享数据区时递增此计数,skb_release_data() 递减,归零后才真正释放数据缓冲区。
内核为 sk_buff 头部维护了专用的 slab 缓存(net/core/skbuff.c 第 89-90 行):
1 | struct kmem_cache *skbuff_cache __ro_after_init; |
核心分配函数 __alloc_skb()(源码第 625-687 行):
1 | struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask, |
分配分两步:
sk_buff 结构体(仅元数据,约 232 字节)kmalloc_reserve() 分配数据缓冲区kmalloc_reserve() 内部会优先尝试 skb_small_head_cache(专为 TCP 头部优化的小块缓存,大小为 SKB_HEAD_ALIGN(MAX_TCP_HEADER)),对于不超过 1024 字节的小报文走快速路径。
NAPI 路径还有额外优化:napi_skb_cache_get() 维护一个 per-CPU 的 64 个 skb 的批量缓存,使用 kmem_cache_alloc_bulk() 批量分配,大幅减少 slab 调用开销。
1 | static inline void skb_reserve(struct sk_buff *skb, int len) |
在 skb 刚分配、尚未写入数据时调用,同时移动 data 和 tail,使 head 到 data 之间形成 headroom。后续各层通过 skb_push() 向前扩展,写入自己的首部,无需移动已有数据。
驱动分配接收 skb 时通常调用:
1 | skb_reserve(skb, NET_SKB_PAD); // 驱动预留(NET_SKB_PAD = 32 字节) |
发送路径中,sock_alloc_send_skb() 预留的空间要包含所有协议层首部的总长度:
1 | NET_SKB_PAD + MAX_HEADER(ETH + IP + TCP 首部之和) |
这三个函数是协议栈各层操作数据的核心原语(源码第 2383-2428 行):
skb_put() — 向 tail 方向扩展,用于接收路径追加数据:
1 | void *skb_put(struct sk_buff *skb, unsigned int len) |
skb_push() — 向 head 方向扩展,用于发送路径在 headroom 中写入首部:
1 | void *skb_push(struct sk_buff *skb, unsigned int len) |
skb_pull() — 从 data 头部移除数据,用于接收路径跳过已处理的首部:
1 | void *skb_pull(struct sk_buff *skb, unsigned int len) |
三者关系图示(发送路径,从上层到下层):
1 | 初始状态(alloc + reserve 后): |
**skb_clone()**(源码第 1853-1881 行):
1 | struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask) |
skb_clone() 只复制 sk_buff 元数据结构,共享数据缓冲区(递增 shinfo->dataref)。克隆体不能修改数据内容,但可以修改自己的首部指针(data 等)。适用于:
fclone 优化:TCP 通过 SKB_ALLOC_FCLONE 标志分配 sk_buff_fclones(两个 skb 紧挨着分配),第一次 clone 时直接使用伴生的 skb2,无需额外 slab 分配。
**skb_copy()**(源码第 1933-1953 行):
1 | struct sk_buff *skb_copy(const struct sk_buff *skb, gfp_t gfp_mask) |
skb_copy() 完全复制数据,包括 headroom、线性数据以及所有 page frags,返回一个完全独立的 skb。代价是需要实际的数据拷贝。适用于需要修改数据内容的场景。
| 操作 | 复制元数据 | 复制数据 | 共享 shinfo | 可修改数据 |
|---|---|---|---|---|
skb_clone() |
是 | 否 | 是 | 否 |
skb_copy() |
是 | 是 | 否 | 是 |
pskb_copy() |
是 | 仅线性部分 | 是(frags) | 仅线性部分 |
1 | static inline int skb_linearize(struct sk_buff *skb) |
当代码需要顺序访问报文所有字节,但 skb 是非线性的(data_len > 0,存在 page frags 或 frag_list)时,调用 skb_linearize() 将所有 page frags 的数据拷贝到线性区域,整合为线性 skb。
这是一个较重的操作,会触发内存分配和拷贝。协议栈中有大量 pskb_may_pull() 调用——它只确保 skb 的首部位于线性区域(仅 pull 必要的字节),比完整的 skb_linearize() 代价小得多。
两者都会递减 skb->users 引用计数,归零后调用 __kfree_skb() 释放内存,但语义不同,这对 tracing 工具(如 drop_monitor、skb:kfree_skb tracepoint)至关重要:
1 | // kfree_skb:报文被丢弃(drop),通常意味着异常 |
释放链路(__kfree_skb() 路径):
1 | __kfree_skb(skb) |
skb_frag_t 等价于 struct bio_vec,每个 frag 持有一个 struct page * 引用:
1 | typedef struct bio_vec skb_frag_t; |
驱动在接收时通常这样操作:
page_pool 管理),通过 DMA 映射给网卡skb_add_rx_frag() 将 page 引用填入 frags[]1 | void skb_add_rx_frag(struct sk_buff *skb, int i, struct page *page, |
page_pool 是 6.x 内核引入的高性能页缓存机制,通过 skb->pp_recycle 标志和 skb_pp_recycle() 函数,在 skb 释放时直接将 page 归还给 page_pool 而非系统内存池,避免了 page allocator 的开销,实现”原地回收”。
sendfile() 和 splice() 系统调用走的是 SKBFL_SHARED_FRAG 路径:文件页缓存中的 page 直接被加入 skb 的 frags[],内核标记该 frag 为可能被外部修改:
1 | /* This indicates at least one fragment might be overwritten |
此时 skb 的数据直接来自 page cache,全程无需拷贝到内核网络缓冲区。但如果需要计算 TX checksum(ip_summed == CHECKSUM_PARTIAL 但硬件不支持),内核必须通过 skb_copy_ubufs() 先复制 frags 内容,再计算 checksum,这是 zero-copy sendfile 的一个隐含成本。
用户态零拷贝(MSG_ZEROCOPY,SKBFL_ZEROCOPY_ENABLE)则通过 ubuf_info 回调机制,在 DMA 传输完成后通知用户态释放缓冲区,实现用户态缓冲区到网卡的真正零拷贝。
| 结构体 | 大小 |
|---|---|
struct sk_buff |
~232 字节 |
struct skb_shared_info |
~320 字节(含 17 个 frags) |
skb->cb[48] |
48 字节 |
struct tcp_skb_cb |
44 字节(含 IPv6 时) |
struct inet_skb_parm |
~20 字节 |
skb_frag_t (bio_vec) |
12 字节 |
本系列共计划 10 篇,系统覆盖 Linux 网络内核的核心子系统:
| 篇次 | 标题 | 核心内容 |
|---|---|---|
| Part 1 | 总览架构与 sk_buff 生命周期(本文) | 协议栈全路径、sk_buff 内存模型、操作函数、零拷贝 |
| Part 2 | 网卡驱动与 NAPI 机制 | ndo_open/ndo_start_xmit、中断合并、NAPI poll、page_pool、XDP |
| Part 3 | IP 层:路由、分片与转发 | fib_lookup、路由缓存、ip_fragment、conntrack hook 点 |
| Part 4 | TCP 实现(一):连接管理与状态机 | 三次握手、TIME_WAIT、tcp_rcv_state_process、SYN cookies |
| Part 5 | TCP 实现(二):拥塞控制与发送缓冲区 | CUBIC/BBR、tcp_write_xmit、TSQ、Nagle 算法 |
| Part 6 | TCP 实现(三):接收与流量控制 | tcp_rcv_established、OOO 队列、接收窗口、tcp_recvmsg |
| Part 7 | UDP 与多播 | udp_sendmsg、udp_rcv、GRO for UDP、多播路由 |
| Part 8 | Socket 层与 VFS 接口 | sock_alloc、inet_stream_ops、poll/epoll 实现、零拷贝 recv |
| Part 9 | Netfilter 与 iptables/nftables | hook 框架、连接跟踪(nf_conntrack)、NAT 实现原理 |
| Part 10 | 性能调优与 eBPF 网络编程 | RSS/RPS/RFS、SO_REUSEPORT、XDP、TC eBPF、sockmap |
include/linux/skbuff.h、net/core/skbuff.cDocumentation/networking/skbuff.rst(内核源码树内)vLLM 官方 recipes 仓库 提供了超过 57 个生产级部署配方,覆盖 28+ 主流模型家族。本文深入剖析这些配方背后的部署模式、硬件选择、并行策略与调优技巧,帮助你快速从零到一地在生产环境中部署大型语言模型。
Scheduler 是 vLLM 的核心调度器。在每个微秒级的时间窗口内,它都要做出关键决策:处理哪些 request、计算多少个 token、何时 preempt request,以及如何最大化 GPU 利用率。本文将深入探讨 Scheduler 的算法与实现。
在任意时刻,Scheduler 必须处理:
与传统批处理不同,LLM 推理服务面临独特挑战:
vLLM 的核心创新:continuous batching(又称迭代级批处理)。
1 | # Static batching - wait for batch to fill |
问题:
1 | # Continuous batching - add/remove every iteration |
优点:
文件位置:vllm/v1/core/sched/scheduler.py
Scheduler 维护 requests、waiting、running 和 skipped_waiting,并持有 KVCacheManager / EncoderCacheManager 等资源管理器。其中 max_num_scheduled_tokens 的来源是:
1 | self.max_num_scheduled_tokens = ( |
也就是说,max_num_batched_tokens 现在是 fallback,而不是唯一来源。
当前 V1 的 request 状态比“WAITING → RUNNING → FINISHED”更细:
WAITINGWAITING_FOR_STRUCTURED_OUTPUT_GRAMMARWAITING_FOR_REMOTE_KVSWAITING_FOR_STREAMING_REQRUNNINGPREEMPTEDFINISHED_STOPPED、FINISHED_LENGTH_CAPPED、FINISHED_ABORTED、FINISHED_IGNORED、FINISHED_ERROR、FINISHED_REPETITION因此,更准确的流转应理解为:
WAITING 进入 RUNNINGPREEMPTED 并重新入队PREEMPTED → RUNNINGFINISHED当前 RequestQueue 的核心 API 是:
1 | queue.add_request(request) |
如果启用 priority 策略,优先级大小规律也与很多直觉相反:数值越小,优先级越高。PriorityRequestQueue 直接依赖 Request.__lt__,比较顺序是:
priority(更小者优先)arrival_time(更早者优先)request_idschedule() 的核心思路仍然是“先处理 running,再在条件允许时接纳 waiting”。SchedulerOutput 会分别携带:
scheduled_new_reqsscheduled_cached_reqsnum_scheduled_tokens: dict[str, int]total_num_scheduled_tokensscheduled_spec_decode_tokensscheduled_encoder_inputsfinished_req_idspreempted_req_ids可以把主循环简化理解为:
1 | def schedule(self) -> SchedulerOutput: |
token budget 的概念没有变化:它仍然是当前迭代最重要的“算力 / latency / activation memory”上限之一。当前实现用 self.max_num_scheduled_tokens 作为每轮 budget,并在 chunked prefill、running request 续跑、spec decode 等路径上共享这份预算。
当前 scheduler 不再显式维护“prefill phase / decode phase”两套完全独立的 API。更准确的做法是根据:
request.num_computed_tokensrequest.num_tokens_with_specrequest.num_output_placeholders之间的差值来决定本轮还需要为该 request 安排多少 token。
对 running request 来说,scheduler 主要会:
num_new_tokenslong_prefill_token_threshold 等配置裁剪超长 prefillallocate_slots() 尝试拿到新 slot因此,比起“给 request 写回 request.num_scheduled_tokens = ...”,当前实现更准确的说法是:每轮调度数量被记录在 SchedulerOutput.num_scheduled_tokens[request_id] 中。
一个新 request 要进入本轮 batch,通常会经历这些检查:
waiting 或 skipped_waiting 中选择可调度的队列get_computed_blocks() 查找 prefix cache 命中can_fit_full_sequence() 判断完整序列是否可以被系统接纳allocate_slots() 为本轮真正需要的 token 分配 slot这里有两个关键点:
waiting + skipped_waiting 两条队列,而不是单一 waiting queuepreemption 通过 _preempt_request() 完成,流程更接近:
1 | def _preempt_request(self, request, timestamp): |
需要注意:
PREEMPTED 状态重新入队,而不是简单地回到 WAITINGpriority 模式下,会先牺牲较差优先级的 running request;FCFS 模式下则更接近按当前运行队列尾部顺序处理chunked prefill 仍然存在,但当前实现不是 self.chunk_size 这种字段驱动。更贴近源码的配置包括:
enable_chunked_prefilllong_prefill_token_thresholdmax_num_partial_prefillsmax_long_partial_prefills因此,当前行为更像是:
num_new_tokens当前 vLLM 只支持两种调度策略:
fcfsprioritypriority 模式下数字越小越先执行。可以这样理解:
1 | # Lower numeric value = higher scheduling priority |
当前 scheduler 对 speculative decoding 的感知体现在:
spec_token_idsSchedulerOutput.scheduled_spec_decode_tokensnum_lookahead_tokens因此,比起 main_model.verify(draft_tokens) 这种直观伪代码,更准确的描述是:scheduler 会为 lookahead / draft token 预留 slot,并通过 scheduled_spec_decode_tokens 把本轮 spec decode 信息传给后续执行与输出更新路径。
当前本地源码里并没有一个按“预估 prefix cache 命中率”对 waiting requests 进行重排序的 reorder_for_cache_hits() 实现。waiting 请求的顺序主要由 FCFS / priority queue 决定,再叠加 skipped_waiting 的恢复逻辑。
更贴近源码的准入流程是:
1 | computed_blocks, num_cached_tokens = kv_cache_manager.get_computed_blocks(request) |
当存在 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-decoder 场景下,当前 request 并不是暴露 encoder_input_ids 这个简化字段。scheduler 会通过 request.mm_features、_try_schedule_encoder_inputs(),以及 EncoderCacheManager.can_allocate() / allocate() / check_and_update_cache() 来判断 encoder side 是否有容量。
SchedulerStats 的重点字段包括:
num_running_reqsnum_waiting_reqskv_cache_usageencoder_cache_usageprefix_cache_statsconnector_prefix_cache_statskv_cache_eviction_eventsspec_decoding_statsperf_stats而“本轮 preempt 了多少 request”这类信息,当前是放在 IterationStats.num_preempted_reqs 里,而不是 SchedulerStats.num_preempted。
因此,当前 scheduler 更像是在平衡这些目标:
下面给出一个更接近当前实现语义的简化时间线。注意这里使用的是 scheduler_output.num_scheduled_tokens[req_id] 这个“每轮输出”,而不是给 request 对象写回一个持久字段。
1 | waiting = [A(prompt=100), B(prompt=50)] |
A 和 B 都还是新请求1 | scheduler_output.num_scheduled_tokens = { |
A、B 进入 running 集合C,scheduler 会先为 running request 预留预算,再根据 long_prefill_token_threshold 与剩余 budget 决定 C 这一轮最多能推进多少 tokenschedule() 里直接调用某个 finish_request() helper,而是要等 model output 返回后,由 scheduler.update_from_output(...) 更新状态并判断 stop / length cap / error 等 finished 原因B 被 preempt,scheduler 会把它以 PREEMPTED 状态重新入队;等资源恢复后,它会再次进入 RUNNING当前 vLLM 暴露的调度策略只有两种:
FCFSRequestQueueadd_request() / pop_request() / peek_request()PriorityRequestQueuepriority 数值越小越先执行;若相同则按 arrival_time,再按 request_id更贴近当前指标的观察方式是关注:
IterationStats.num_preempted_reqsSchedulerStats.kv_cache_usageSchedulerStats.encoder_cache_usage如果 preemption 频繁发生,通常优先检查:
max_num_seqs 是否过高long_prefill_token_threshold 是否合理如果 SchedulerStats.num_waiting_reqs 长时间偏高,通常意味着:
如果 TTFT 偏高,通常优先检查:
max_num_scheduled_tokens 是否过大Continuous batching 通过每次迭代动态增减 request,消除了队头阻塞
Token budget 控制 batch 大小,平衡延迟与吞吐量的权衡
运行中的 request 拥有优先权,以最小化 decode 延迟
Preemption 是内存紧张时的最后手段:它会中断当前运行进度,但后续恢复时仍可能重新命中 prefix cache
Chunked prefill 在处理长 prompt 与交互式 request 之间取得平衡
当前的 SchedulerOutput 是结构化输出,会同时携带 per-request token 分配、spec decode、encoder 输入与 finished/preempted request 信息
在第四部分中,我们将探索 Request Processing——request 如何从 tokenization 到流式输出在系统中流转,以及跨迭代的状态管理机制。
Scheduler 是 vLLM 的大脑,每秒钟要做出成千上万个瞬间决策,在满足延迟 SLA 的同时最大化 GPU 利用率。接下来,我们将看到 request 如何在系统中端到端地流转。
PagedAttention 是 vLLM 的突破性创新,彻底改变了大语言模型的服务方式。在 PagedAttention 出现之前,高效服务 LLM 长期受到内存碎片化和浪费问题的困扰。本文将深入探讨 PagedAttention 的工作原理、重要意义,以及它在 vLLM 中的实现方式。
在 transformer 模型中,attention 层为每个 token 计算键(K)和值(V)向量。在生成过程中:
挑战:我们必须存储所有之前的 K、V 张量(即”KV cache”),以避免重复计算。
内存大小:以 Llama-3-8B 为例:
对于 2048 个 token 的序列:仅 KV cache 就需要 1 GB!
传统服务系统为每个请求的最大长度预先分配连续内存:
1 | Request 1 (max 2048 tokens, actual 512): |
问题:
实际影响:没有 PagedAttention,你可能只能服务 10 个并发请求;而有了 PagedAttention,同样的内存可以服务 50 个以上的请求!
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 共享 |
取代连续分配的方式:
1 | Request 1 (512 tokens, 32 blocks): |
优势:
文件位置:vllm/v1/core/kv_cache_utils.py
当前源码中的 KVCacheBlock 是一个 @dataclass(slots=True),核心字段包括:
1 | @dataclass(slots=True) |
其中 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。
文件位置:vllm/v1/core/block_pool.py
BlockPool 维护所有 KV cache block、空闲队列与 prefix cache 索引。构造函数的核心形态如下:
1 | class BlockPool: |
这有两个容易忽略的细节:
block_id=0 不是普通数据块,而是 null_block 占位块。cached_block_hash_to_block 不是简单的 dict[int, block],而是支持 hash 冲突的 BlockHashToBlockMap。分配与释放:
BlockPool 的核心接口是 get_new_blocks()、touch() 与 free_blocks():
1 | new_blocks = block_pool.get_new_blocks(num_blocks) |
get_new_blocks() 会从 free queue 批量取出 block,并在需要时调用 _maybe_evict_cached_block() 清理旧的缓存元数据。touch() 用于 prefix cache 命中路径:若命中的 block 仍在 free queue 中,会先移出再增加 ref_cnt。free_blocks() 按列表批量释放 block;调用方通常按逆序释放,以便让尾部 block 更容易被优先回收。当前 v1 更接近“按 KV cache group 组织 block 列表”的实现,而不是 [[7], [23], ...] 这种每个逻辑 block 再包一层列表的结构。
KVCacheBlocks.blocks: tuple[Sequence[KVCacheBlock], ...]get_block_ids() 后,会得到按 KV cache group 分组的 block id 列表block_table tensor对单一 attention group 来说,可以把它理解为一个平坦的 physical block id 序列;有多个 group(如 attention + Mamba)时,外层再按 group 分组。
文件位置:vllm/v1/core/kv_cache_manager.py
KVCacheManager 的职责不只是“给 request 分配新 block”,而是把 prefix cache 命中、完整序列准入、sliding window 裁剪、speculative lookahead,以及 encoder-decoder 的 cross-attention 都纳入同一套 slot 管理逻辑。
当前更重要的接口是:
1 | computed_blocks, num_computed_tokens = kv_cache_manager.get_computed_blocks(request) |
这套接口有几个关键点:
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 仍然是 PagedAttention 最有价值的能力之一,但当前实现比“对 token tuple 做一次 Python hash”要复杂得多。
源码里的 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 可以:
当前 KVCacheManager.get_computed_blocks() 会调用 coordinator / manager 路径来寻找“最长可命中的前缀”,而不是直接遍历 request.block_hashes 并手动递增 ref_cnt。
1 | computed_blocks, num_new_computed_tokens = ( |
几个细节很重要:
max_cache_hit_length 会被限制为 request.num_tokens - 1block_pool.touch(...) 路径,而不是在查找函数里直接修改reference counting 的基本含义没有变化:多个 request 可以共享同一个 prefix block,只有 ref_cnt == 0 时它才会回到 free queue,成为可复用或可淘汰对象。
当前实现不是“扫描 free queue 找第一个 ref_cnt == 0 的 cached block 并返回”的 evict_cached_block() 接口。更接近真实源码的过程是:
get_new_blocks() 先从 free queue 取出候选 blockblock_hash,则调用 _maybe_evict_cached_block(block) 把它从 cached_block_hash_to_block 中摘除evict_blocks(block_ids)也就是说,淘汰是围绕“取出的 block 是否还挂着 cache 元数据”展开的。
当前 v1 并没有统一公开的 paged_attention_prefill(...) Python API。prefill 由具体 attention backend 负责执行,例如 FlashInfer 路径会使用 BatchPrefillWithPagedKVCacheWrapper / BatchPrefillWithRaggedKVCacheWrapper,其他 backend 则会构造自己的 metadata 和 kernel 调用。
对外可以把它理解为两步:
seq_lens 和 block tables 构建 batch 视图decode 阶段通常由具体 attention backend 负责。以 FlashInfer 路径为例,decode 侧会通过 BatchDecodeWithPagedKVCacheWrapper(或 TRT-LLM decode path)直接消费 paged KV cache。
因此,当前实现更准确的描述是:
vLLM 支持不同层使用不同 cache 规格,相关配置类型是 KVCacheGroupSpec / KVCacheConfig。
概念上可以理解为:
KVCacheConfig 持有 num_blocks、kv_cache_tensors 和 kv_cache_groupsKVCacheGroupSpec 绑定一组 layer 名称与对应的 kv_cache_speckv_cache_spec 可以是 FullAttentionSpec、SlidingWindowSpec、MambaSpec 等不同类型因此,一个 request 的 block 记录也会天然按 group 分层,例如:
1 | blocks = ( |
这也是为什么 BlockHash 需要和 group id 绑定:同一段 token 在不同 KV cache group 中对应的是不同的物理缓存项。
使用 PagedAttention 之前(连续分配):
1 | 10 requests × 2048 max length × 512 KB/token = 10.5 GB |
使用 PagedAttention 之后(block 分配):
1 | 500 blocks allocated across all requests |
结果:并发请求数提升 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% |
使用共同系统提示词(500 个 token)时:
| 请求次数 | 未使用缓存 | 使用缓存 | 加速比 |
|---|---|---|---|
| 第 1 次 | 10ms(prefill) | 10ms | 1x |
| 第 2 次 | 10ms | 1ms | 10x |
| 第 100 次 | 10ms | 1ms | 10x |
缓存命中率:对于带有系统提示词的聊天机器人,通常在 60-80% 之间。
对 Mistral 这类 sliding-window 模型,当前实现不会简单地“切片旧 block 然后逐个 free”。更接近真实源码的流程是:
total_computed_tokens 计算哪些 block 已经落到窗口之外remove_skipped_blocks() 把这些位置替换为 null_block这样既保留了逻辑 block table 的形状,又避免了窗口外 token 继续占用可复用的缓存块。
在当前 v1 中,speculative decoding 主要通过 num_lookahead_tokens、slot allocation 和 scheduler 的 scheduled_spec_decode_tokens 协同完成。
更准确地说:
allocate_slots() 只会把最终确认的 token 提交到 prefix cacheref_cnt += 1 / 回滚 block 列表来管理当前实现通过 BlockHashToBlockMap 处理哈希碰撞。对于同一个 hash,值可能是单个 KVCacheBlock,也可能退化成 {block_id: KVCacheBlock} 这样的结构,而不是简单的 hash -> list[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 暴露给外部使用。
PagedAttention 将 KV cache 拆分为固定大小的 block,消除了碎片化和过度分配问题
block table 将逻辑 block 映射到 physical block,类似于虚拟内存的页表
prefix caching 跨请求共享 block,大幅减少了冗余计算
reference counting 确保安全共享,防止过早释放内存
近零内存浪费在实践中实现了 4-5 倍的吞吐量提升
attention 内核经过优化,可直接使用 block 索引存储进行计算
在第三部分,我们将探索 Scheduler——vLLM 的核心调度器,它负责决定处理哪些请求、何时进行抢占,以及如何通过 continuous batching 最大化 GPU 利用率。
PagedAttention 是 vLLM 卓越性能的基础。在下一篇文章中,我们将看到 Scheduler 如何在此之上协调请求的执行。
在深入研究具体组件之前,我们需要先了解 vLLM 的整体架构。本文梳理了各主要组件及其交互方式,为后续各部分的深入探讨奠定基础。
vLLM 的设计围绕以下几个核心原则:
当你向 vLLM 发送一个请求时,整个流程如下:
1 | User Request (HTTP/gRPC) |
V1 架构(于 2024 年底引入)采用多进程设计,以实现更好的 CPU 利用率和进程隔离。下面逐一介绍各进程类型:
职责:处理前端请求、I/O,以及与 engine core 的通信
主要任务:
核心实现:OpenAI-compatible HTTP 服务位于 vllm/entrypoints/openai/api_server.py;gRPC 服务位于 vllm/entrypoints/grpc_server.py。
API server 对于模型执行而言是无状态的。它不了解 GPU 内存、KV cache 或模型权重,只负责:
EngineCoreRequest 对象EngineCoreOutput 对象进程数量:默认单 API server;在在线 data parallel 部署中,api_server_count 的默认值会随 internal / hybrid / external load-balancing 模式变化,并可由 --api-server-count 覆盖。
CPU 线程:使用 VLLM_MEDIA_LOADING_THREAD_COUNT 个线程(默认 8)并行加载媒体文件。
职责:调度请求并协调模型执行
主要任务:
核心实现:vllm/v1/engine/core.py
engine core 运行一个紧凑的循环(run_busy_loop,core.py:1138):
1 | def run_busy_loop(self): |
进程数量:每个 data parallel rank 对应一个。设置 --data-parallel-size 4 时,将启动 4 个 engine core。
CPU 用量:运行忙循环以实现低延迟的调度决策。
职责:在 GPU 上执行模型前向传播
主要任务:
核心实现:vllm/v1/worker/gpu_worker.py
在 MultiprocExecutor 下,通常会看到“每个 GPU 一个 worker 进程”;但在单机单卡默认的 UniProcExecutor 路径中,worker 会运行在 EngineCore 进程内,而不是独立的 OS 进程。worker 负责:
进程数量:取决于 executor 后端。对于 8 个 GPU、设置 --tensor-parallel-size 4 --data-parallel-size 2 这类多 GPU 部署,通常会看到 8 个 worker,由 2 个 engine core 分别协调 2 组 tensor parallel worker。
职责:在 data parallel 部署中汇总引擎状态,并为前端负载均衡与 wave coordination 提供协调信息
主要任务:
START_DP_WAVE核心实现:vllm/v1/engine/coordinator.py
进程数量:当 --data-parallel-size > 1 时启动 1 个,否则不启动。
下面来看几个具体示例。需要注意的是,实际 OS 进程数量会随 executor 后端与 load-balancing 模式变化;以下示例以当前 CLI 默认路径为参考:
1 | vllm serve meta-llama/Llama-3-8B |
进程:
UniProcExecutor)1 | vllm serve meta-llama/Llama-3-70B --tensor-parallel-size 4 |
进程:
1 | vllm serve meta-llama/Llama-3-8B --data-parallel-size 4 |
进程:
1 | vllm serve meta-llama/Llama-3-70B --tensor-parallel-size 2 --data-parallel-size 4 |
进程:
LLMEngine 类是离线推理(直接使用 Python API)的主要入口点。
位置:vllm/v1/engine/llm_engine.py
主要职责:
InputProcessor 处理输入OutputProcessor 转换输出使用示例:
1 | from vllm import LLM, SamplingParams |
在底层,LLM 创建 LLMEngine,LLMEngine 再创建 EngineCore,由其协调各 worker。
Scheduler 是 vLLM 的核心大脑,负责决策:
位置:vllm/v1/core/sched/scheduler.py
Scheduler 维护三个队列:
调度算法(简化版):
1 | def schedule(self) -> SchedulerOutput: |
我们将在第三部分深入探讨 scheduler。
使用 PagedAttention 管理 attention key-value cache 的内存。
位置:vllm/v1/core/kv_cache_manager.py
核心概念:
示例:若块大小为 16,共有 1000 个块,则可以服务于:
我们将在第二部分深入剖析 PagedAttention。
worker 进程负责加载模型并执行前向传播。
Worker(vllm/v1/worker/gpu_worker.py):
具体后端的 model runner(例如 GPUModelRunner / CPUModelRunner / XPUModelRunner,以及较新的 GPU runner 路径):
让我们跟踪一个完整请求在系统中的流转过程:
1 | POST /v1/completions |
[791, 3139, 315, 9822, 374]EngineCoreRequest 对象InputProcessor 阶段分配/随机化,并随 EngineCoreRequest 一起传入SchedulerOutputmodel_executor.sample_tokens(...) 生成本轮 token 输出scheduler.update_from_output(...) 更新请求状态EngineCoreOutput 通过 ZMQ 发送给 API serverdata: {"text": "Paris", "finish_reason": null}vLLM 的配置统一集中在 VllmConfig 中:
1 | from vllm.config import VllmConfig |
许多核心组件会共享或持有同一个 VllmConfig,以确保配置一致性。
主要配置项:
类层次结构遵循一致的模式:
1 | LLMEngine |
许多核心类会接收或持有 VllmConfig,但并非所有类都直接以它作为构造参数;例如 OutputProcessor 并不直接接收 VllmConfig。
API server 与 engine core 通过 ZMQ 进行通信,消息编码采用基于 MsgpackEncoder / MsgpackDecoder 的 multipart frame:
EngineCoreRequest 编码成 multipart 消息并通过 send_multipart(...) 发送recv_multipart(...) 接收,再按消息类型用 MsgpackDecoder 解码为什么选择 ZMQ?
GPU worker 使用 NCCL 进行集合通信操作:
1 | # Tensor parallelism: all-reduce across GPUs |
通信模式:
理解内存布局对于 vLLM 至关重要:
1 | Total GPU Memory: 80GB (H100) |
对于块大小为 16 token 的 8B 模型:
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%) |
现在我们已经了解了整体架构,接下来可以深入探讨各具体组件:
VllmConfig,以保持配置一致性在下一篇文章中,我们将详细探讨 PagedAttention,了解 vLLM 如何通过精巧的内存管理实现近乎零浪费的内存利用。
prow-images 仓库是基于 Kubernetes Prow 构建的复杂 CI/CD 基础设施的核心组件。它作为专用容器镜像的集中式集合,为持续集成和交付流水线的各个方面提供动力。本文将深入探讨 prow-images 生态系统的架构、组件和工作流程,特别关注它与 prow-configs 仓库和 manual-trigger 服务之间的关系。
prow-images 仓库是一个包含超过 35 个不同专用容器镜像的单体仓库(monorepo),每个镜像都设计用于处理 Prow 作业中的特定任务。这些镜像从基本实用工具(如 Git 操作)到复杂工具(如 E2E 测试框架、Kubernetes 集群配置和自动安全 PR 生成)应有尽有。
仓库中的每个组件都遵循一致的结构:
DockerfileVERSION 文件(例如 v0.0.1)entrypoint 目录根目录的 Makefile 负责协调所有镜像的构建和推送到中央镜像仓库 hub.tess.io/prowimages/。
让我们深入了解组成这个生态系统的一些关键组件:
CI Generator 是生态系统中最关键的组件之一。它从简化的清单文件自动生成 Prow 作业规范。
主要特性:
.manifest 文件Build 和 UnitTest工作原理:
ci.manifest 文件onPr)、标签时 (onTag),支持正则表达式模式清单示例片段:
1 | tess/maintenance: |
Kaniko 镜像包装器提供了一种在 Kubernetes Pod 中安全构建容器镜像的方式,无需访问 Docker 守护进程。
功能:
在 Prow 作业中的使用模式:
1 | spec: |
Kind 镜像能够在 CI 流水线中创建临时 Kubernetes 集群用于 E2E 测试。
特性:
这个专用工具自动化创建跨多个集群的安全相关 RBAC 资源的拉取请求。
工作流程:
fcp、cluster、tessAppsAZ、tessNetAZ 或 tessMasterAZall: true 针对作用域中的所有集群使用示例:
1 | clusterRoles: |
这些组件处理拉取请求的自动批准和验证:
多个 E2E 测试镜像提供全面的测试能力:
各种专业的构建工具:
Git 相关实用工具:
用途:容器镜像定义和构建逻辑
位置:/Users/tashen/prow-images
内容:
构建过程:
1 | # 构建所有镜像 |
镜像仓库:所有镜像都推送到 hub.tess.io/prowimages/
用途:Prow 作业配置和 CI/CD 流水线定义
位置:/Users/tashen/prow-configs
结构:
1 | prow-configs/ |
关键文件:
ci.manifest:CI Generator 处理的简化作业定义*.yaml:自动生成的 Prow 作业规范工作流程:
ci.manifest 文件make jobgen 生成作业规范用途:手动作业触发服务
位置:/Users/tashen/test-infra/prow/cmd/manual-trigger
功能:HTTP 服务,允许在没有 GitHub 事件的情况下触发 Prow 作业
开发者操作(prow-configs):
1 | # 在 prow-configs/jobs/myorg/myrepo/ci.manifest |
CI 生成:
作业执行:
pr-${PULL_NUMBER}开发者需要测试特定提交:
1 | curl -X POST "http://manual-trigger.tessprow/manual-trigger" \ |
Manual Trigger 服务:
AUTHOR=developer-name 环境变量作业执行:
安全团队操作:
自动化工作流程:
开发者更新 Kaniko 镜像:
/Users/tashen/prow-images/kaniko/entrypoint/main.go/Users/tashen/prow-images/kaniko/VERSION 中将版本提升到 v0.0.2本地构建:
1 | cd /Users/tashen/prow-images |
CI 集成:
Prow-configs 更新:
latest 标签自动更新所有组件通过 hub.tess.io 的中央镜像仓库进行通信:
hub.tess.io/prowimages/CI Generator 在简单清单和复杂 Prow 配置之间建立桥梁:
.manifest 文件所有三个仓库都使用 Git 进行版本控制和触发:
一切都在 Kubernetes 上运行:
prow-images 中的每个镜像都维护一个 VERSION 文件:
1 | v0.0.1 |
这使得:
作业可以配置在不同的触发器上运行:
.manifest 文件而不是手写作业 YAMLmake jobgenpresubmit 用于 PR 测试,postsubmit 用于分支测试user 参数以进行审计跟踪1 | 1. 创建目录:prow-images/mytool/ |
1 | 1. 创建/编辑 ci.manifest 文件 |
1 | 1. 从 prow-configs 查找作业名称 |
1 | ┌─────────────────────────────────────────────────────────┐ |
问题:make image-kaniko 失败
问题:Prow 作业在 PR 上不触发
问题:”配置中未找到作业 X”
问题:作业使用旧镜像版本
:latest 标签强制拉取prow-images 生态系统代表了基于 Kubernetes 和 Prow 构建的全面的生产级 CI/CD 基础设施。三仓库架构提供了清晰的关注点分离:
这些组件共同实现了:
通过 CI Generator 的清单驱动方法显著降低了 Prow 配置的复杂性,使开发者能够轻松使用,同时保持 Kubernetes 原生 CI/CD 的全部功能。
无论您是构建新工具、添加测试作业还是手动触发部署,理解这三个仓库如何交互是有效使用这个强大 CI/CD 平台的关键。
最后更新:2026 年 4 月 8 日
存储 IO 性能问题是生产环境中最常见也最棘手的问题之一。数据库响应变慢、应用延迟飙升、批处理任务超时,背后往往隐藏着复杂的 IO 瓶颈。本文从内核源码层面出发,系统梳理 Linux 存储性能的监控、分析与调优方法,覆盖从 iostat 到 eBPF、从 blktrace 到 ftrace 的完整工具链。
在深入探讨 Direct IO 与异步 IO 实现之前,有必要先厘清 Linux 下各种 IO 模型的本质差异。POSIX 标准定义了同步与异步两大类 IO,而 Linux 在此之上提供了更丰富的变体。
同步阻塞 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 | ┌─────────────────────────────────────────────────────────────────┐ |
两个关键维度区分这些模型:等待数据就绪的过程是否阻塞;数据拷贝(内核→用户)的过程是否阻塞。只有真正的异步 IO 在两个维度上都不阻塞调用者。
Linux 还支持信号驱动 IO(SIGIO/O_ASYNC),通过 fcntl(fd, F_SETOWN, pid) 注册信号接收者。当 fd 就绪时内核发送 SIGIO 信号。但这种模式在实践中很少使用,因为信号处理函数受到很多限制,且无法区分多个 fd 的就绪事件。
Linux 的页缓存(Page Cache)是提升 IO 性能的核心机制:读操作的数据被缓存在内存中供后续复用,写操作先写入内存中的脏页(dirty page),由内核的 pdflush/writeback 线程异步刷盘。对于大多数应用,这种双重缓冲能显著提升吞吐量。
然而,对于数据库系统(PostgreSQL、MySQL InnoDB、Oracle 等),页缓存是一个障碍:
Direct IO 有严格的内存和偏移对齐要求,违反会得到 EINVAL:
内核在 do_blockdev_direct_IO 入口检查这些约束:
1 | /* fs/direct-io.c */ |
struct dio 结构体与核心函数struct dio 是 Direct IO 操作的核心控制块,定义在 fs/direct-io.c:
1 | /* fs/direct-io.c */ |
do_blockdev_direct_IO 是发起 Direct IO 的核心入口,它协调用户空间缓冲区的 pin(通过 get_user_pages)、构建 bio 链并提交到块层:
1 | ssize_t __blockdev_direct_IO(struct kiocb *iocb, struct inode *inode, |
dio_bio_submit 与 DMA 传输dio_bio_submit 将构建好的 bio 提交到通用块层(Generic Block Layer),最终由设备驱动程序通过 DMA 完成数据传输:
1 | static void dio_bio_submit(struct dio *dio, struct dio_submit *sdio) |
DMA(Direct Memory Access)传输的原理:控制器从 bio 中取出物理内存页地址(bio_vec 数组),通过总线直接在磁盘控制器与主存之间搬运数据,CPU 全程不参与数据拷贝,完成后通过中断通知内核。这是 Direct IO 高效的根本原因——减少了一次内核缓冲区到用户缓冲区的 memcpy。
fs/aio.c)struct kiocb 字段解析struct kiocb(Kernel IO Control Block)是内核异步 IO 的基本请求描述符,定义在 include/linux/fs.h:
1 | struct kiocb { |
ki_flags 中的关键标志:
IOCB_EVENTFD:完成时通过 eventfd 通知IOCB_DIRECT:使用 Direct IO 路径IOCB_NOWAIT:如果操作需要等待则立即返回 EAGAINIOCB_NOIO:不允许发起新 IO(用于预读路径)io_submit() 系统调用实现Linux AIO 的提交入口是 io_submit() 系统调用,对应内核函数 __io_submit_one:
1 | /* fs/aio.c */ |
io_submit() 每次调用可以批量提交多个 iocb 请求,内部对每个请求调用 __io_submit_one,分配 aio_kiocb,填充后提交到相应的文件操作实现。
io_getevents() 轮询机制提交后,应用通过 io_getevents() 收割完成事件:
1 | /* fs/aio.c */ |
完成事件写入 io_event 结构:
1 | struct io_event { |
Linux 内核 AIO 存在若干根本性限制,这也是 io_uring 诞生的主要动因:
aio_read/aio_write 实际上并不是真正异步的——当页缓存缺页时会同步阻塞在工作队列线程里,只是把阻塞转移到了内核线程,并未消除。io_submit 每次需要从用户空间拷贝 iocb 结构(每个 64 字节),无法利用共享内存避免拷贝。io_getevents 轮询开销:需要进入内核态才能获取完成事件。fsync(早期版本):无法异步地刷盘。io_uring 由 Jens Axboe 在 2019 年引入(Linux 5.1),彻底解决了 Linux AIO 的局限性。其核心思路是通过共享内存环形队列实现用户态与内核态的零拷贝通信。
struct io_ring_ctx 核心上下文1 | /* io_uring/io_uring.c(简化) */ |
io_ring_ctx 按缓存行对齐拆分,SQ 和 CQ 各占独立缓存行,避免多核并发时的伪共享(false sharing)。
io_uring 的精髓在于:内核与用户空间共享同一块物理内存,通过生产者-消费者模型通信,无需系统调用即可提交和收割大量 IO。
1 | 用户空间 内核空间 |
SQE(Submission Queue Entry)结构,每个 64 字节:
1 | struct io_uring_sqe { |
CQE(Completion Queue Entry)结构,每个 16 字节:
1 | struct io_uring_cqe { |
io_uring_setup() 与初始化1 | /* io_uring/io_uring.c */ |
IORING_SETUP_SQPOLL 内核轮询线程模式开启 IORING_SETUP_SQPOLL 后,内核创建一个内核线程(io_sq_thread)持续轮询 SQ 环:
1 | /* io_uring/sqpoll.c(简化) */ |
SQPOLL 模式下,应用提交 SQE 只需写共享内存,内核线程自动发现并处理,完全零系统调用。这对高 IOPS 场景(NVMe SSD,百万级 IOPS)效果显著,系统调用开销本身可能成为瓶颈。
注册固定资源可以避免每次 IO 时重复的 get_user_pages(锁定内存页)和 fget(引用计数)开销:
1 | /* 注册固定缓冲区 */ |
注册文件(IORING_REGISTER_FILES)类似,将 fd 数组预先注册,SQE 中 flags |= IOSQE_FIXED_FILE,fd 字段为数组索引而非真实 fd,绕过每次 fget/fput 的引用计数操作。
io_uring 支持将多个 SQE 链接为有序序列,前一个完成后才提交下一个:
1 | /* 读取文件 → 处理 → 写入另一个文件,串行执行 */ |
链式请求中任意一步失败,后续步骤会以 -ECANCELED 取消,类似事务语义。
1 | #include <liburing.h> |
| 维度 | Buffered IO | Direct IO |
|---|---|---|
| 缓存效果 | 热数据命中后接近内存速度 | 无缓存,每次都落盘 |
| 内存占用 | 页缓存占用物理内存 | 仅用户缓冲区 |
| 适用场景 | 随机小读写、反复访问相同数据 | 数据库 Buffer Pool、流式大文件读写 |
| 对齐要求 | 无 | 严格(512B 或 4096B) |
| 写安全性 | 需 fsync 保证持久化 |
数据直达磁盘(仍需考虑磁盘缓存) |
| 顺序读写吞吐 | 接近(预读机制补偿) | 略低(无预读,需自行管理) |
推荐使用 Direct IO 的典型场景:
effective_io_concurrency,MySQL InnoDB 的 innodb_flush_method=O_DIRECT)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 |
否(早期) | 是 | 是 |
| 固定缓冲区 | 否 | 是 | 是 |
测试 Buffered IO 顺序写吞吐量:
1 | fio --name=buffered-seq-write \ |
测试 Direct IO 随机读 IOPS:
1 | fio --name=direct-rand-read \ |
测试 io_uring 随机读写(混合 70/30):
1 | fio --name=io-uring-mixed \ |
解读关键指标:
IOPS:每秒完成的 IO 操作次数,评估随机 IO 能力BW:带宽(MB/s),评估顺序 IO 吞吐lat (usec):延迟,avg 是平均值,99.00th 是 P99 尾延迟——对数据库场景尤为重要clat:完成延迟(Completion Latency),从 IO 提交到完成的时间内核参数:
1 | # 提升 AIO 最大并发请求数(默认 65536) |
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)实现。