上一篇我把 KAI Scheduler 当成一个平台来读:它不是单个 scheduler 进程,而是一串围绕 CRD、controller 和 scheduling session 组织起来的控制面。

这一篇开始看它真正的“主干路径”:一个 workload 进入集群后,到底是怎么一步步变成调度决策,再变成真实节点绑定的?

如果要先给结论,我会这样概括:

在 KAI 里,提交 workload 并不会立刻进入“给 Pod 选 node”的同步流程,而是先被重写成更适合调度的 workload model,再进入周期性调度循环,最后通过 BindRequest 异步落地。

这条路径一共可以拆成六步:

  1. workload 进入 Kubernetes
  2. pod-grouper 抽取 workload 语义,创建 PodGroup
  3. scheduler 在一个 cycle 里对 PodGroupQueue 做 placement 决策
  4. scheduler 创建 BindRequest
  5. binder reconcile BindRequest 并执行真实绑定
  6. PodGroup/Queue controller 回写状态,形成反馈闭环

先看整条链路

sequenceDiagram
    participant U as User / Workload Controller
    participant A as Admission
    participant PG as Pod Grouper
    participant PGCRD as PodGroup CRD
    participant Q as Queue CRD
    participant S as Scheduler
    participant BR as BindRequest CRD
    participant B as Binder
    participant PGC as PodGroup Controller
    participant QC as Queue Controller
    participant PM as Prometheus / Metrics Store

    U->>A: 创建 Pod / Job / Ray / Kubeflow workload
    A-->>U: 准入校验 / 变更
    U->>PG: Pod 被 watch 到
    PG->>PGCRD: 基于 top owner 创建或更新 PodGroup
    U->>Q: workload 通过 queue 标签归属某个 Queue
    S->>PGCRD: 在 snapshot 中读取 PodGroup
    S->>Q: 在 snapshot 中读取 Queue
    S->>BR: 为选中的 Pod 创建 BindRequest
    B->>BR: reconcile BindRequest
    B->>B: 执行 bind / 资源准备 / 回滚
    B-->>U: Pod 绑定到目标节点
    PGC->>PGCRD: 回写资源状态与部分运行态
    QC->>Q: patch Queue 状态
    QC->>PM: 暴露队列级资源指标
    PM-->>S: usage DB client 查询历史 usage

这个 sequence 图里,真正最关键的转折点有两个:

  • Pod -> PodGroup:KAI 把原始 workload 转成更适合调度的抽象。
  • Decision -> BindRequest:KAI 把调度决策和执行绑定解耦。

只要把这两个转折点读懂,整个系统就顺了。

第一步:workload 进入集群,但 scheduler 还不会立刻直接处理它

KAI 支持的工作负载并不只有单 Pod。它非常强调 AI / batch / distributed workload 的调度语义,所以常见入口包括:

  • 原生 Pod
  • Job / CronJob(以及其他 batch 类上层对象)
  • Deployment
  • MPIJob
  • Ray
  • Kubeflow 相关 workload
  • JobSet
  • 以及其他带 owner reference 的上层对象

这些对象刚进入集群时,并不天然具备 KAI 需要的完整调度语义。

例如,scheduler 真正关心的问题往往是:

  • 这些 Pod 是否必须一起启动?
  • 它们属于哪个 queue?
  • 它们默认优先级和 preemptibility 是什么?
  • 它们有没有 topology constraint?
  • 它们是不是多级 subgroup 结构?

这些信息如果散落在不同 workload 的不同字段里,后续调度逻辑会非常难维护。于是 KAI 先做了一次“语义收敛”。

第二步:pod-grouper 把 workload 重写成 PodGroup

pod-grouper 是整条链路里最容易被低估的组件。

很多人看 scheduler 时会把 pod-grouper 当成辅助服务,但实际上它承担的是“把 Kubernetes 原生 workload 翻译成 KAI workload model”的职责。

它在做什么

pod-grouper 监听 Pod,然后做三件事:

  1. 找到 Pod 的 top owner
  2. 根据 owner 类型选择合适的 grouping plugin
  3. 生成或更新 PodGroup

文档 docs/developer/pod-grouper.md 给出的核心思路非常清楚:它不是简单按 label 聚合 Pod,而是通过 owner chain 去推断真正的 workload 边界。

为什么一定要找 top owner

因为很多 Kubernetes workload 都有多层 owner reference。

例如:

  • Pod 的 owner 是 ReplicaSet
  • ReplicaSet 的 owner 又是 Deployment

如果只按 Pod 当前直接 owner 聚合,你得到的是“副本控制器层”的分组,而不是“业务 workload 层”的分组。KAI 的做法是继续往上找,找到最适合代表 workload 语义的对象。

这一步带来的收益很大:

  • 同一个 workload 的 Pod 可以稳定归到同一个 PodGroup
  • 不同 workload 类型可以保留各自的分组逻辑
  • 调度器不需要理解所有 workload CRD 的细节,只需要理解 PodGroup

PodGroup 才是 KAI 真正的 workload 主语

pkg/apis/scheduling/v2alpha2/podgroup_types.go 里,PodGroupSpec 已经不仅仅是一个 minMember 包装器,它承载的是 workload 的调度语义:

  • MinMember
  • MinSubGroup
  • Queue
  • PriorityClassName
  • Preemptibility
  • TopologyConstraint
  • SubGroups
  • MarkUnschedulable
  • SchedulingBackoff

这里最重要的不是字段多,而是字段的组合方式。

这意味着 KAI 在表达的是:

  • 这是一个 gang 还是普通任务?
  • 这是平面 workload 还是层级 workload?
  • 它属于哪个资源队列?
  • 它可以被抢占吗?
  • 它有机架、zone、拓扑域要求吗?

也就是说,从这一步开始,系统不再是“对一堆 Pod 做 placement”,而是“对带有完整调度语义的 workload group 做 placement”。

第三步:Queue 把 workload 放进资源与公平性的上下文里

PodGroup 解决的是 workload 建模问题,Queue 解决的是资源分配问题。

在 KAI 里,queue 不是顺手加的一个标签,而是公平性和资源边界的核心对象。很多关键特性都建立在它上面:

  • hierarchical queue
  • quota / deserved quota
  • over-quota share
  • reclaim
  • fair-share
  • time-based fairshare

也就是说,scheduler 在做决策时,看到的不是“某个 Pod 能不能放到某个节点”,而是:

某个 PodGroup 在它所属 Queue 的资源语境下,是否应该得到这次调度机会。

这是 KAI 跟只做 node fitting 的调度器非常不同的一点。

第四步:scheduler 在一个 cycle 里对 snapshot 做决策

等到 PodGroupQueue 都就位以后,scheduler 才开始它真正擅长的事情。

这里的关键点不是某个单独算法,而是 周期性调度模型

KAI scheduler 不是收到一个 Pod 事件就立刻同步做完全部决策。更准确地说,是启动时先让 cache 跑起来并等待同步;进入稳定运行后,每个 cycle 再做:

  1. 获取 snapshot
  2. 打开 session
  3. 执行 actions
  4. 关闭 session

这带来两个直接好处:

1. 所有决策基于同一份一致视图

在同一个 cycle 里,所有 action 和 plugin 都基于同一个 cluster snapshot 工作。

这对复杂特性尤其重要:

  • gang scheduling
  • reclaim
  • preempt
  • topology-aware placement
  • dynamic resources
  • queue fairshare

如果没有 snapshot 这一层,很多逻辑会变成“边读边改边抢锁”,复杂度会急剧上升。

2. scheduler 可以处理的是 workload 级别的组合决策

因为它不是只看一个 Pod,而是看 snapshot 里的:

  • nodes
  • queues
  • podgroups
  • bindrequests
  • 资源状态
  • 历史 usage

所以它做出来的是更接近“全局最优”或“局部一致最优”的决策,而不只是一个 request/response 式的即时选择。

第五步:scheduler 不直接 bind,而是创建 BindRequest

这是 KAI 整条工作流里最精彩的一步。

pkg/scheduler/cache/cache.go 里,SchedulerCache.Bind(...) 的逻辑不是直接调用 Pod binding API,而是创建 BindRequest

当前 scheduler 创建 BindRequest 时,BindRequestSpec 里主要会写入:

  • PodName
  • SelectedNode
  • ReceivedResourceType
  • ReceivedGPU
  • SelectedGPUGroups
  • ResourceClaimAllocations

另外,BindRequest 类型里确实还定义了 BackoffLimit,但当前 scheduler 创建对象时并不会主动填充它。更重要的是,scheduler 输出的不是一个轻量的“node choice”,而是一份 完整的绑定执行意图

为什么这一步非常重要

如果 scheduler 直接 bind Pod,那它就必须自己同步承担这些职责:

  • 资源声明与 DRA 处理
  • 共享 GPU 相关准备
  • 失败重试
  • 失败回滚
  • 状态更新
  • 绑定副作用控制

这会让调度循环变得又慢又重。

KAI 的选择是把它们拆开:

  • scheduler 专注于 决策
  • binder 专注于 执行

中间用 BindRequest 这个 CRD 做状态中介。

这类设计最大的价值不是“看起来解耦”,而是真正在错误模型和并发模型上得到收益:

  • scheduler cycle 可以更短
  • binder 可以独立重试
  • 执行错误不会把调度内核拖进复杂副作用里
  • 中间态对象便于排障和观测

第六步:binder reconcile BindRequest,把决策变成真实集群状态

binder 读取到 BindRequest 后,会进入 controller-runtime 的 reconcile 流程。

它的大致步骤是:

  1. 取回 BindRequest
  2. 找到对应 Pod
  3. 找到目标 Node
  4. 如果 Pod 已经绑定则退出
  5. 调用 binder 实现执行真实 bind
  6. 如果失败则 rollback
  7. 更新 BindRequest 状态和 Pod condition

这里最值得注意的是两点。

1. binder 处理的是“执行型失败”

比如:

  • 节点在短时间内变化了
  • 资源声明失败了
  • 共享 GPU 相关准备失败了
  • 某些 bind-time side effect 没完成

这些都属于执行面的问题,而不是 placement 逻辑本身的问题。

KAI 通过 binder 把这些风险从主调度循环里隔离了出来。

2. binder 能做 rollback

这很关键。

当 bind 失败时,binder 会尝试 rollback。对于一个支持 DRA、GPU sharing、复杂资源声明的系统来说,这是必须的。

因为 scheduler 的决策不只是“选 node”,它往往还隐含了若干资源分配语义。如果失败后没有清理干净,后面很容易出现资源状态污染。

最后一环:状态回写与历史反馈

如果工作流只到 binder 为止,那 KAI 还只是一个“高级 scheduler”。

它之所以更像平台,是因为后面还有完整的反馈闭环。

PodGroupController

它主要回写 PodGroup.ResourcesStatus,并补充部分调度/运行态信息。

这让 PodGroup 不只是“调度前的输入”,也变成“调度后的观测对象”。

QueueController

它主要 patch Queue 状态,并把队列级资源统计暴露成指标,为公平性和资源分配提供基础。

Prometheus / usage DB

当 time-based fairshare 打开以后,这条反馈链会继续延伸:

  • podgroup-controller 更新 podgroup 资源状态
  • queue-controller patch queue 状态并导出指标
  • Prometheus 存储这些历史指标
  • scheduler 通过 usage DB client 读取历史数据

这就让 KAI 不只是“看当前资源”的 scheduler,而是一个能把 历史使用行为 纳入决策的系统。

KAI 的 workflow 为什么适合 AI / batch workload

把整条链路看完以后,会很容易理解它为什么适合 AI 场景。

1. 它先建模,再调度

AI workload 往往不是单 Pod,而是:

  • 多副本训练
  • 分布式推理
  • 分层通信结构
  • gang / subgroup / topology 约束

KAI 通过 PodGroup 先把这些语义固定下来,再做 scheduling,避免把 workload 复杂性泄漏进每个 action/plugin 的实现里。

2. 它先决策,再执行

AI workload 的 bind-time 复杂度通常更高:

  • GPU sharing
  • DRA / ResourceClaim
  • volume / storage 约束
  • 失败回滚

BindRequest 把 decision plane 和 execution plane 分开,能让调度器更专注,也让系统更稳定。

3. 它有反馈闭环

公平性不只是“这一刻谁先跑”,而是“长期来看谁占用了多少资源”。

KAI 通过 queue controller 导出队列级指标,再由 Prometheus 和 usage DB client 把这些历史数据带回调度决策里,因此它可以逐步走向时间维度的公平性,而不是只做瞬时队列排序。

我对这条工作流的一个总结

如果把 KAI 的控制面 workflow 用一句话概括,我会写成:

先把 Kubernetes workload 翻译成 KAI 的 workload language,再在一致性 snapshot 上做调度决策,最后把决策通过异步 binding pipeline 落地,并把结果反馈回 queue 与 workload 状态。

这条链路的好处是系统边界非常清楚:

  • pod-grouper 负责“翻译 workload”
  • scheduler 负责“决定谁该上、上到哪”
  • binder 负责“把决定真的执行掉”
  • controller 们负责“把执行结果重新喂回系统”

下一篇看什么

理解 workflow 以后,下一步最自然的问题就是:

  • snapshot 到底是什么?
  • session 里都存了什么?
  • action 和 plugin 究竟谁负责流程,谁负责策略?
  • KAI 为什么要引入 statement / scenario 这种“事务化模拟”模型?

下一篇就进入调度内核本身,拆解 KAI 最核心的设计:

cycle、snapshot、session、action、plugin、statement。

KAI Scheduler 这个项目第一眼很容易被名字误导:看起来像是一个“GPU 版 kube-scheduler”,但真的把仓库翻一遍以后,会发现它更像是一套围绕 AI/ML 工作负载构建的 Kubernetes 原生调度平台

它当然有 scheduler 这个核心二进制,但仓库真正的架构中心并不是单个调度循环,而是下面这几个层次同时成立:

  1. 工作负载建模:把 Pod、Job、Ray、Kubeflow、JobSet 之类的对象,统一收敛成 PodGroupQueue 这些调度语义更强的 CRD。
  2. 调度决策:scheduler 基于 snapshot/session/actions/plugins 做周期性调度。
  3. 执行落地:scheduler 不直接 bind Pod,而是创建 BindRequest,交给 binder 异步执行。
  4. 状态与反馈:PodGroup Controller、Queue Controller 与 scheduler status updater 一起回写当前状态;当启用 Prometheus-backed usage DB 时,历史使用量再通过 usage DB client 回流给调度器。
  5. 平台配置:operator 通过 ConfigSchedulingShard 把整套控制面部署出来,并且把每个 shard 的调度策略参数化。

如果只盯着 pkg/scheduler,会看见一个很强的调度内核;但如果把 cmd/pkg/apis/pkg/operator/pkg/binder/pkg/podgrouper/ 放在一起,才会看见 KAI 真正的系统轮廓。

先从 repo 结构建立“地图感”

这个仓库最值得先看的不是某个具体算法,而是目录本身。下面这个目录树是为了建立“地图感”整理出的简化视图:

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
cmd/
admission/
binder/
operator/
podgrouper/
podgroupcontroller/
queuecontroller/
scheduler/
nodescaleadjuster/
resourcereservation/
snapshot-tool/
fairshare-simulator/
time-based-fairshare-simulator/

pkg/
apis/
binder/
operator/
podgrouper/
podgroupcontroller/
queuecontroller/
scheduler/
common/

docs/
developer/
operator/
fairness/
queues/
topology/
time-based-fairshare/

这不是逐目录逐文件的完整清单,但已经足够说明一件事:KAI 的实现方式不是把复杂性都堆进 scheduler 进程,而是把不同职责拆成多个控制器和服务。

cmd/:每个二进制都代表一个架构角色

cmd/ 可以直接看出 KAI 的主要运行单元:

  • scheduler:真正做 placement 决策的内核。
  • binder:异步执行 bind、资源准备、回滚。
  • podgrouper:监听匹配 schedulerName 的 Pod,并把 workload 转换成 PodGroup
  • podgroupcontroller:回写 PodGroup 的资源状态,并补充部分运行态信息。
  • queuecontroller:回写 Queue 状态,并暴露队列级资源统计指标。
  • operator:把整套 KAI 控制面部署出来。
  • admission:准入控制和 webhook。
  • nodescaleadjuster:和节点伸缩场景对接。
  • snapshot-toolfairshare-simulatortime-based-fairshare-simulator:偏调试、验证、离线分析工具。

这跟传统印象中的“scheduler 就是一切”非常不同。KAI 的设计明显在强调:

调度不是一个函数调用,而是一条跨多个控制器的控制面流水线。

pkg/apis/:真正的架构边界写在 CRD 里

很多 Kubernetes 项目表面上是 controller,实际上系统边界都藏在 CRD 里。KAI 也是这样。

对理解全局最关键的几个 API 是:

  • Config
  • SchedulingShard
  • Queue
  • PodGroup
  • BindRequest
  • Topology

这些对象并不是简单的配置载体,它们定义了 KAI 如何把“AI 集群调度”拆成若干可以被控制器协作处理的状态机。

KAI 的整体架构可以拆成五层

我把它整理成下面这个图:

flowchart LR
    User[用户 / 工作负载控制器] --> Admission[Admission]
    Admission --> PodGrouper[Pod Grouper]
    PodGrouper --> PodGroup[PodGroup CRD]
    User --> Queue[Queue CRD]

    Config[Config CRD] --> Operator[Operator]
    Shard[SchedulingShard CRD] --> Operator
    Operator --> Scheduler
    Operator --> Binder
    Operator --> QueueController
    Operator --> PodGroupController
    Operator --> PodGrouper

    Queue --> Scheduler[Scheduler]
    PodGroup --> Scheduler
    Nodes[Node / PVC / DRA / CSI / etc] --> Scheduler

    Scheduler --> BindRequest[BindRequest CRD]
    BindRequest --> Binder[Binder]
    Binder --> Pods[Pods bound to nodes]

    Pods --> PodGroupController[PodGroup Controller]
    Pods --> QueueController[Queue Controller]
    PodGroupController --> PodGroup
    QueueController --> Queue
    QueueController --> Prometheus[Prometheus Metrics]
    Prometheus --> UsageDB[Usage DB Client]
    UsageDB --> Scheduler

这个图最想表达的是三件事:

1. KAI 先把 workload 变成“可调度对象”,再做调度

Kubernetes 原生对象很多:Pod、Job、Deployment、RayCluster、PyTorchJob、MPIJob、JobSet……

但真正做公平性、gang scheduling、拓扑约束、优先级/可抢占性判断时,直接围绕这些原生对象写逻辑会非常散。

KAI 的做法是通过 pod-grouper 把它们统一抽象成 PodGroup,再配合 Queue 建立调度语义。这一步非常关键:

  • PodGroup 代表“应该一起考虑的 workload 单元”
  • Queue 代表“资源应该如何分配给哪个组织/租户/项目”

也就是说,KAI 不是在调度“孤立的 Pod”,而是在调度 workload group 在 queue 体系里的资源位置

2. scheduler 只负责决策,binder 负责执行

这是我读完整个仓库以后最喜欢的一点。

KAI 没有把“算出节点”与“执行绑定”写成一个同步链路,而是中间插了一个 BindRequest CRD。

这意味着:

  • scheduler 可以更专注在 决策速度和一致性 上;
  • binder 可以独立处理 绑定、副作用、资源声明、GPU sharing、失败回滚
  • 两个子系统的错误模型、重试模型、并发模型可以分开设计。

这类设计在控制面系统里非常常见,但在 scheduler 里做得这么明确,其实很少见。

3. KAI 不是静态策略,而是可配置的调度平台

很多项目的“可扩展”只是加几个 flag。KAI 明显不是这个级别。

SchedulingShard 这一层直接把下面这些内容暴露成平台配置面:

  • partitionLabelValue / node pool label
  • placement strategy(binpack / spread)
  • queue depth
  • min runtime
  • plugin/action override
  • historical usage 配置
  • time-based fairshare 参数

也就是说,KAI 的目标不是给你一个固定算法,而是给你一个 围绕 AI 工作负载场景可演化的调度框架

四个最关键的 CRD:它们决定了系统的“语义骨架”

Config:整套控制面的安装与组合

Config 不是一个简单的 values 文件替代品,它实际上定义了 KAI 控制面里有哪些服务、这些服务怎么部署、Prometheus 是否启用、全局配置如何下发。

pkg/apis/kai/v1/config_types.go 可以看到,ConfigSpec 直接包含:

  • PodGrouper
  • Binder
  • Admission
  • Scheduler
  • QueueController
  • PodGroupController
  • NodeScaleAdjuster
  • Prometheus

这很像在 Kubernetes 里做了一层“平台安装 API”。

SchedulingShard:调度器不再是单实例,而是按分片部署

SchedulingShard 是另一个非常有代表性的对象。

它说明 KAI 并不把“集群只有一个 scheduler 实例”当成默认前提,而是支持按 node pool / partitionLabelValue 做逻辑分片,并让带有对应分片标签的 Queue / PodGroup 进入同一个调度域。每个 shard 都可以有自己的:

  • partitionLabelValue
  • placement strategy
  • queueDepthPerAction
  • kValue
  • usageDBConfig
  • Plugins
  • Actions

这代表 KAI 的“平台视角”很强:

不是所有 GPU 集群都该共享同一种调度策略。

Queue:资源公平性的组织边界

如果只把 KAI 看成 GPU 调度器,容易低估 Queue 的重要性。实际上 queue 才是公平性、quota、over-quota、reclaim 的基础对象。

KAI 的很多高级能力——尤其是 fairshare、hierarchical queue、reclaim、time-based fairshare——都建立在 queue 体系之上。

PodGroup:KAI 的工作负载主语

我觉得 PodGroup 是这个项目最值得花时间理解的对象。

pkg/apis/scheduling/v2alpha2/podgroup_types.go 里,它已经不只是“gang scheduling 的 minMember 容器”,而是逐渐长成了 KAI 的 workload API:

  • MinMember
  • MinSubGroup
  • Queue
  • PriorityClassName
  • Preemptibility
  • TopologyConstraint
  • SubGroups

而在 Status 侧,PodGroup 还维护了 SchedulingConditionsResourcesStatus,让它同时成为 workload 输入模型和运行态观察点。

看到这里就能理解:KAI 实际上在把“AI workload 的调度语义”沉淀进一个独立 API,而不是永远寄生在原生 Pod 字段上。

为什么 scheduler / binder 拆分是全局设计的支点

如果只能记住 KAI 架构中的一件事,我建议记住这一句:

scheduler 决定“应该放哪”,binder 决定“怎么把它真的放上去”。

这个拆分的收益非常大:

  1. 调度循环可以保持短而稳定

    • 不需要在一个 scheduling cycle 里等待所有 bind 副作用完成。
  2. 复杂资源准备可以异步处理

    • 比如 DRA、PVC/CSI、GPU sharing、resource reservation。
  3. 失败与回滚逻辑可以局部化

    • binder 失败不等于 scheduler 内核设计失败;它只是执行面的一次 reconcile 失败。
  4. 状态可观察性更好

    • BindRequest 本身就成了一个中间态对象,便于排障、审计和重试。

这也是为什么我认为 KAI 更像“调度平台”而不是“单个调度器实现”。

我对 KAI 架构的一个总结

读完 repo 以后,我会用下面这句话概括它:

KAI Scheduler 把 AI 集群调度拆成了 工作负载建模、资源分配决策、绑定执行、状态反馈、平台配置 五个层面,并用 CRD + controller + session-based scheduler 的方式把它们拼成一个完整控制面。

这个思路跟很多只在单个 scheduling cycle 上做文章的项目不一样。KAI 真正重视的是:

  • gang scheduling 不是插件细节,而是 workload model;
  • fairshare 不是一个排序函数,而是 queue 系统;
  • topology 不是一个 affinity 小技巧,而是跨 workload 结构的策略面;
  • scheduler 的职责不是把所有事情都做完,而是生成高质量、可执行、可回滚的调度决策。

推荐的阅读顺序

如果你准备继续往下读源码,我建议按这个顺序:

  1. docs/developer/scheduler-concepts.md
  2. docs/developer/action-framework.md
  3. docs/developer/plugin-framework.md
  4. docs/developer/binder.md
  5. docs/developer/pod-grouper.md
  6. docs/operator/scheduling-shards.md
  7. pkg/scheduler/scheduler.go
  8. pkg/scheduler/framework/
  9. pkg/scheduler/cache/cache.go
  10. pkg/binder/controllers/bindrequest_controller.go

下一篇看什么

理解完组件地图以后,下一步就该看这套系统的“主干工作流”:

  • 一个 Pod 进入集群之后,什么时候变成 PodGroup
  • QueuePodGroup、node pool label 是怎么汇合到一起的?
  • scheduler 为什么不直接 bind Pod,而是创建 BindRequest
  • PodGroup Controller 和 Queue Controller 又把什么信息回写给系统?

下一篇就沿着这条链路,从 Pod 到 BindRequest,把 KAI 的控制面工作流走一遍。

Virtlet learning

Key Components

  1. Virtlet Manager:
    • Implements the CRI interface for virtualization and image handling
    • Processes requests from kubelet
    • Sets up libvirt VM environment (virtual drives, network interfaces, resources)
    • Manages VM lifecycle through libvirt
  2. Tapmanager:
    • Controls VM networking using CNI
    • Takes setup requests from Virtlet manager
    • Runs DHCP server for each active VM
    • Serves requests from vmwrapper
    • Tears down VM networks upon Virtlet manager requests
  3. VMWrapper:
    • Run by libvirt, wraps the emulator (QEMU/KVM)
    • Requests tap file descriptor from tapmanager
    • Adds command line arguments for the emulator
    • Execs the emulator
  4. Libvirt:
    • Manages VM lifecycle
    • Provides API for VM operations
  5. QEMU/KVM:
    • The actual emulator that runs VMs
  6. CRI Proxy:
    • Allows running multiple CRI implementations on the same node
    • Routes requests to appropriate runtime (Virtlet or dockershim)

Volume Management

Virtlet supports various volume types:

  1. Root Volumes: The main VM disk
  2. Cloud-Init Volumes: For VM configuration
  3. Raw Devices: Direct access to host devices
  4. Kubernetes Volumes: Integration with Kubernetes volume system

The volume management is handled through:

  • VMVolumeSource interface
  • Various volume implementations (rootfs, cloudinit, raw, etc.)
  • Libvirt storage pools

Networking Architecture

VM Lifecycle Management

Code Flow

Detailed Code Flow Explanation

1. Pod Creation and Annotation Parsing

  • Starting Point: Kubernetes creates a pod with Virtlet-specific annotations
  • Key Files: pkg/metadata/types/annotations.go
  • Process:
    • The VirtletDiskDriver annotation specifies the disk driver type (virtio, scsi, or nvme)
    • Annotations are parsed in parsePodAnnotations method

2. Disk Driver Selection

  • Key Files: pkg/libvirttools/diskdriver.go
  • Process:
    • getDiskDriverFactory selects the appropriate driver factory based on the annotation
    • Driver factories: virtioBlkDriverFactory, scsiDriverFactory, or nvmeDriverFactory
    • Each factory creates a driver implementing the diskDriver interface

3. Volume Source and Volume Creation

  • Key Files: pkg/libvirttools/volumes.go, pkg/libvirttools/virtualization.go
  • Process:
    • volumeSource function creates VMVolume objects for each required volume
    • Volumes include root disk, cloud-init config, and additional volumes from flexvolume

4. Disk List Setup

  • Key Files: pkg/libvirttools/disklist.go
  • Process:
    • newDiskList creates a list of disk items, each with a driver and volume
    • diskList.setup calls volume.Setup() for each volume to get disk definitions
    • Each disk definition gets its target from the corresponding driver

5. Domain Creation

  • Key Files: pkg/libvirttools/virtualization.go
  • Process:
    • createDomain builds the libvirt domain XML structure
    • Disk definitions from diskList.setup are added to the domain devices
    • For NVMe disks, the target is set to nvmeXn1 with bus type nvme

6. Domain Definition and Start

  • Key Files: pkg/libvirttools/virtualization.go
  • Process:
    • Domain is defined in libvirt using DefineDomain
    • diskList.writeImages writes any necessary disk images (e.g., cloud-init)
    • Domain is started, launching QEMU with the configured devices

Device Mapping Details

For SCSI Disks (default):

  1. The domain includes a SCSI controller
  2. Disks are attached to this controller with names like sda, sdb
  3. SCSI addressing is used to connect disks to the controller

For virtio-blk Disks:

  1. Disks are attached directly to the PCI bus
  2. Disk names follow the pattern vda, vdb
  3. No controller is needed, simplifying the setup

Key Insights

  1. Modular Design: Virtlet uses a modular architecture with clear separation of concerns between components.
  2. Integration with Kubernetes: Fully implements the CRI interface, allowing seamless integration with Kubernetes.
  3. Networking: Uses CNI for network setup and runs a DHCP server for each VM.
  4. Volume Management: Flexible volume system supporting various volume types and Kubernetes volume integration.
  5. Resource Management: Supports CPU and memory limits, CPU pinning, and NUMA topology.

This architecture allows Virtlet to run VMs as if they were containers from Kubernetes’ perspective, providing a way to run legacy applications or workloads that require full virtualization in a Kubernetes environment.

Questions:

  1. Key Functions and Interfaces Between vmwrapper and tapmanager

Core Interface: FDClient/FDServer Protocol
The communication between vmwrapper and tapmanager is primarily facilitated through the FDClient/FDServer protocol, which allows passing file descriptors across process boundaries.

Key Functions in vmwrapper
In cmd/vmwrapper/vmwrapper.go:

// Main function that retrieves network FDs from tapmanager
if netFdKey != “” {
c := tapmanager.NewFDClient(fdSocketPath)
fds, marshaledData, err := c.GetFDs(netFdKey)
if err != nil {
glog.Errorf(“Failed to obtain tap fds for key %q: %v”, netFdKey, err)
os.Exit(1)
}

var descriptions \[\]tapmanager.InterfaceDescription  
if err := json.Unmarshal(marshaledData, \&descriptions); err \!= nil {  
    glog.Errorf("Failed to unmarshal network interface info: %v", err)  
    os.Exit(1)  
}  
// ...  

}
This code in vmwrapper:

Creates a new FDClient connected to tapmanager’s socket
Calls GetFDs() with the network key to retrieve file descriptors
Unmarshals the interface descriptions
Uses these FDs to configure QEMU network devices
Key Functions in tapmanager
In pkg/tapmanager/fdserver.go:

// FDServer.serveGet - handles GetFDs requests from clients
func (s *FDServer) serveGet(c *net.UnixConn, hdr *fdHeader) (*fdHeader, []byte, []byte, error) {
key := hdr.getKey()
fds, err := s.getFDs(key)
if err != nil {
return nil, nil, nil, err
}
info, err := s.source.GetInfo(key)
if err != nil {
return nil, nil, nil, fmt.Errorf(“can’t get key info: %v”, err)
}

// ... prepare and return file descriptors ...  
rights := syscall.UnixRights(fds...)  
return \&fdHeader{/\*...\*/}, info, rights, nil  

}
In pkg/tapmanager/tapfdsource.go:

// GetFDs implements GetFDs method of FDSource interface
func (s *TapFDSource) GetFDs(key string, data []byte) ([]int, []byte, error) {
var payload GetFDPayload
if err := json.Unmarshal(data, &payload); err != nil {
return nil, nil, fmt.Errorf(“error unmarshalling GetFD payload: %v”, err)
}

// ... network namespace and CNI setup ...  
  
// Setup container side network  
if csn, err \= nettools.SetupContainerSideNetwork(netConfig, netNSPath, allLinks, s.enableSriov, hostNS); err \!= nil {  
    return nil, err  
}

// Marshal network configuration to return to client  
if respData, err \= json.Marshal(csn); err \!= nil {  
    return nil, fmt.Errorf("error marshalling net config: %v", err)  
}

// Collect file descriptors for tap devices  
for \_, i := range csn.Interfaces {  
    fds \= append(fds, int(i.Fo.Fd()))  
}  
  
return fds, respData, nil  

}
Key Interfaces
FDSource Interface
// FDSource denotes an ‘executive’ part for FDServer which
// creates and destroys (closes) the file descriptors and
// associated resources
type FDSource interface {
// GetFDs sets up file descriptors based on key and extra data
GetFDs(key string, data []byte) ([]int, []byte, error)

// Release destroys the file descriptor and associated resources  
Release(key string) error  
  
// GetInfo returns information to propagate back to FDClient  
GetInfo(key string) (\[\]byte, error)  
  
// Recover recovers FDSource's state after restart  
Recover(key string, data \[\]byte) error  
  
// RetrieveFDs retrieves file descriptors  
RetrieveFDs(key string) (\[\]int, error)  
  
// Stop stops goroutines associated with FDSource  
Stop() error  

}
FDManager Interface
// FDManager denotes an object that provides ‘master’-side
// functionality of FDClient
type FDManager interface {
// AddFDs adds new file descriptor to the FDManager
AddFDs(key string, data interface{}) ([]byte, error)

// ReleaseFDs makes FDManager close the file descriptor  
ReleaseFDs(key string) error  
  
// Recover recovers the state regarding the specified key  
Recover(key string, data interface{}) error  

}
InterfaceDescription Struct
// InterfaceDescription contains interface type with additional data
// needed to identify it
type InterfaceDescription struct {
Type network.InterfaceType `json:”type”`
HardwareAddr net.HardwareAddr `json:”mac”`
FdIndex int `json:”fdIndex”`
PCIAddress string `json:”pciAddress”`
}
Network Setup Functions
The actual network setup is handled by nettools.SetupContainerSideNetwork(), which:

Creates tap devices
Sets up bridges
Configures networking
Returns a ContainerSideNetwork structure with interface descriptions
Data Flow Between Components
Virtlet creates a VM and generates a network key
Virtlet passes this key to vmwrapper via environment variables
vmwrapper connects to tapmanager’s socket and calls GetFDs(key)
tapmanager calls TapFDSource.GetFDs() to set up networking
TapFDSource uses nettools to create and configure tap devices
TapFDSource returns file descriptors and interface descriptions
vmwrapper uses these to configure QEMU network devices
QEMU uses the file descriptors to communicate with the tap devices
This architecture allows for clean separation between the VM process and the network setup, with file descriptors being the primary interface between the components.

  1. Key Functions and Interfaces Between virtlet and vmwrapper

Key Functions and Interfaces Between Virtlet and vmwrapper
Overview
The interaction between Virtlet and vmwrapper is primarily through environment variables and the libvirt domain definition. Virtlet configures the VM domain and sets vmwrapper as the emulator, passing necessary configuration through environment variables.

Key Functions in Virtlet (VirtualizationTool)
Domain Creation
In pkg/libvirttools/virtualization.go, the CreateContainer method is responsible for defining the libvirt domain with vmwrapper as the emulator:

func (v *VirtualizationTool) CreateContainer(config *types.VMConfig, netFdKey string) (string, error) {
// …
settings := domainSettings{
domainUUID: domainUUID,
domainName: “virtlet-“ + domainUUID[:13] + “-“ + config.Name,
netFdKey: netFdKey,
// … other settings
}

domainDef := settings.createDomain(config)  
// ...  

}
Domain Definition
The createDomain method in domainSettings sets up the domain definition with vmwrapper as the emulator and passes configuration through environment variables:

func (ds *domainSettings) createDomain(config *types.VMConfig) *libvirtxml.Domain {
// …
domain := &libvirtxml.Domain{
Devices: &libvirtxml.DomainDeviceList{
Emulator: “/vmwrapper”,
// … other devices
},
// … other domain settings

    QEMUCommandline: \&libvirtxml.DomainQEMUCommandline{  
        Envs: \[\]libvirtxml.DomainQEMUCommandlineEnv{  
            {Name: vconfig.EmulatorEnvVarName, Value: emulator},  
            {Name: vconfig.NetKeyEnvVarName, Value: ds.netFdKey},  
            {Name: vconfig.ContainerIDEnvVarName, Value: config.DomainUUID},  
            {Name: vconfig.LogPathEnvVarName, Value: filepath.Join(config.LogDirectory, config.LogPath)},  
            {Name: vconfig.NetworkDeviceEnvVarName, Value: config.ParsedAnnotations.NetworkDevice},  
        },  
    },  
}  
// ...  
return domain  

}
Environment Variables (Communication Interface)
The key environment variables used for communication between Virtlet and vmwrapper are defined in pkg/config/constants.go:

const (
// ContainerIDEnvVarName contains name of env variable passed from virtlet to vmwrapper
ContainerIDEnvVarName = “VIRTLET_CONTAINER_ID”
// CpusetsEnvVarName contains name of env variable passed from virtlet to vmwrapper
CpusetsEnvVarName = “VIRTLET_CPUSETS”
// EmulatorEnvVarName contains name of env variable passed from virtlet to vmwrapper
EmulatorEnvVarName = “VIRTLET_EMULATOR”
// LogPathEnvVarName contains name of env variable passed from virtlet to vmwrapper
LogPathEnvVarName = “VIRTLET_CONTAINER_LOG_PATH”
// NetKeyEnvVarName contains name of env variable passed from virtlet to vmwrapper
NetKeyEnvVarName = “VIRTLET_NET_KEY”
// Network device
NetworkDeviceEnvVarName = “VIRTLET_NETWORK_DEVICE”
)
Key Functions in vmwrapper
In cmd/vmwrapper/vmwrapper.go, the main function processes these environment variables:

func main() {
// …
emulator := os.Getenv(config.EmulatorEnvVarName)
emulatorArgs := os.Args[1:]
var netArgs []string
if emulator == “” {
// this happens during ‘qemu -help’ invocation by libvirt
// (capability check)
emulator = defaultEmulator
} else {
netFdKey := os.Getenv(config.NetKeyEnvVarName)
// …
if netFdKey != “” {
c := tapmanager.NewFDClient(fdSocketPath)
fds, marshaledData, err := c.GetFDs(netFdKey)
// …
// Process network interfaces
// …
}
}

args := append(\[\]string{emulator}, emulatorArgs...)  
args \= append(args, netArgs...)  
env := os.Environ()  
if err := syscall.Exec(args\[0\], args, env); err \!= nil {  
    glog.Errorf("Can't exec emulator: %v", err)  
    os.Exit(1)  
}  

}
Data Flow Between Components
Virtlet creates a VM configuration with a unique domain UUID
Virtlet generates a network key for the VM
Virtlet defines a libvirt domain with:
/vmwrapper as the emulator
Environment variables containing configuration:
VIRTLET_EMULATOR: The actual QEMU emulator path
VIRTLET_NET_KEY: Key to retrieve network interfaces
VIRTLET_CONTAINER_ID: The domain UUID
VIRTLET_CONTAINER_LOG_PATH: Path for VM logs
VIRTLET_NETWORK_DEVICE: Network device type (e.g., “virtio”)
VIRTLET_CPUSETS: CPU sets for the VM (optional)
libvirt starts the domain, executing /vmwrapper with the environment variables
vmwrapper:
Reads the environment variables
Connects to tapmanager using the socket path
Retrieves network interfaces using the network key
Constructs QEMU command line arguments
Executes the actual QEMU emulator with the arguments
Key Interfaces
The primary interface between Virtlet and vmwrapper is the set of environment variables passed through the libvirt domain definition. These variables provide vmwrapper with all the information it needs to:

Identify the VM (container ID)
Locate the actual emulator to use
Set up networking by retrieving the appropriate file descriptors
Configure logging
Set CPU affinity (if specified)
This design allows Virtlet to remain in control of the VM configuration while delegating the actual execution and network setup to vmwrapper, which runs in a separate process.

Function Relationship

进程调度是 Linux 内核中最复杂也最关键的子系统之一。在生产环境中,”CPU 使用率 100% 但响应很慢”、”任务唤醒后等了几十毫秒才运行”、”某个进程长期卡在 D 状态”——这些问题的根因往往深藏在调度层。本文是本系列第五篇,聚焦于调度诊断的完整方法论:从 /proc 接口读取原始数据,到 perf sched 分析调度延迟,再到 bpftrace 精确追踪内核路径,最后结合四个典型生产案例给出可落地的排查流程与修复建议。

Read more »

容器技术的核心在于资源隔离与限制,而这一能力的底层支撑正是 Linux 内核的 cgroup(Control Group)机制。本文基于 Linux 6.4-rc1 源码,深入剖析 cgroup v2 的内核实现,涵盖统一层次框架、Memory/CPU/IO/PID 四大控制器的核心数据结构与关键路径,以及 cgroup namespace 与诊断方法。

Read more »

前言

在 Linux 进程管理体系中,信号(Signal)是最古老也最核心的异步通知机制,而进程间通信(IPC)则是多进程协作的基础设施。本文基于 Linux 6.4-rc1 内核源码,深入剖析信号的数据结构、发送路径、处理流程,以及 pipe、POSIX 消息队列、System V IPC、UNIX Domain Socket 和 futex 的内核实现。理解这些机制,是系统编程、性能调优和内核调试的必备基础。


一、信号机制:数据结构全景

1.1 sigset_t:信号集位图

信号集的底层表示是一个位图数组。x86-64 下 _NSIG = 64_NSIG_BPW = 64,因此 _NSIG_WORDS = 1,整个集合用一个 64 位整数表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// include/linux/signal.h
static inline void sigaddset(sigset_t *set, int _sig)
{
unsigned long sig = _sig - 1;
if (_NSIG_WORDS == 1)
set->sig[0] |= 1UL << sig;
else
set->sig[sig / _NSIG_BPW] |= 1UL << (sig % _NSIG_BPW);
}

static inline int sigismember(sigset_t *set, int _sig)
{
unsigned long sig = _sig - 1;
if (_NSIG_WORDS == 1)
return 1 & (set->sig[0] >> sig);
else
return 1 & (set->sig[sig / _NSIG_BPW] >> (sig % _NSIG_BPW));
}

信号编号从 1 开始,因此位操作时先减 1。信号 1-31 是传统不可靠信号(POSIX 标准信号),32-64 是实时信号(SIGRTMIN=34, SIGRTMAX=64)。

1.2 核心数据结构

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
// include/linux/signal_types.h

// 待处理信号队列:位图 + 链表
struct sigpending {
struct list_head list; // sigqueue 链表(实时信号排队)
sigset_t signal; // 位图(标记哪些信号已投递)
};

// 单个排队信号节点
struct sigqueue {
struct list_head list;
int flags;
kernel_siginfo_t info; // 包含 si_signo/si_code/si_pid 等
struct ucounts *ucounts;
};

// 用户态信号处理器描述
struct sigaction {
__sighandler_t sa_handler; // SIG_DFL / SIG_IGN / 用户函数
unsigned long sa_flags; // SA_RESTART / SA_SIGINFO / SA_NODEFER ...
sigset_t sa_mask; // 处理期间额外屏蔽的信号
};

struct k_sigaction {
struct sigaction sa;
};

// 信号处理器数组(线程组共享)
// include/linux/sched/signal.h
struct sighand_struct {
spinlock_t siglock;
refcount_t count; // 引用计数,线程共享
wait_queue_head_t signalfd_wqh;
struct k_sigaction action[_NSIG]; // 64 个信号的处理器数组
};

sighand_struct 是线程组内所有线程共享的,当调用 sigaction() 修改某个信号的处理器时,整个线程组都受影响。

1.3 signal_struct:进程级信号状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// include/linux/sched/signal.h
struct signal_struct {
refcount_t sigcnt;
atomic_t live;
int nr_threads;
struct list_head thread_head;

wait_queue_head_t wait_chldexit; // wait4() 等待子进程退出

struct task_struct *curr_target; // 信号负载均衡目标线程

/* 进程级共享 pending 队列(发给整个线程组的信号) */
struct sigpending shared_pending;

int group_exit_code;
int group_stop_count;
unsigned int flags; // SIGNAL_GROUP_EXIT / SIGNAL_STOP_STOPPED ...
// ...
};

每个 task_struct 还拥有自己私有的 task->pending,用于接收 tgkill/tkill 等指定线程的信号。信号投递时,shared_pending 由线程组中任意一个线程处理,task->pending 只能由目标线程处理。


二、信号发送路径

2.1 kill() 到 __send_signal_locked 的调用链

1
2
3
4
5
6
7
8
kill(pid, sig)
└── sys_kill()
└── kill_something_info()
├── kill_pid_info() → 发给单进程
└── __kill_pgrp_info() → 发给进程组
└── group_send_sig_info()
└── send_signal_locked()
└── __send_signal_locked()

进程组信号发送的核心实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kernel/signal.c:1453
int __kill_pgrp_info(int sig, struct kernel_siginfo *info, struct pid *pgrp)
{
struct task_struct *p = NULL;
int retval, success;

success = 0;
retval = -ESRCH;
do_each_pid_task(pgrp, PIDTYPE_PGID, p) {
int err = group_send_sig_info(sig, info, p, PIDTYPE_PGID);
success |= !err;
retval = err;
} while_each_pid_task(pgrp, PIDTYPE_PGID, p);
return success ? 0 : retval;
}

2.2 __send_signal_locked:信号入队核心

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
// kernel/signal.c:1078
static int __send_signal_locked(int sig, struct kernel_siginfo *info,
struct task_struct *t, enum pid_type type, bool force)
{
struct sigpending *pending;
struct sigqueue *q;
int override_rlimit;

lockdep_assert_held(&t->sighand->siglock);

if (!prepare_signal(sig, t, force))
goto ret; // 信号被忽略,直接返回

// 进程组信号进 shared_pending,线程信号进私有 pending
pending = (type != PIDTYPE_PID) ? &t->signal->shared_pending : &t->pending;

// 不可靠信号(1-31):已经在 pending 中则不重复入队
if (legacy_queue(pending, sig))
goto ret;

// 实时信号:分配 sigqueue 节点加入链表
if (sig < SIGRTMIN)
override_rlimit = (is_si_special(info) || info->si_code >= 0);
else
override_rlimit = 0;

q = __sigqueue_alloc(sig, t, GFP_ATOMIC, override_rlimit, 0);
if (q) {
list_add_tail(&q->list, &pending->list); // 加入等待链表
copy_siginfo(&q->info, info);
} else if (sig >= SIGRTMIN && info->si_code != SI_USER) {
// 实时信号队列溢出,返回 EAGAIN
return -EAGAIN;
}

out_set:
signalfd_notify(t, sig);
sigaddset(&pending->signal, sig); // 在位图中置位
complete_signal(sig, t, type); // 选择目标线程并唤醒
return 0;
}

可靠信号 vs 不可靠信号的关键差异在第 1073-1076 行的 legacy_queue()

1
2
3
4
static inline bool legacy_queue(struct sigpending *signals, int sig)
{
return (sig < SIGRTMIN) && sigismember(&signals->signal, sig);
}

信号 1-31(sig < SIGRTMIN=34)如果 pending 位图已置位,新的投递会被静默丢弃。而实时信号(34-64)不受此约束,每次都会分配新的 sigqueue 节点入链表,从而实现多次投递的可靠排队。

2.3 signal_wake_up:唤醒目标进程

1
2
3
4
5
6
7
8
9
10
11
12
// kernel/signal.c:763
void signal_wake_up_state(struct task_struct *t, unsigned int state)
{
lockdep_assert_held(&t->sighand->siglock);

// 在 thread_info 中设置 TIF_SIGPENDING 标志
set_tsk_thread_flag(t, TIF_SIGPENDING);

// 唤醒处于 TASK_INTERRUPTIBLE 或 TASK_WAKEKILL 状态的线程
if (!wake_up_state(t, state | TASK_INTERRUPTIBLE))
kick_process(t); // 如果目标在其他 CPU 上运行,发送 IPI
}

TIF_SIGPENDING 标志设置后,目标进程在下次从内核返回用户态时(系统调用返回或中断返回)会检查并处理信号。

2.4 tgkill / tkill:向指定线程发送信号

tkill(tid, sig) 绕过线程组,直接向特定 tid 发送信号,信号进入 task->pending(私有队列)而非 signal->shared_pending。这对于多线程程序中精确控制信号投递至关重要,也是 pthread_kill() 的内核实现基础。


三、信号处理流程

3.1 信号处理时机

信号不是异步立即执行的,而是在进程从内核态返回用户态的”安全点”处理:

  • 系统调用返回前syscall_exit_to_user_mode()exit_to_user_mode_loop() → 检查 TIF_SIGPENDING
  • 中断返回前:硬件中断处理完毕返回用户态时检查

x86-64 的入口点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// arch/x86/kernel/signal.c:302
void arch_do_signal_or_restart(struct pt_regs *regs)
{
struct ksignal ksig;

if (get_signal(&ksig)) {
/* 有信号需要投递,调用 handle_signal */
handle_signal(&ksig, regs);
return;
}

/* 没有信号处理器,检查是否需要重启系统调用 */
if (syscall_get_nr(current, regs) != -1) {
switch (syscall_get_error(current, regs)) {
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2; // 重新执行 syscall 指令
break;
// ...
}
}
restore_saved_sigmask();
}

3.2 get_signal:从 pending 队列取信号

get_signal() 是信号出队的核心函数(kernel/signal.c:2642),其工作流程:

  1. 检查 TIF_SIGPENDING 标志
  2. 调用 try_to_freeze() 处理冻结请求
  3. 调用 dequeue_signal()task->pendingsignal->shared_pending 取出最高优先级信号
  4. 检查信号处理器:若为 SIG_DFL 且是致命信号,执行默认行为(SIGKILL 直接在此终止进程)
  5. 返回 ksignal(含信号编号、info、处理器)

SIGKILLSIGSTOP 在此处被特殊处理——它们永远不会到达用户态信号处理器:

1
2
3
4
5
6
// kernel/signal.c:2709
if ((signal->flags & SIGNAL_GROUP_EXIT) || signal->group_exec_task) {
ksig->info.si_signo = signr = SIGKILL;
// 直接跳到 fatal 处理,不查 sighand->action
goto fatal;
}

3.3 handle_signal 与信号栈帧

当信号有用户态处理器时,handle_signal() 负责在用户栈上构建信号栈帧,并修改 pt_regs 使处理器返回用户态时跳转到信号处理函数:

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
// arch/x86/kernel/signal.c:224
static void handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
bool stepping, failed;
struct fpu *fpu = &current->thread.fpu;

/* 处理系统调用重启语义 */
if (syscall_get_nr(current, regs) != -1) {
switch (syscall_get_error(current, regs)) {
case -ERESTART_RESTARTBLOCK:
case -ERESTARTNOHAND:
regs->ax = -EINTR;
break;
case -ERESTARTSYS:
if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
regs->ax = -EINTR;
break;
}
fallthrough;
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2;
break;
}
}

failed = (setup_rt_frame(ksig, regs) < 0);
if (!failed) {
regs->flags &= ~(X86_EFLAGS_DF|X86_EFLAGS_RF|X86_EFLAGS_TF);
fpu__clear_user_states(fpu);
}
signal_setup_done(failed, ksig, stepping);
}

3.4 rt_sigframe:信号栈帧布局

setup_rt_frame() 在用户栈上压入 struct rt_sigframe

1
2
3
4
5
6
7
// arch/x86/include/asm/sigframe.h:59
struct rt_sigframe {
char __user *pretcode; // 指向 sigreturn trampoline
struct ucontext uc; // 保存的用户态上下文(含 pt_regs)
struct siginfo info; // 信号信息
/* FP/XSAVE 状态紧跟其后 */
};

struct ucontext 内嵌 struct sigcontext,后者保存了所有通用寄存器(rax/rbx/…/rsp/rip/rflags)以及 FPU 状态指针。信号处理函数执行完毕后,调用 rt_sigreturn 系统调用,内核从 uc 中恢复完整的 CPU 状态,进程无缝回到被中断的执行点。


四、管道(pipe)

4.1 struct pipe_inode_info:环形缓冲区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// include/linux/pipe_fs_i.h:58
struct pipe_inode_info {
struct mutex mutex;
wait_queue_head_t rd_wait, wr_wait; // 读写等待队列
unsigned int head; // 写入位置(生产者指针)
unsigned int tail; // 读取位置(消费者指针)
unsigned int max_usage; // 可用槽数上限
unsigned int ring_size; // 环形数组总槽数(必须是 2 的幂)
unsigned int readers; // 当前读端 fd 引用计数
unsigned int writers; // 当前写端 fd 引用计数
struct pipe_buffer *bufs; // 环形缓冲区数组
struct user_struct *user;
// ...
};

// 每个缓冲区槽指向一个物理页
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags; // PIPE_BUF_FLAG_CAN_MERGE 等
unsigned long private;
};

默认 ring_size = PIPE_DEF_BUFFERS = 16,每槽一个 4KB 页,管道默认容量 64KB。headtail 不做掩码,允许自然溢出环绕,访问时用 index & (ring_size - 1) 取模。

4.2 pipe_write:数据写入

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
// fs/pipe.c:416(关键片段)
static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
struct pipe_inode_info *pipe = filp->private_data;
unsigned int head;

__pipe_lock(pipe);

if (!pipe->readers) {
send_sig(SIGPIPE, current, 0); // 无读端则发 SIGPIPE
ret = -EPIPE;
goto out;
}

head = pipe->head;
was_empty = pipe_empty(head, pipe->tail);

/* 尝试合并到上一个 buf(小写入优化) */
if (chars && !was_empty) {
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
ret = copy_page_from_iter(buf->page, offset, chars, from);
buf->len += ret;
}
}

for (;;) {
head = pipe->head;
if (!pipe_full(head, pipe->tail, pipe->max_usage)) {
struct pipe_buffer *buf = &pipe->bufs[head & mask];
/* 分配新页,写入数据,推进 head */
pipe->head = head + 1;
} else {
/* 管道满,等待读者消费 */
if (wait_event_interruptible_exclusive(pipe->wr_wait,
pipe_writable(pipe)) < 0)
break;
}
}
// ...
}

4.3 管道容量控制

1
/proc/sys/fs/pipe-max-size  # 默认 1048576(1MB),非 root 用户上限

通过 fcntl(fd, F_SETPIPE_SZ, size) 可以动态调整单个管道容量。内核将请求的 size 向上取整到 2 的幂(不超过 pipe_max_size),然后 krealloc 扩展 bufs 数组。

4.4 splice 与零拷贝

splice(2) 系统调用利用管道的 pipe_buffer 结构实现文件到 socket 的零拷贝:数据以页引用的方式在管道内传递,不需要 memcpy 到用户缓冲区。vmsplice 可以将用户态内存页”赠送”给管道(PIPE_BUF_FLAG_GIFT),配合 splice 实现用户态到 socket 的全程零拷贝传输。


五、POSIX 消息队列

5.1 基于 tmpfs 的实现

POSIX 消息队列挂载在独立的 mqueue 文件系统(基于 tmpfs)。每个消息队列对应一个 mqueue_inode_info

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ipc/mqueue.c:134
struct mqueue_inode_info {
spinlock_t lock;
struct inode vfs_inode;
wait_queue_head_t wait_q;

struct rb_root msg_tree; // 消息按优先级存储(红黑树)
struct rb_node *msg_tree_rightmost; // 最高优先级节点(O(1) 访问)
struct posix_msg_tree_node *node_cache;
struct mq_attr attr;

struct sigevent notify; // 消息到达时的通知方式
struct pid *notify_owner;
// 等待发送 / 等待接收的任务队列
struct ext_wait_queue e_wait_q[2];

unsigned long qsize; // 队列已用内存
};

5.2 优先级队列

1
2
3
4
5
6
// ipc/mqueue.c:61
struct posix_msg_tree_node {
struct rb_node rb_node; // 插入红黑树的节点
struct list_head msg_list; // 相同优先级的消息链表
int priority;
};

mq_send() 时,先在红黑树中查找对应优先级节点,若不存在则创建新节点插入;mq_receive() 直接取 msg_tree_rightmost(最右节点即最高优先级),时间复杂度 O(log P)(P 为不同优先级数量),同优先级内 FIFO 顺序。


六、System V IPC

6.1 信号量:struct sem_array

1
2
3
4
5
6
7
8
9
10
11
12
13
// ipc/sem.c:114
struct sem_array {
struct kern_ipc_perm sem_perm; // IPC 权限(key/uid/gid/mode)
time64_t sem_ctime;
struct list_head pending_alter; // 待执行的修改操作队列
struct list_head pending_const; // 待执行的只读操作队列
struct list_head list_id; // undo 请求链表
int sem_nsems; // 信号量个数
int complex_count;
unsigned int use_global_lock;

struct sem sems[]; // 信号量值数组(柔性数组)
} __randomize_layout;

semop() 的原子性保证:操作执行前检查整个操作集合能否同时满足(”all-or-nothing”语义),不能满足则整体入 pending_alter 睡眠等待。内核还支持 SEM_UNDO 标志,进程退出时自动回滚所有 semop 操作,防止死锁。

6.2 消息队列:struct msg_queue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ipc/msg.c:49
struct msg_queue {
struct kern_ipc_perm q_perm;
time64_t q_stime; // 最后 msgsnd 时间
time64_t q_rtime; // 最后 msgrcv 时间
unsigned long q_cbytes; // 当前队列字节数
unsigned long q_qnum; // 消息数量
unsigned long q_qbytes; // 最大字节限制
struct pid *q_lspid; // 最后发送者 PID
struct pid *q_lrpid; // 最后接收者 PID

struct list_head q_messages; // 消息链表
struct list_head q_receivers; // 等待接收的进程
struct list_head q_senders; // 等待发送的进程(队列满时阻塞)
} __randomize_layout;

msgsnd() 将消息附加到 q_messages 链表尾部;msgrcv() 支持按 msgtype 过滤接收(正数接收指定类型,负数接收类型绝对值最小的,0 接收任意最早消息)。

6.3 共享内存

shmget()/shmat() 基于 tmpfsshmem)实现。shmget() 在 shmem 文件系统上创建一个匿名文件,shmat() 调用 do_mmap() 将该文件的页面映射到进程虚拟地址空间。多个进程 shmat() 同一个 shmid,它们的虚拟地址映射到相同的物理页(零拷贝),通过共享页表实现数据直接共享。


七、UNIX Domain Socket

7.1 struct unix_sock

1
2
3
4
5
6
7
8
9
10
11
12
13
// include/net/af_unix.h:56
struct unix_sock {
struct sock sk; // 必须是第一个成员
struct unix_address *addr; // 绑定的路径名地址
struct path path; // 对应的 dentry/inode
struct mutex iolock, bindlock;
struct sock *peer; // 连接的对端 socket
struct list_head link;
atomic_long_t inflight; // 传输中的 FD 数量
spinlock_t lock;
struct socket_wq peer_wq;
struct scm_stat scm_stat;
};

7.2 unix_stream_sendmsg:内存直传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// net/unix/af_unix.c:2160
static int unix_stream_sendmsg(struct socket *sock, struct msghdr *msg, size_t len)
{
struct sock *sk = sock->sk;
struct sock *other = unix_peer(sk); // 对端 socket

err = scm_send(sock, msg, &scm, false); // 处理辅助数据(SCM_RIGHTS 等)

while (sent < len) {
// 分配 sk_buff,将用户数据拷贝进去
skb = sock_alloc_send_pskb(sk, size - data_len, data_len, ...);
err = unix_scm_to_skb(&scm, skb, !fds_sent); // 附加文件描述符
err = skb_copy_datagram_from_iter(skb, 0, &msg->msg_iter, size);

unix_state_lock(other);
skb_queue_tail(&other->sk_receive_queue, skb); // 直接放入对端接收队列
unix_state_unlock(other);
other->sk_data_ready(other); // 唤醒对端
}
}

UNIX Domain Socket 的关键优势:数据通过 sk_buff 在内核内存中传递,完全绕过网络协议栈(无 TCP/IP 头部处理、无校验和计算、无路由查找)。与管道相比,它支持双向通信和数据报语义(SOCK_DGRAM)。

7.3 文件描述符传递(SCM_RIGHTS)

UNIX Domain Socket 最独特的能力是通过 sendmsg() + SCM_RIGHTS 辅助消息传递文件描述符。发送端将 fd 列表附加到 struct scm_fp_list,内核在 unix_scm_to_skb() 中调用 get_file() 增加文件的引用计数,将 struct file * 指针存入 skb->cb(即 UNIXCB(skb).fp)。接收端通过 recvmsg() 取出后,内核在当前进程的文件描述符表中安装新的 fd,指向同一个 struct file 对象,实现跨进程 fd 共享。这是容器运行时、Chrome 沙箱和 systemd socket activation 等系统的核心机制。


八、futex:快速用户空间互斥锁

8.1 设计哲学

futex(Fast Userspace muTEX)的核心洞察:在无竞争时完全在用户态完成,仅在竞争时陷入内核。glibc 的 pthread_mutex_lock() 底层就是 futex。

8.2 struct futex_hash_bucket

1
2
3
4
5
6
// kernel/futex/futex.h:45
struct futex_hash_bucket {
atomic_t waiters; // 该桶中等待者计数(用于快速路径优化)
spinlock_t lock;
struct plist_head chain; // 优先级排序的等待队列(用于 PI-futex)
} ____cacheline_aligned_in_smp;

内核维护一个全局哈希表 futex_queues,以 futex 变量的物理地址(对于 shared futex)或虚拟地址(对于 private futex)为 key 哈希到对应的 bucket。

8.3 FUTEX_WAIT:快速路径 vs 慢速路径

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
// kernel/futex/waitwake.c:632
int futex_wait(u32 __user *uaddr, unsigned int flags, u32 val,
ktime_t *abs_time, u32 bitset)
{
struct futex_hash_bucket *hb;
struct futex_q q = futex_q_init;

retry:
/* 慢速路径:进入内核,锁定 hash bucket */
ret = futex_wait_setup(uaddr, val, flags, &q, &hb);
/*
* futex_wait_setup 内部:
* 1. get_futex_key():计算哈希键
* 2. futex_q_lock(q):锁定 bucket,原子地增加 waiters 计数
* 3. 再次读取 *uaddr,若已不等于 val 则返回 -EWOULDBLOCK
* (防止在用户态检查和内核入队之间漏掉 wake)
*/
if (ret)
goto out;

/* 入队并调度睡眠,等待 FUTEX_WAKE */
futex_wait_queue(hb, &q, to);

if (!futex_unqueue(&q))
goto out; // 已被 wake,返回 0

if (!signal_pending(current))
goto retry; // 虚假唤醒,重试

ret = -ERESTARTSYS;
// ...
}

快速路径(用户态完成):pthread_mutex_lock()cmpxchg 原子地尝试将 futex 值从 0 改为线程 TID;成功则无需系统调用。只有 cmpxchg 失败(有竞争)时才调用 sys_futex(FUTEX_WAIT, ...)

8.4 FUTEX_WAKE:唤醒等待者

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
// kernel/futex/waitwake.c:143
int futex_wake(u32 __user *uaddr, unsigned int flags, int nr_wake, u32 bitset)
{
struct futex_hash_bucket *hb;
DEFINE_WAKE_Q(wake_q);

ret = get_futex_key(uaddr, flags & FLAGS_SHARED, &key, FUTEX_READ);

hb = futex_hash(&key);

/* 快速路径:bucket 没有等待者,直接返回 */
if (!futex_hb_waiters_pending(hb))
return ret;

spin_lock(&hb->lock);
plist_for_each_entry_safe(this, next, &hb->chain, list) {
if (futex_match(&this->key, &key)) {
futex_wake_mark(&wake_q, this); // 加入唤醒队列
if (++ret >= nr_wake)
break;
}
}
spin_unlock(&hb->lock);
wake_up_q(&wake_q); // 批量唤醒
return ret;
}

futex_hb_waiters_pending() 检查 hb->waiters 计数,若为 0 则跳过所有锁操作——这是 futex 在无竞争时保持低开销的关键优化。

8.5 pthread_mutex 的 futex 实现原理

1
2
3
4
5
6
7
8
9
10
11
12
pthread_mutex_lock():
1. cmpxchg(futex_addr, 0, tid) // 用户态原子操作,无系统调用
成功 → 获得锁,返回
失败 → 调用 sys_futex(FUTEX_WAIT, futex_addr, current_val)
内核将当前线程加入 hash bucket 的等待链表
调度出去睡眠

pthread_mutex_unlock():
1. atomic_store(futex_addr, 0) // 用户态释放
2. 如果 futex 值之前标记了 FUTEX_WAITERS:
调用 sys_futex(FUTEX_WAKE, futex_addr, 1)
内核从等待链表中取出一个线程唤醒

九、诊断方法与工具

9.1 信号诊断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 查看所有信号编号与名称
kill -l

# 追踪进程的信号收发
strace -e trace=signal -p <PID>

# 解读 /proc/PID/status 中的信号位图
cat /proc/$(pgrep mysqld)/status | grep -E "Sig(Blk|Pnd|Cgt|Ign)"
# 示例输出:
# SigPnd: 0000000000000000 # 待处理信号(线程私有)
# SigBlk: 0000000000000000 # 已屏蔽信号
# SigIgn: 0000000000001000 # 忽略的信号(bit 12 = SIGPIPE)
# SigCgt: 00000001800024fb # 有用户态处理器的信号
# 用 python 解码:python3 -c "v=0x00000001800024fb; [print(i+1) for i in range(64) if v>>i&1]"

# bpftrace 追踪信号发送
bpftrace -e 'kprobe:__send_signal_locked { printf("sig=%d pid=%d->%d\n",
arg0, pid, ((struct task_struct*)arg2)->pid); }'

9.2 IPC 资源诊断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 查看所有 System V IPC 资源(信号量/消息队列/共享内存)
ipcs -a

# 查看管道的 fd 使用情况
lsof | grep pipe | head -20

# 查看 POSIX 消息队列(需要挂载 mqueue)
ls -la /dev/mqueue/

# 追踪 futex 竞争热点
bpftrace -e 'kprobe:do_futex { @[ustack] = count(); }
interval:s:5 { print(@); clear(@); exit(); }'

# 统计 futex 系统调用耗时
perf stat -e 'syscalls:sys_enter_futex' -p <PID> -- sleep 10

9.3 /proc/PID/status 信号位图解读

信号位图是 64 位十六进制数,每个 bit 对应一个信号编号(bit N = 信号 N+1)。例如 SigIgn: 0000000000001000

1
2
0x1000 = 0001 0000 0000 0000 (binary)
bit 12 已置位 → 信号 13 (SIGPIPE) 被忽略

守护进程通常会忽略 SIGPIPESIGHUP,这在 SigIgn 中可以直接观察到。


十、各 IPC 机制对比

机制 方向 持久性 传输效率 主要用途
信号 单向通知 非持久 极低(仅编号) 异步事件通知
pipe 单向数据流 进程生命期 高(ring buffer) 父子进程数据流
UNIX Socket 双向 进程生命期 高(内存拷贝) 本机 C/S 通信、fd 传递
POSIX MQ 双向 内核持久 中(优先级排序) 有序异步消息传递
SysV 消息队列 双向 内核持久 传统 IPC
共享内存 双向 内核持久 极高(零拷贝) 大数据量共享
futex 同步原语 非持久 极高(无竞争零系统调用) 互斥锁/条件变量

总结

Linux 信号与 IPC 机制的设计体现了内核”机制与策略分离”的哲学:

  • 信号通过 sigpending 位图 + sigqueue 链表分别处理可靠性需求,借助 TIF_SIGPENDING 在内核返回用户态的安全点触发;
  • pipe 以环形 pipe_buffer 数组为核心,支持页级零拷贝(splice),默认容量 64KB 可动态调整;
  • futex 以用户态原子操作为快速路径、内核哈希等待队列为慢速路径,是现代高性能同步原语的基础;
  • UNIX Domain Socket 通过 sk_buff 在内核内完成内存传递,配合 SCM_RIGHTS 实现跨进程 fd 共享;
  • System V IPCPOSIX 消息队列 则提供了更结构化的进程间通信语义。

理解这些机制的内核实现,能够帮助我们在系统设计、性能调优和问题排查时做出更明智的技术选择。


参考源码

  • kernel/signal.c — 信号发送、处理核心
  • include/linux/signal_types.h — 信号数据结构定义
  • include/linux/sched/signal.hsighand_struct / signal_struct
  • arch/x86/kernel/signal.c — x86-64 信号帧构建
  • arch/x86/include/asm/sigframe.hstruct rt_sigframe
  • include/linux/pipe_fs_i.hpipe_inode_info / pipe_buffer
  • fs/pipe.c — 管道读写实现
  • kernel/futex/core.c — futex 哈希表
  • kernel/futex/waitwake.cfutex_wait / futex_wake
  • kernel/futex/futex.hfutex_hash_bucket
  • ipc/sem.c — System V 信号量
  • ipc/msg.c — System V 消息队列
  • ipc/mqueue.c — POSIX 消息队列
  • net/unix/af_unix.c — UNIX Domain Socket
  • include/net/af_unix.hstruct unix_sock

本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,所有代码片段均直接来自内核源文件。主要参考文件:kernel/sched/fair.ckernel/sched/sched.hkernel/sched/core.ckernel/sched/rt.c

Linux 调度器是内核中最核心也最复杂的子系统之一。自 2.6.23 版本引入 CFS(Completely Fair Scheduler,完全公平调度器)以来,它已成为处理普通进程(SCHED_NORMALSCHED_BATCH)调度的主要机制。本文将从数据结构出发,深入分析 CFS 的每一个核心算法,揭示”公平”背后的工程实现。


一、调度器框架:调度类与运行队列

1.1 调度类层次(struct sched_class

Linux 调度器采用面向对象的设计,通过 struct sched_class 抽象出调度策略接口。每种调度策略实现一套方法,调度器核心代码通过函数指针调用。

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
// kernel/sched/sched.h:2169
struct sched_class {

#ifdef CONFIG_UCLAMP_TASK
int uclamp_enabled;
#endif

void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
void (*yield_task) (struct rq *rq);
bool (*yield_to_task)(struct rq *rq, struct task_struct *p);

void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);

struct task_struct *(*pick_next_task)(struct rq *rq);

void (*put_prev_task)(struct rq *rq, struct task_struct *p);
void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);

void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
void (*task_fork)(struct task_struct *p);
void (*task_dead)(struct task_struct *p);

void (*update_curr)(struct rq *rq);
/* ... */
};

各调度类按优先级从高到低排列,链接器脚本将它们放置在连续内存区域,for_each_class 宏按顺序遍历:

1
stop  >  dl  >  rt  >  fair  >  idle
  • stop:停止机器专用(CPU 热插拔、内核调试),优先级最高,永远抢占其他任务
  • dl:Deadline 调度(SCHED_DEADLINE),按截止时间调度实时任务
  • rt:实时调度(SCHED_FIFOSCHED_RR),基于优先级位图
  • fair:CFS 公平调度(SCHED_NORMALSCHED_BATCHSCHED_IDLE),本文主角
  • idle:CPU 空闲时运行 idle 线程
1
2
3
4
5
6
// kernel/sched/sched.h:2272
extern const struct sched_class stop_sched_class;
extern const struct sched_class dl_sched_class;
extern const struct sched_class rt_sched_class;
extern const struct sched_class fair_sched_class;
extern const struct sched_class idle_sched_class;

1.2 运行队列 struct rq

每个 CPU 有一个全局运行队列 struct rq,所有调度类的队列都嵌入其中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// kernel/sched/sched.h:957
struct rq {
raw_spinlock_t __lock;

unsigned int nr_running; // 当前 CPU 可运行任务总数

struct cfs_rq cfs; // CFS 运行队列
struct rt_rq rt; // RT 运行队列
struct dl_rq dl; // DL 运行队列

struct task_struct __rcu *curr; // 当前正在运行的任务
struct task_struct *idle; // idle 任务
struct task_struct *stop; // stop 任务

u64 clock; // 队列时钟(纳秒)
u64 clock_task; // 任务时钟(排除 irq/steal)

atomic_t nr_iowait; // 等待 I/O 的任务数

/* SMP 负载均衡 */
unsigned long next_balance;
/* ... */
};

通过 cpu_rq(cpu) 宏获取指定 CPU 的 rq,通过 this_rq() 获取当前 CPU 的 rq

1.3 CFS 运行队列 struct cfs_rq

CFS 的核心数据结构,管理所有可运行的 CFS 调度实体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// kernel/sched/sched.h:550
struct cfs_rq {
struct load_weight load; // 队列总权重
unsigned int nr_running; // 直接成员数
unsigned int h_nr_running; // 递归成员总数(组调度)

u64 exec_clock; // 已执行的 CPU 时间
u64 min_vruntime; // 队列中最小 vruntime(单调递增)

struct rb_root_cached tasks_timeline; // 红黑树(按 vruntime 排序)

struct sched_entity *curr; // 当前运行的调度实体
struct sched_entity *next; // 被标记为"优先运行"的实体
struct sched_entity *last; // 刚被抢占的实体(尽量恢复)
struct sched_entity *skip; // 应跳过的实体

#ifdef CONFIG_FAIR_GROUP_SCHED
struct rq *rq; // 所属 CPU 运行队列
struct task_group *tg; // 所属任务组
#endif
};

min_vruntime 是一个单调递增的基准线,代表队列中”最公平”的进度时间点,用于新进程 vruntime 初始化和跨 CPU 迁移时的规范化。

1.4 调度实体 struct sched_entity

每个普通进程(以及组调度中的任务组)都有一个 struct sched_entity,嵌入在 task_struct 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// include/linux/sched.h:549
struct sched_entity {
/* 负载均衡用 */
struct load_weight load; // 实体权重(由 nice 值决定)
struct rb_node run_node; // 红黑树节点
struct list_head group_node;
unsigned int on_rq; // 是否在运行队列中

u64 exec_start; // 本次运行开始时间
u64 sum_exec_runtime; // 累计实际运行时间
u64 vruntime; // 虚拟运行时间(CFS 核心)
u64 prev_sum_exec_runtime; // 上次切换时的累计时间

u64 nr_migrations; // 跨 CPU 迁移次数

#ifdef CONFIG_FAIR_GROUP_SCHED
int depth; // 层级深度
struct sched_entity *parent; // 父调度实体
struct cfs_rq *cfs_rq; // 所在 cfs_rq
struct cfs_rq *my_q; // 若为 group,则为其拥有的 cfs_rq
unsigned long runnable_weight;
#endif
};

vruntime 是 CFS 的核心字段,代表该实体”虚拟时间维度”上的运行进度。CFS 始终选择 vruntime 最小的实体运行,以维护公平性。


二、CFS 核心算法:vruntime 与权重归一化

2.1 vruntime 的概念

CFS 的核心思想是:如果有 N 个相同权重的进程,每个进程应该获得 1/N 的 CPU 时间。为了跟踪”公平进度”,引入 vruntime(虚拟运行时间)

1
vruntime += delta_exec × (NICE_0_LOAD / weight)

对于 nice=0 的进程(权重 1024),vruntime 增长速度等于真实时间。权重越大(nice 值越低),vruntime 增长越慢,因此会更频繁地被调度;权重越小(nice 值越高),vruntime 增长越快,被调度的频率越低。

2.2 权重表 sched_prio_to_weight

nice 值到权重的映射通过预计算表实现,相邻 nice 值之间权重比约为 1.25:

1
2
3
4
5
6
7
8
9
10
11
// kernel/sched/core.c:11459
const int sched_prio_to_weight[40] = {
/* -20 */ 88761, 71755, 56483, 46273, 36291,
/* -15 */ 29154, 23254, 18705, 14949, 11916,
/* -10 */ 9548, 7620, 6100, 4904, 3906,
/* -5 */ 3121, 2501, 1991, 1586, 1277,
/* 0 */ 1024, 820, 655, 526, 423,
/* 5 */ 335, 272, 215, 172, 137,
/* 10 */ 110, 87, 70, 56, 45,
/* 15 */ 36, 29, 23, 18, 15,
};

nice=0 对应权重 1024(NICE_0_LOAD),这是归一化的基准值。nice=-20 的权重(88761)约是 nice=0(1024)的 86 倍,意味着 nice=-20 的进程能获得近 86 倍于 nice=0 进程的 CPU 时间。

2.3 calc_delta_fair:vruntime 增量计算

1
2
3
4
5
6
7
8
// kernel/sched/fair.c:709
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
if (unlikely(se->load.weight != NICE_0_LOAD))
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);

return delta;
}

__calc_delta 的计算公式为:

1
2
delta_vruntime = delta_exec × NICE_0_LOAD / weight
= delta_exec × 1024 / se->load.weight

为了避免浮点运算,内核通过预计算的逆权重表(sched_prio_to_wmult)将除法转化为乘法:delta × wmult >> 32

2.4 update_curr:实时更新 vruntime

每次时钟中断、调度切换时都会调用 update_curr 更新当前进程的运行统计:

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
// kernel/sched/fair.c:897
static void update_curr(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;

if (unlikely(!curr))
return;

delta_exec = now - curr->exec_start; // 本次运行的真实时间
if (unlikely((s64)delta_exec <= 0))
return;

curr->exec_start = now; // 重置开始时间

curr->sum_exec_runtime += delta_exec; // 累加真实运行时间

curr->vruntime += calc_delta_fair(delta_exec, curr); // 更新虚拟时间
update_min_vruntime(cfs_rq); // 更新队列基准线

if (entity_is_task(curr)) {
struct task_struct *curtask = task_of(curr);
cgroup_account_cputime(curtask, delta_exec); // cgroup 计费
}

account_cfs_rq_runtime(cfs_rq, delta_exec); // 带宽控制计费
}

update_min_vruntime 维护 cfs_rq->min_vruntime 单调递增:它取当前运行实体的 vruntime 和红黑树中最左节点的 vruntime 二者中的较小值,但保证不回退。

2.5 CFS 红黑树操作

CFS 使用红黑树(struct rb_root_cached)以 vruntime 为键组织所有可运行实体。rb_root_cached 额外缓存了最左节点,使得 O(1) 时间内找到 vruntime 最小的实体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// kernel/sched/fair.c:643
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
rb_add_cached(&se->run_node, &cfs_rq->tasks_timeline, __entity_less);
}

static void __dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
rb_erase_cached(&se->run_node, &cfs_rq->tasks_timeline);
}

struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);

if (!left)
return NULL;

return __node_2_se(left);
}

__entity_less 比较两个实体的 vruntime,确保红黑树按 vruntime 升序排列。最左节点始终是 vruntime 最小的实体,即最应被调度的进程。

注意:当前正在运行的实体(cfs_rq->curr不在红黑树中,只有就绪但未运行的实体才在树中。当前实体被切换出去时,才通过 put_prev_entity 重新插入树中。

2.6 pick_next_entity:选择下一个运行实体

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
// kernel/sched/fair.c:5084
pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
struct sched_entity *left = __pick_first_entity(cfs_rq);
struct sched_entity *se;

/*
* 若 curr 仍在队列中且其 vruntime 小于树中最左节点,优先考虑 curr
*/
if (!left || (curr && entity_before(curr, left)))
left = curr;

se = left; /* 理想情况下运行最左实体 */

/* 避免运行 skip buddy(主动 yield 时标记)*/
if (cfs_rq->skip && cfs_rq->skip == se) {
struct sched_entity *second;
/* 尝试选次优实体 */
if (se == curr) {
second = __pick_first_entity(cfs_rq);
} else {
second = __pick_next_entity(se);
if (!second || (curr && entity_before(curr, second)))
second = curr;
}
if (second && wakeup_preempt_entity(second, left) < 1)
se = second;
}

/* next buddy:被标记为"下一个应运行"(wakeup 抢占提名) */
if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1) {
se = cfs_rq->next;
} else if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1) {
/* last buddy:尽量恢复刚被抢占的任务,减少上下文切换 */
se = cfs_rq->last;
}

return se;
}

这里引入了三个”buddy”机制:

  • skip:主动 sched_yield 的实体,本轮跳过
  • next:因 wakeup 抢占而被提名的实体,优先运行
  • last:刚刚被抢占的实体,若成本可接受则优先恢复,减少上下文切换开销

wakeup_preempt_entity 判断候选实体相对于最优实体的 vruntime 差距是否在允许范围内(wakeup_gran,基于 sysctl_sched_wakeup_granularity)。


三、进程入队与出队

3.1 enqueue_task_fair:进程变为 RUNNABLE 时入队

当进程从睡眠唤醒或新建进程时,enqueue_task_fair 被调用:

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
// kernel/sched/fair.c:6291
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &p->se;
int task_new = !(flags & ENQUEUE_WAKEUP);

util_est_enqueue(&rq->cfs, p); // 更新利用率估计

for_each_sched_entity(se) {
if (se->on_rq)
break;
cfs_rq = cfs_rq_of(se);
enqueue_entity(cfs_rq, se, flags); // 插入 cfs_rq

cfs_rq->h_nr_running++;

if (cfs_rq_throttled(cfs_rq)) // 若队列被限流则停止向上传播
goto enqueue_throttle;

flags = ENQUEUE_WAKEUP;
}

/* 更新父层级的统计(组调度) */
for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
update_load_avg(cfs_rq, se, UPDATE_TG);
update_cfs_group(se);
cfs_rq->h_nr_running++;
if (cfs_rq_throttled(cfs_rq))
goto enqueue_throttle;
}

add_nr_running(rq, 1); // 全局 nr_running +1
/* ... */
}

for_each_sched_entity 在非组调度情况下只迭代一次(直接返回进程本身),在组调度下则会沿父链向上遍历,将每一层的 cfs_rq 都更新。

内层的 enqueue_entity 完成实际插入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// kernel/sched/fair.c:4823
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
bool renorm = !(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_MIGRATED);

if (renorm && cfs_rq->curr == se)
se->vruntime += cfs_rq->min_vruntime; // 规范化(迁移后)

update_curr(cfs_rq); // 先更新当前进程的 vruntime

if (renorm && cfs_rq->curr != se)
se->vruntime += cfs_rq->min_vruntime;

update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH);
account_entity_enqueue(cfs_rq, se); // 加入队列权重

if (flags & ENQUEUE_WAKEUP)
place_entity(cfs_rq, se, 0); // 设置唤醒进程的初始 vruntime

if (!curr)
__enqueue_entity(cfs_rq, se); // 插入红黑树
se->on_rq = 1;
}

3.2 place_entity:防止饥饿的 vruntime 初始化

新创建的进程或长期睡眠后唤醒的进程,其 vruntime 可能远小于 min_vruntime,若直接放入队列会持续抢占其他进程(因为它的 vruntime 最小)。place_entity 通过调整 vruntime 来防止这种情况:

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
// kernel/sched/fair.c:4732
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
u64 vruntime = cfs_rq->min_vruntime; // 以队列基准线为起点

if (initial && sched_feat(START_DEBIT))
vruntime += sched_vslice(cfs_rq, se); // 新进程:额外惩罚一个 vslice

if (!initial) {
unsigned long thresh;

if (se_is_idle(se))
thresh = sysctl_sched_min_granularity;
else
thresh = sysctl_sched_latency; // 唤醒进程:补偿最多一个调度周期

if (sched_feat(GENTLE_FAIR_SLEEPERS))
thresh >>= 1; // 只补偿一半,避免 sleeper 占便宜

vruntime -= thresh;
}

/* 确保不倒退(防止 vruntime 溢出边界情况) */
if (entity_is_long_sleeper(se))
se->vruntime = vruntime;
else
se->vruntime = max_vruntime(se->vruntime, vruntime);
}

这里有两种场景:

  1. 新进程initial=1):vruntime = min_vruntime + sched_vslice。新进程不能直接从 min_vruntime 起跑,需要额外支付一个”虚拟时间片”的”入场费”,防止 fork 炸弹抢占所有 CPU。

  2. 唤醒进程initial=0):vruntime = min_vruntime - thresh。睡眠进程被唤醒时,允许其 vruntime 比当前基准线略小(补偿其等待时间),但最多补偿一个调度周期(sysctl_sched_latency),避免长期睡眠者无限期抢占。

3.3 dequeue_task_fair:进程阻塞或被抢占时出队

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
// kernel/sched/fair.c:6384
static void dequeue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &p->se;
int task_sleep = flags & DEQUEUE_SLEEP;

util_est_dequeue(&rq->cfs, p);

for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
dequeue_entity(cfs_rq, se, flags);

cfs_rq->h_nr_running--;

if (cfs_rq_throttled(cfs_rq))
goto dequeue_throttle;

/* 若父队列中还有其他实体,停止向上传播 */
if (cfs_rq->load.weight) {
se = parent_entity(se);
/* 进程主动睡眠时,提名父实体为 next_buddy,提升同组任务的调度优先级 */
if (task_sleep && se && !throttled_hierarchy(cfs_rq))
set_next_buddy(se);
break;
}
flags |= DEQUEUE_SLEEP;
}

/* 更新父层级统计 */
for_each_sched_entity(se) { /* ... */ }

sub_nr_running(rq, 1); // 全局 nr_running -1
}

四、调度周期与抢占机制

4.1 sched_slice:计算进程的调度时间片

CFS 没有固定时间片,而是根据进程权重动态计算应得的 CPU 时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// kernel/sched/fair.c:741
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
unsigned int nr_running = cfs_rq->nr_running;
u64 slice;

/* 调度周期:nr_running <= sched_nr_latency 时固定为 sysctl_sched_latency
* 超过时按比例拉伸:period = nr_running × min_granularity
*/
slice = __sched_period(nr_running + !se->on_rq);

/* 按权重比例分配:slice = period × (se->weight / cfs_rq->total_weight) */
for_each_sched_entity(se) {
struct load_weight *load;
struct cfs_rq *qcfs_rq = cfs_rq_of(se);
/* ... 计算权重比 ... */
slice = __calc_delta(slice, se->load.weight, load);
}

/* 确保不低于最小粒度 */
/* ... */
return slice;
}

默认参数(可通过 /proc/sys/kernel/sched_* 调整):

  • sysctl_sched_latency:调度周期,默认 6ms(nr_running <= 8 时)
  • sysctl_sched_min_granularity:最小运行粒度,默认 0.75ms
  • sysctl_sched_nr_latency:超过此值后拉伸周期,默认 8

举例:有 4 个相同 nice 值的进程,调度周期 6ms,每个进程的时间片为 6/4 = 1.5ms。若一个进程 nice=-5(权重 3121),另三个 nice=0(权重 1024),则 nice=-5 进程获得 6ms × 3121 / (3121 + 3×1024) ≈ 3ms

4.2 时钟中断中的 CFS Tick:task_tick_fair

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// kernel/sched/fair.c:12064
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &curr->se;

for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
entity_tick(cfs_rq, se, queued); // 每层 cfs_rq 都更新
}

if (static_branch_unlikely(&sched_numa_balancing))
task_tick_numa(rq, curr); // NUMA 平衡检查

update_misfit_status(curr, rq);
update_overutilized_status(task_rq(curr));
}

entity_tick 中调用 check_preempt_tick 检查是否需要抢占:

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
// kernel/sched/fair.c:4993
static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
unsigned long ideal_runtime, delta_exec;
struct sched_entity *se;
s64 delta;

/* 当前进程的理想运行时间(sched_slice 与 sysctl_sched_latency 取小) */
ideal_runtime = min_t(u64, sched_slice(cfs_rq, curr), sysctl_sched_latency);

delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
if (delta_exec > ideal_runtime) {
resched_curr(rq_of(cfs_rq)); // 设置 TIF_NEED_RESCHED
clear_buddies(cfs_rq, curr);
return;
}

/* 若运行时间不足最小粒度,不抢占(避免频繁切换) */
if (delta_exec < sysctl_sched_min_granularity)
return;

/* 若当前进程的 vruntime 已超过红黑树最左节点 delta > ideal_runtime,抢占 */
se = __pick_first_entity(cfs_rq);
delta = curr->vruntime - se->vruntime;
if (delta < 0)
return;
if (delta > ideal_runtime)
resched_curr(rq_of(cfs_rq));
}

4.3 调度器 tick 调用链

1
2
3
4
5
6
7
8
9
timer interrupt
→ scheduler_tick() [kernel/sched/core.c]
→ curr->sched_class->task_tick()
→ task_tick_fair() [kernel/sched/fair.c]
→ entity_tick()
→ update_curr() // 更新 vruntime
→ check_preempt_tick() // 检查是否超时
→ resched_curr() // 设置 TIF_NEED_RESCHED
→ trigger_load_balance() // 触发负载均衡

scheduler_tick 的核心路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// kernel/sched/core.c:5602
void scheduler_tick(void)
{
int cpu = smp_processor_id();
struct rq *rq = cpu_rq(cpu);
struct task_struct *curr = rq->curr;

rq_lock(rq, &rf);
update_rq_clock(rq);
curr->sched_class->task_tick(rq, curr, 0); // 调用调度类的 tick 方法
calc_global_load_tick(rq);

rq_unlock(rq, &rf);

#ifdef CONFIG_SMP
rq->idle_balance = idle_cpu(cpu);
trigger_load_balance(rq); // 触发 SMP 负载均衡
#endif
}

4.4 唤醒抢占:check_preempt_wakeup

当一个进程被唤醒(wake_up_process)时,内核会检查是否应该抢占当前进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// kernel/sched/fair.c:7855
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
{
struct task_struct *curr = rq->curr;
struct sched_entity *se = &curr->se, *pse = &p->se;
struct cfs_rq *cfs_rq = task_cfs_rq(curr);

/* SCHED_BATCH/SCHED_IDLE 进程不会通过 wakeup 抢占 */
if (unlikely(p->policy != SCHED_NORMAL) || !sched_feat(WAKEUP_PREEMPTION))
return;

find_matching_se(&se, &pse); // 找到公共祖先层级

update_curr(cfs_rq_of(se));
if (wakeup_preempt_entity(se, pse) == 1) {
if (!next_buddy_marked)
set_next_buddy(pse); // 提名新任务为 next buddy
goto preempt;
}
return;

preempt:
resched_curr(rq); // 设置重调度标志
}

wakeup_preempt_entity 判断逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// kernel/sched/fair.c:7810
wakeup_preempt_entity(struct sched_entity *curr, struct sched_entity *se)
{
s64 gran, vdiff = curr->vruntime - se->vruntime;

if (vdiff <= 0)
return -1; // 新任务 vruntime 更大,不抢占

gran = wakeup_gran(se); // 基于 sysctl_sched_wakeup_granularity 计算
if (vdiff > gran)
return 1; // 差距超过粒度阈值,触发抢占

return 0;
}

只有当当前进程的 vruntime 超过唤醒进程 vruntime 一个 wakeup_gran 时才触发抢占,避免因微小的 vruntime 差异导致频繁切换。


五、组调度(Group Scheduling)与 cgroup

5.1 struct task_group:cgroup CPU 调度组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// kernel/sched/sched.h:369
struct task_group {
struct cgroup_subsys_state css; // cgroup 子系统状态

#ifdef CONFIG_FAIR_GROUP_SCHED
struct sched_entity **se; // 每个 CPU 的调度实体(该组在父队列中的代表)
struct cfs_rq **cfs_rq; // 每个 CPU 的 CFS 运行队列
unsigned long shares; // cpu.shares(cgroup v1)/ cpu.weight(v2)
#endif

struct task_group *parent;
struct list_head siblings;
struct list_head children;

struct cfs_bandwidth cfs_bandwidth; // CPU 带宽控制(quota/period)
};

5.2 层次化调度结构

组调度的核心思想是:每个 task_group每个 CPU 上都有独立的 cfs_rqsched_entity

1
2
3
4
5
6
7
8
root task_group
├── se[0] ← 在 CPU 0 的 root cfs_rq 中代表整个组
│ my_q → task_group A 的 CPU 0 cfs_rq
│ ├── task1->se
│ └── task2->se
└── se[1] ← 在 CPU 1 的 root cfs_rq 中代表整个组
my_q → task_group A 的 CPU 1 cfs_rq
└── task3->se

pick_next_task_fair 通过 do { se = pick_next_entity(cfs_rq); cfs_rq = group_cfs_rq(se); } while (cfs_rq) 循环,从根 cfs_rq 向下穿透,直到找到一个叶子级别的 sched_entity(即真正的进程)。

5.3 cpu.shares 与 cpu.weight

  • cgroup v1cpu.shares(默认 1024),等比例分配 CPU。两个组分别设置 512 和 1024,则第二个组能获得约两倍 CPU 时间。

  • cgroup v2cpu.weight(默认 100,范围 1-10000),语义相同但取值范围不同。

对应内核中的 task_group->shares 字段,通过 sched_group_set_shares 更新,最终调整 se->load.weight,从而影响 sched_slice 的计算。

5.4 CPU 带宽控制:cpu.cfs_quota_us / cpu.cfs_period_us

带宽控制允许限制一个 cgroup 在一个周期内最多使用多少 CPU 时间,超出即被限流(throttle)直到下一个周期。

相关内核参数:

  • cpu.cfs_period_us:周期长度,默认 100ms(100000 μs)
  • cpu.cfs_quota_us:每周期可用 CPU 时间,-1 表示不限制

例如设置 quota=50000, period=100000 表示该 cgroup 最多使用 0.5 个 CPU。

5.5 throttle_cfs_rq / unthrottle_cfs_rq

当 cgroup 消耗完配额时触发限流:

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
// kernel/sched/fair.c:5400
static bool throttle_cfs_rq(struct cfs_rq *cfs_rq)
{
struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg);

raw_spin_lock(&cfs_b->lock);
if (__assign_cfs_rq_runtime(cfs_b, cfs_rq, 1)) {
/* 有新配额分配,不需要限流 */
dequeue = 0;
} else {
/* 将 cfs_rq 加入限流链表 */
list_add_tail_rcu(&cfs_rq->throttled_list,
&cfs_b->throttled_cfs_rq);
}
raw_spin_unlock(&cfs_b->lock);

if (!dequeue)
return false;

/* 从父 cfs_rq 中移除该组的调度实体 */
se = cfs_rq->tg->se[cpu_of(rq_of(cfs_rq))];
for_each_sched_entity(se) {
dequeue_entity(qcfs_rq, se, DEQUEUE_SLEEP);
/* ... */
}
sub_nr_running(rq, task_delta);
/* ... */
}

被限流的 cfs_rq 中的所有任务实际上被”虚拟阻塞”——它们仍在 cfs_rq 中,但其父调度实体已被从父 cfs_rq 移除,因此不会被调度。

配额补充由高精度定时器(hrtimer)在每个 cfs_period 结束时触发 sched_cfs_period_timer,调用 unthrottle_cfs_rq 解除限流,重新将组的调度实体加入父 cfs_rq。


六、负载均衡(Load Balance)

6.1 整体框架

SMP 系统中,CFS 通过周期性负载均衡将任务从繁忙 CPU 迁移到空闲 CPU,以实现全局公平性。触发时机:

  1. 周期性均衡scheduler_ticktrigger_load_balance → 软中断 SCHED_SOFTIRQrun_rebalance_domainsload_balance
  2. 空闲时均衡:CPU 进入 idle 时调用 load_balance 主动拉取任务
  3. 新任务唤醒select_task_rq_fair 选择负载最轻的 CPU

6.2 load_balance 主流程

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
// kernel/sched/fair.c:10733
static int load_balance(int this_cpu, struct rq *this_rq,
struct sched_domain *sd, enum cpu_idle_type idle,
int *continue_balancing)
{
struct sched_group *group;
struct rq *busiest;
struct lb_env env = {
.sd = sd,
.dst_cpu = this_cpu,
.dst_rq = this_rq,
.idle = idle,
/* ... */
};

cpumask_and(cpus, sched_domain_span(sd), cpu_active_mask);

group = find_busiest_group(&env); // 在调度域中找最繁忙的组
if (!group) goto out_balanced;

busiest = find_busiest_queue(&env, group); // 在组中找最繁忙的队列
if (!busiest) goto out_balanced;

env.src_cpu = busiest->cpu;
env.src_rq = busiest;

/* 迁移任务 */
ld_moved = detach_tasks(&env);
if (ld_moved) {
attach_tasks(&env);
/* ... */
}
/* ... */
}

6.3 find_busiest_groupfind_busiest_queue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// kernel/sched/fair.c:10361
static struct sched_group *find_busiest_group(struct lb_env *env)
{
/* 统计每个 sched_group 的负载 */
update_sd_lb_stats(env, &sds);

/* 处理特殊情况:misfit task、asym packing、imbalanced group */
if (busiest->group_type == group_misfit_task) goto force_balance;
if (busiest->group_type == group_asym_packing) goto force_balance;

/* 计算不平衡量 */
calculate_imbalance(env, &sds);
return sds.busiest;
}

find_busiest_queue 在选定的 sched_group 中,选出运行队列权重最高(任务最重)的 CPU:

1
2
3
4
5
6
7
8
9
10
11
// kernel/sched/fair.c:10490
static struct rq *find_busiest_queue(struct lb_env *env,
struct sched_group *group)
{
/* 遍历组内各 CPU,找 runnable_load 最大且允许迁移的队列 */
for_each_cpu_and(i, sched_group_span(group), env->cpus) {
/* 计算 rq 的 runnable load,选择最大值 */
/* 排除亲和性不允许迁移的情况 */
}
return busiest;
}

6.4 调度域(Scheduling Domain)层次

负载均衡在调度域(sched_domain)层次上进行,从低到高:

1
2
3
4
SMT(超线程,同一物理核的逻辑核)
→ MC(多核,同一物理 CPU 的多个核)
→ NUMA(跨 NUMA 节点)
→ 系统级

每一层的均衡间隔、迁移代价、不平衡阈值都可以单独配置。

6.5 NUMA 感知调度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// kernel/sched/fair.c:3188
static void task_tick_numa(struct rq *rq, struct task_struct *curr)
{
/*
* 周期性扫描任务的内存访问模式,
* 通过页表保护位触发 NUMA hinting fault,
* 统计各 NUMA 节点的访问热度,
* 将任务迁移到访问最频繁的内存所在节点。
*/
now = curr->se.sum_exec_runtime;
period = (u64)curr->numa_scan_period * NSEC_PER_MSEC;

if (now > curr->node_stamp + period) {
curr->node_stamp += period;
if (!time_before(jiffies, curr->mm->numa_next_scan))
task_work_add(curr, work, TWA_RESUME); // 触发 NUMA 扫描
}
}

NUMA 均衡通过 task_numa_migrate 实现,综合考虑任务的内存访问热度(NUMA faults)和 CPU 负载,将任务迁移到数据所在的 NUMA 节点,减少跨节点内存访问延迟。


七、实时调度(RT Scheduler)概述

CFS 处理普通进程,rt_sched_class 处理实时进程(SCHED_FIFOSCHED_RR)。RT 任务永远优先于 CFS 任务运行。

7.1 SCHED_FIFO vs SCHED_RR

策略 特点
SCHED_FIFO 先入先出。只有主动放弃 CPU(sched_yield)、阻塞或被更高优先级任务抢占时才切换。时间片无限。
SCHED_RR 轮转调度。有固定时间片(默认 100ms),时间片耗尽后轮转到同优先级队列末尾。

RT 进程优先级为 1-99(sched_priority),99 最高。CFS 进程等效 RT 优先级为 0。

7.2 struct rt_prio_array:优先级位图

1
2
3
4
5
// kernel/sched/sched.h:273
struct rt_prio_array {
DECLARE_BITMAP(bitmap, MAX_RT_PRIO+1); // 位图:标记哪些优先级有任务
struct list_head queue[MAX_RT_PRIO]; // 每个优先级对应一个 FIFO 链表
};

RT 运行队列通过位图实现 O(1) 找到最高优先级:sched_find_first_bit(bitmap) 立即定位第一个置位的优先级,然后从对应链表头取出任务。

7.3 pick_next_task_rt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kernel/sched/rt.c:1787
static struct task_struct *_pick_next_task_rt(struct rq *rq)
{
struct sched_rt_entity *rt_se;
struct rt_rq *rt_rq = &rq->rt;

do {
rt_se = pick_next_rt_entity(rt_rq); // 从位图找最高优先级任务
if (unlikely(!rt_se))
return NULL;
rt_rq = group_rt_rq(rt_se);
} while (rt_rq);

return rt_task_of(rt_se);
}

pick_next_rt_entity 通过 sched_find_first_bit(array->bitmap) 找到最高优先级,然后返回对应链表的第一个实体,时间复杂度 O(1)。


八、调度诊断与调优

8.1 查看和修改调度策略

1
2
3
4
5
6
7
8
9
10
11
# 查看进程调度策略和优先级
chrt -p $PID

# 将进程设为 SCHED_FIFO,优先级 50
chrt -f -p 50 $PID

# 将进程设为 SCHED_RR,优先级 20
chrt -r -p 20 $PID

# 将进程重置为 SCHED_OTHER(CFS)
chrt -o -p 0 $PID

8.2 调整 nice 值

1
2
3
4
5
6
7
8
# 调整运行中进程的 nice 值(需要 CAP_SYS_NICE 或降低 nice 值)
renice -n -5 -p $PID

# 以指定 nice 值启动进程
nice -n 10 ./my_process

# 查看所有进程的 nice 值
ps -eo pid,ni,comm --sort=ni

8.3 /proc/PID/sched:CFS 调度统计

1
cat /proc/$(pidof nginx)/sched

输出示例:

1
2
3
4
5
6
7
8
9
10
11
12
nginx (12345, #threads: 4)
---------------------------------------------------------
se.exec_start : 1234567890.123456
se.vruntime : 12345.678901
se.sum_exec_runtime : 1234567.890123
se.nr_migrations : 42
nr_switches : 1234
nr_voluntary_switches : 980
nr_involuntary_switches : 254
se.load.weight : 1024
se.avg.load_avg : 512
se.avg.util_avg : 256

关键字段:

  • vruntime:当前虚拟运行时间
  • sum_exec_runtime:累计实际 CPU 时间(ns)
  • nr_voluntary_switches:主动切换次数(I/O 等待等)
  • nr_involuntary_switches:被动抢占次数(时间片用完)
  • se.load.weight:当前权重(由 nice 值决定)

8.4 /proc/schedstat:全局调度统计

1
2
3
4
cat /proc/schedstat
# 每行对应一个 CPU
# 格式:cpu<N> 各种计数器...
# 字段含义参见 Documentation/scheduler/sched-stats.rst

结合 schedtooltuna 可更方便地解析。

8.5 perf sched:调度延迟分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 录制调度事件(需要 root)
perf sched record -a sleep 10

# 分析调度延迟
perf sched latency

# 输出示例:
# Task | Runtime ms | Switches | Average delay ms | Maximum delay ms |
# nginx:12345 | 123.456 | 100 | 0.123 | 5.678 |

# 查看调度时间线
perf sched timehist

# 分析 map
perf sched map

8.6 bpftrace 追踪调度决策

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
# 追踪 pick_next_task_fair 调用,输出被选中的进程
bpftrace -e '
kprobe:pick_next_task_fair {
$rq = (struct rq *)arg0;
if ($rq->cfs.nr_running > 0) {
printf("CPU %d: nr_running=%d\n",
cpu, $rq->cfs.nr_running);
}
}'

# 追踪调度延迟超过 1ms 的进程
bpftrace -e '
tracepoint:sched:sched_wakeup {
@wake[args->pid] = nsecs;
}
tracepoint:sched:sched_switch {
$prev_pid = args->prev_pid;
if (@wake[$prev_pid]) {
$latency = (nsecs - @wake[$prev_pid]) / 1000000;
if ($latency > 1) {
printf("pid %d comm %s latency %d ms\n",
$prev_pid, args->prev_comm, $latency);
}
delete(@wake[$prev_pid]);
}
}'

# 统计 vruntime 分布(需要 BTF 支持)
bpftrace -e '
kprobe:enqueue_task_fair {
$p = (struct task_struct *)arg1;
@vruntime = hist($p->se.vruntime / 1000000);
}'

8.7 cgroup CPU 统计

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
# cgroup v2
cat /sys/fs/cgroup/my_service/cpu.stat
# 输出:
# usage_usec 12345678 # 累计 CPU 使用时间(微秒)
# user_usec 8765432
# system_usec 3580246
# nr_periods 100 # 经历的带宽周期数
# nr_throttled 5 # 被限流的周期数
# throttled_usec 50000 # 被限流的总时间(微秒)
# nr_bursts 0
# burst_usec 0

# 查看/设置带宽限制
cat /sys/fs/cgroup/my_service/cpu.max
# 输出:50000 100000 (quota=50ms, period=100ms)

# 设置为 0.5 CPU
echo "50000 100000" > /sys/fs/cgroup/my_service/cpu.max

# 查看 cpu.weight(cgroup v2,对应 cpu.shares / 10.24)
cat /sys/fs/cgroup/my_service/cpu.weight

# cgroup v1
cat /sys/fs/cgroup/cpu/my_service/cpu.stat
cat /sys/fs/cgroup/cpu/my_service/cpu.shares

8.8 调度参数调优

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 查看 CFS 调度参数
sysctl -a | grep sched

# 调度延迟周期(默认 6000000 ns = 6ms)
sysctl kernel.sched_latency_ns

# 最小运行粒度(默认 750000 ns = 0.75ms)
# 进程运行不足此时间不会被抢占
sysctl kernel.sched_min_granularity_ns

# 唤醒抢占粒度(默认 1000000 ns = 1ms)
# 唤醒进程 vruntime 优势需超过此值才触发抢占
sysctl kernel.sched_wakeup_granularity_ns

# 延迟敏感场景(如实时音视频):降低粒度
sysctl -w kernel.sched_min_granularity_ns=500000
sysctl -w kernel.sched_wakeup_granularity_ns=500000
sysctl -w kernel.sched_latency_ns=3000000

# 吞吐量优先场景(如批处理):增大粒度,减少切换
sysctl -w kernel.sched_min_granularity_ns=2000000
sysctl -w kernel.sched_latency_ns=12000000

小结

CFS 的设计哲学在于将抽象的”公平”转化为具体的可计算量——vruntime。通过这个虚拟时间轴,内核无需维护复杂的优先级队列逻辑,只需一棵以 vruntime 为键的红黑树,就能以 O(log n) 的复杂度实现近乎完美的公平调度。

核心设计要点回顾:

  1. vruntime 归一化calc_delta_fair 用权重对真实时间进行缩放,权重大的进程 vruntime 增长慢,因此获得更多 CPU 时间,这是 CFS”公平”的数学基础。

  2. min_vruntime 锚点:单调递增的 min_vruntime 防止新进程/唤醒进程通过历史积累的低 vruntime 无限抢占,place_entity 在此基础上实现精细的”补偿-惩罚”策略。

  3. **红黑树 O(log n)**:rb_root_cached 缓存最左节点实现 O(1) 选取,__enqueue_entity/__dequeue_entity 维护 O(log n) 插入删除。

  4. 组调度层次化:每个 task_group 在每个 CPU 上都有独立的 cfs_rqsched_entity,通过多层遍历实现层次化公平,cgroup CPU 带宽控制基于此实现容器 CPU 限制。

  5. SMP 负载均衡:通过调度域层次(SMT → MC → NUMA)实现多粒度的负载均衡,NUMA 感知调度进一步优化内存局部性。

理解 CFS 的源码不仅有助于诊断调度延迟问题,也为容器化环境下合理设置 cpu.shares/cpu.weightcpu.cfs_quota_us 提供了理论基础。下一篇文章将深入分析 Linux 进程的内存管理机制,包括虚拟内存区域(VMA)、缺页异常处理和 OOM Killer 的实现。

基于 Linux 6.4-rc1 源码(commit ac9a78681b92),深入剖析进程创建的内核实现。

进程是操作系统最核心的抽象之一。每次你敲下一条 shell 命令,背后都会发生 fork + exec 这一对经典舞步。然而 “fork 复制父进程、exec 替换镜像” 这句话远未揭示全貌:写时复制如何让 fork 变得极其廉价?clonefork 共享同一条代码路径吗?exec 如何安全地销毁旧地址空间并跳进新程序?本文从 struct task_struct 出发,沿着 fork → clone → exec → exit 的脉络,逐函数拆解内核实现。


一、task_struct:进程的完整画像

每个进程(或线程)在内核中对应一个 struct task_struct,定义于 include/linux/sched.h。它既是调度的基本单元,也是内核追踪进程一切状态的”档案袋”。以下精选最关键的字段并加以注释。

1.1 进程状态

1
2
3
4
5
6
7
8
9
/* include/linux/sched.h: 87–107 */
#define TASK_RUNNING 0x00000000 /* 正在运行或就绪等待调度 */
#define TASK_INTERRUPTIBLE 0x00000001 /* 可中断睡眠,可被信号唤醒 */
#define TASK_UNINTERRUPTIBLE 0x00000002 /* 不可中断睡眠(D 状态),等待 I/O */
#define __TASK_STOPPED 0x00000004 /* 被 SIGSTOP/SIGTSTP 暂停 */
#define __TASK_TRACED 0x00000008 /* 被 ptrace 追踪 */
#define EXIT_DEAD 0x00000010 /* 正在被回收,即将消失 */
#define EXIT_ZOMBIE 0x00000020 /* 已退出但父进程尚未 wait() */
#define TASK_DEAD 0x00000080 /* 彻底死亡,即将释放 */

task_struct 中保存状态的字段为:

1
2
/* include/linux/sched.h: 747 */
unsigned int __state;

注意前缀双下划线——内核要求通过 READ_ONCE()/WRITE_ONCE() 或专用宏访问它,以防止编译器优化导致的竞态。

1.2 调度相关字段

1
2
3
4
5
6
7
8
9
10
/* include/linux/sched.h: 785–793 */
int prio; /* 动态优先级(受 nice 和 RT boost 影响) */
int static_prio; /* 静态优先级,由 nice 值决定 */
int normal_prio; /* 归一化优先级 */
unsigned int rt_priority; /* 实时优先级(0 = 非 RT) */

struct sched_entity se; /* CFS 调度实体(含 vruntime) */
struct sched_rt_entity rt; /* RT 调度实体 */
struct sched_dl_entity dl; /* Deadline 调度实体 */
const struct sched_class *sched_class; /* 指向所属调度类(fair/rt/dl/idle) */

sched_class 是一个虚函数表指针,实现了策略模式:CFS、RT、Deadline 各自实现 enqueue_taskdequeue_taskpick_next_task 等接口,调度器核心代码通过 sched_class 统一调用,无需 if/else 判断。

1.3 进程标识与亲缘关系

1
2
3
4
5
6
7
8
9
/* include/linux/sched.h: 963–987 */
pid_t pid; /* 进程 ID(线程唯一) */
pid_t tgid; /* 线程组 ID(即主线程 pid,getpid() 返回此值) */

struct task_struct __rcu *real_parent; /* 真实父进程(被 ptrace 时可能与 parent 不同) */
struct task_struct __rcu *parent; /* 当前父进程(SIGCHLD 的接收者) */
struct list_head children; /* 子进程链表头 */
struct list_head sibling; /* 挂入父进程 children 链表的节点 */
struct task_struct *group_leader; /* 线程组 leader(主线程) */

pid vs tgid:POSIX 要求同一进程内所有线程共享 PID,这在内核中通过 tgid 实现。getpid() 返回 tgidgettid() 返回 pidgroup_leader 永远指向主线程。

1.4 内存管理

1
2
3
/* include/linux/sched.h: 872–873 */
struct mm_struct *mm; /* 用户态地址空间(内核线程为 NULL) */
struct mm_struct *active_mm; /* 当前使用的 mm(借用上一进程的 mm 做 lazy TLB) */

内核线程 mm == NULL,但运行时 CPU 的 TLB 仍需要一个 mm,所以借用上一个用户进程的 active_mm,这就是 lazy TLB 优化的来源。

1.5 文件系统与文件描述符

1
2
3
/* include/linux/sched.h: 1087–1090 */
struct fs_struct *fs; /* 当前工作目录、根目录(chdir/chroot 影响此处) */
struct files_struct *files; /* 文件描述符表(引用计数共享) */

fs_structfiles_struct 各自有引用计数,clone(CLONE_FS)clone(CLONE_FILES) 时父子进程共享同一个实例而非复制。

1.6 信号处理

1
2
3
4
5
/* include/linux/sched.h: 1100–1106 */
struct signal_struct *signal; /* 线程组共享的信号信息(pending 队列等) */
struct sighand_struct *sighand; /* 信号处理函数表(可被 CLONE_SIGHAND 共享) */
sigset_t blocked; /* 被屏蔽的信号集合 */
struct sigpending pending; /* 该线程私有的 pending 信号队列 */

1.7 凭证与命名空间

1
2
3
4
5
6
/* include/linux/sched.h: 1057–1060 */
const struct cred __rcu *real_cred; /* 客观凭证(真实 UID/GID,setuid 前的值) */
const struct cred __rcu *cred; /* 有效凭证(权限检查使用此值) */

/* include/linux/sched.h: 1097 */
struct nsproxy *nsproxy; /* 指向命名空间集合(PID/Net/UTS/IPC/Mount/Time NS) */

cred 采用写时复制(COW):setuid() 会分配新的 cred 对象,而非修改现有的,这使得 RCU 读者可以无锁访问。


二、fork() 系统调用:从用户态到 kernel_clone

2.1 调用链全景

1
2
3
4
5
6
7
8
9
用户态: fork()
↓ glibc syscall wrapper
内核态: sys_fork (SYSCALL_DEFINE0)

kernel_clone(args)

copy_process(pid, trace, node, args) ← 核心:创建子进程描述符

wake_up_new_task(p) ← 将子进程加入运行队列

2.2 sys_fork 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* kernel/fork.c: 3000–3012 */
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};

return kernel_clone(&args);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}

fork 不传任何 clone flags,这意味着子进程拥有独立的 mm、文件描述符表、信号处理器、命名空间——一个完整的独立进程。

2.3 kernel_clone:派发与收尾

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
/* kernel/fork.c: 2877–2962(节选) */
pid_t kernel_clone(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
pid_t nr;

/* 决定向 ptracer 报告哪类事件 */
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
}

p = copy_process(NULL, trace, NUMA_NO_NODE, args); /* 创建子进程 */
if (IS_ERR(p))
return PTR_ERR(p);

pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid); /* 在当前 PID 命名空间中的虚拟 PID */

if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}

wake_up_new_task(p); /* 子进程入队,可以被调度了 */

if (clone_flags & CLONE_VFORK) {
/* 父进程在此阻塞,等待子进程 exec 或 exit */
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}

put_pid(pid);
return nr; /* 父进程返回子进程 PID,子进程返回 0(由 copy_thread 设置) */
}

2.4 copy_process:子进程的诞生

copy_process 是整个 fork 路径最复杂的函数(约 600 行),它按照严格顺序完成以下工作:

第一步:合法性检查

1
2
3
4
5
6
7
8
/* kernel/fork.c: 2263–2302(节选) */
/* CLONE_THREAD 必须同时设置 CLONE_SIGHAND */
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);

/* CLONE_SIGHAND 必须同时设置 CLONE_VM */
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL);

这揭示了线程的必要条件:线程 = 共享信号处理器 = 共享地址空间,三者缺一不可。

第二步:dup_task_struct——克隆描述符与内核栈

1
2
/* kernel/fork.c: 2333 */
p = dup_task_struct(current, node);

dup_task_struct 的实现(kernel/fork.c: 1101–1190):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
struct task_struct *tsk;

tsk = alloc_task_struct_node(node); /* slab 分配 task_struct */
if (!tsk) return NULL;

err = arch_dup_task_struct(tsk, orig); /* memcpy task_struct */
if (err) goto free_tsk;

err = alloc_thread_stack_node(tsk, node); /* 分配独立内核栈(通常 16KB) */
if (err) goto free_tsk;

setup_thread_stack(tsk, orig); /* 复制 thread_info,更新 task 指针 */
clear_tsk_need_resched(tsk);
set_task_stack_end_magic(tsk); /* 在栈底写入魔数,用于溢出检测 */

#ifdef CONFIG_STACKPROTECTOR
tsk->stack_canary = get_random_canary(); /* 新的栈 canary,防止父子共享 */
#endif
refcount_set(&tsk->usage, 1);
return tsk;
}

关键点:此时 task_struct 是父进程的完整拷贝,但内核栈是全新分配的——否则父子进程会使用同一个内核栈,调度时会互相破坏。

第三步:各子系统的选择性复制

1
2
3
4
5
6
7
8
9
10
11
/* kernel/fork.c: 2492–2518 */
retval = copy_files(clone_flags, p, args->no_files);
retval = copy_fs(clone_flags, p);
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
retval = copy_mm(clone_flags, p); /* COW 复制地址空间 */
retval = copy_namespaces(clone_flags, p);
retval = copy_thread(p, args); /* 架构相关:设置返回地址和寄存器 */

/* 分配 PID */
pid = alloc_pid(p->nsproxy->pid_ns_for_children, args->set_tid, args->set_tid_size);

copy_mm(kernel/fork.c: 1714)的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm = current->mm;

if (!oldmm) return 0; /* 内核线程无需地址空间 */

if (clone_flags & CLONE_VM) {
mmget(oldmm); /* 线程:直接共享父进程 mm,引用计数 +1 */
mm = oldmm;
} else {
mm = dup_mm(tsk, current->mm); /* 进程:COW 复制整个地址空间 */
if (!mm) return -ENOMEM;
}

tsk->mm = mm;
tsk->active_mm = mm;
return 0;
}

copy_files(kernel/fork.c: 1772):

1
2
3
4
5
6
7
8
9
10
11
12
13
static int copy_files(unsigned long clone_flags, struct task_struct *tsk, int no_files)
{
struct files_struct *oldf = current->files;

if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count); /* 线程:共享文件描述符表 */
goto out;
}

newf = dup_fd(oldf, NR_OPEN_MAX, &error); /* 进程:复制 fd 表 */
tsk->files = newf;
...
}

第四步:设置 pid/tgid/group_leader

1
2
3
4
5
6
7
8
9
/* kernel/fork.c: 2574–2581 */
p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
p->group_leader = current->group_leader; /* 新线程的组长 = 父线程的组长 */
p->tgid = current->tgid; /* 线程组 ID 不变 */
} else {
p->group_leader = p; /* 新进程自己是组长 */
p->tgid = p->pid; /* tgid = 自己的 pid */
}

第五步:加入进程树

1
2
3
4
5
6
7
/* kernel/fork.c: 2638–2648 */
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
p->real_parent = current->real_parent; /* 与父进程平级 */
} else {
p->real_parent = current; /* 普通 fork:父进程为当前进程 */
p->exit_signal = args->exit_signal;
}

2.5 wake_up_new_task:子进程入队

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* kernel/sched/core.c: 4809–4848(节选) */
void wake_up_new_task(struct task_struct *p)
{
struct rq_flags rf;
struct rq *rq;

raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
WRITE_ONCE(p->__state, TASK_RUNNING); /* 设为就绪态 */

#ifdef CONFIG_SMP
/* fork 均衡:选择最优 CPU,避免与父进程竞争同一核 */
__set_task_cpu(p, select_task_rq(p, task_cpu(p), WF_FORK));
#endif
rq = __task_rq_lock(p, &rf);
update_rq_clock(rq);
post_init_entity_util_avg(p); /* 初始化 CFS 调度实体的 util_avg */

activate_task(rq, p, ENQUEUE_NOCLOCK); /* 将子进程加入运行队列 */
trace_sched_wakeup_new(p);
check_preempt_curr(rq, p, WF_FORK); /* 检查是否需要抢占当前任务 */

task_rq_unlock(rq, p, &rf);
}

至此,子进程已就绪,等待调度器选中它上 CPU 运行。


三、clone() 与线程创建

3.1 clone flags 语义

所有标志定义于 include/uapi/linux/sched.h

标志 含义
CLONE_VM 共享地址空间(mm_struct),线程的必要条件
CLONE_FS 共享文件系统信息(cwd、root、umask)
CLONE_FILES 共享文件描述符表
CLONE_SIGHAND 共享信号处理函数表,CLONE_VM 的前提
CLONE_THREAD 加入同一线程组(共享 tgid),CLONE_SIGHAND 的前提
CLONE_NEWNS 新建 Mount 命名空间(容器隔离的基础)
CLONE_NEWPID 新建 PID 命名空间(容器内 PID 从 1 开始)
CLONE_VFORK 父进程挂起,直到子进程 exec 或 exit
CLONE_PIDFD 在父进程返回一个 pidfd(进程文件描述符)

3.2 pthread_create 的内核路径

glibcpthread_create 最终调用:

1
2
3
4
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS |
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD,
child_stack, &parent_tid, tls, &child_tid);

这些 flags 让内核:

  • 共享地址空间(VM)
  • 共享文件和信号处理
  • 加入同一线程组(THREAD)
  • 设置 TLS 段(SETTLS)
  • 在子线程退出时通过 CLONE_CHILD_CLEARTID 向 futex 地址写 0,唤醒等待 pthread_join 的线程

3.3 sys_clone 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* kernel/fork.c: 3045–3062 */
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
{
struct kernel_clone_args args = {
.flags = (lower_32_bits(clone_flags) & ~CSIGNAL),
.pidfd = parent_tidptr,
.child_tid = child_tidptr,
.parent_tid = parent_tidptr,
.exit_signal = (lower_32_bits(clone_flags) & CSIGNAL),
.stack = newsp,
.tls = tls,
};

return kernel_clone(&args); /* 与 fork 共享同一路径 */
}

forkvforkclone 在内核中最终都调用 kernel_clone,差异仅在传入的 kernel_clone_args 结构体。这是 Linux 内核”一个实现,多个接口”的典型范式。

3.4 copy_thread:fork 返回 0 的秘密

子进程在 fork 后从 ret_from_fork 开始运行,但为何 fork() 在子进程中返回 0?答案在 x86 的 copy_thread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* arch/x86/kernel/process.c: 137–202(节选) */
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
{
struct pt_regs *childregs = task_pt_regs(p);
struct fork_frame *fork_frame = container_of(childregs, struct fork_frame, regs);
struct inactive_task_frame *frame = &fork_frame->frame;

/* 设置子进程从 ret_from_fork 开始执行 */
frame->ret_addr = (unsigned long) ret_from_fork;
p->thread.sp = (unsigned long) fork_frame;

/* 复制父进程的完整寄存器状态 */
*childregs = *current_pt_regs();

/* 关键:将 eax/rax 设为 0,这就是 fork() 在子进程返回 0 的实现 */
childregs->ax = 0;

if (sp)
childregs->sp = sp; /* 线程使用新栈 */
...
}

父进程的 raxkernel_clone 返回子进程 PID,子进程的 rax 被强制置 0。当子进程被调度到 CPU 上,它从 ret_from_fork 恢复执行,iretrax=0 作为系统调用返回值送回用户态——这就是 fork() 在子进程中返回 0 的完整机制。


四、exec() 系统调用:变身新程序

4.1 调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用户态: execve(path, argv, envp)

sys_execve (SYSCALL_DEFINE3)

do_execve → do_execveat_common

alloc_bprm + bprm_mm_init ← 分配 linux_binprm,创建新 mm

bprm_execve

exec_binprm → search_binary_handler ← 遍历 binfmt 链表找加载器

load_elf_binary (fs/binfmt_elf.c) ← ELF 加载器

begin_new_exec ← 不可回头点:替换旧进程上下文

start_thread (arch) ← 设置入口地址,跳入新程序

4.2 do_execveat_common:准备 bprm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* fs/exec.c: 1886–1969(节选) */
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags)
{
struct linux_binprm *bprm;

bprm = alloc_bprm(fd, filename); /* 分配 linux_binprm,内含新 mm */

bprm->argc = count(argv, MAX_ARG_STRINGS);
bprm->envc = count(envp, MAX_ARG_STRINGS);

bprm_stack_limits(bprm); /* 检查 ARG_MAX 限制 */

/* 将 filename、envp、argv 依次压入新栈(从高地址向低地址) */
copy_string_kernel(bprm->filename, bprm); /* filename */
copy_strings(bprm->envc, envp, bprm); /* 环境变量 */
copy_strings(bprm->argc, argv, bprm); /* 参数 */

retval = bprm_execve(bprm, fd, filename, flags);
...
}

4.3 bprm_mm_init:提前准备新地址空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* fs/exec.c: 364–392 */
static int bprm_mm_init(struct linux_binprm *bprm)
{
struct mm_struct *mm = NULL;

bprm->mm = mm = mm_alloc(); /* 分配全新的 mm_struct */
if (!mm) goto err;

task_lock(current->group_leader);
bprm->rlim_stack = current->signal->rlim[RLIMIT_STACK]; /* 记录栈限制 */
task_unlock(current->group_leader);

err = __bprm_mm_init(bprm); /* 为新栈 mmap 一页临时映射 */
...
}

注意此时旧的 mm 仍然有效——新 mm 在 begin_new_exec 之前不会替换旧的,这样如果 load_elf_binary 失败,进程可以安全回滚。

4.4 search_binary_handler:寻找加载器

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/exec.c: 1715–1759(节选) */
static int search_binary_handler(struct linux_binprm *bprm)
{
struct linux_binfmt *fmt;

retval = prepare_binprm(bprm); /* 读取文件头 128 字节到 bprm->buf */
retval = security_bprm_check(bprm); /* LSM 安全检查 */

read_lock(&binfmt_lock);
list_for_each_entry(fmt, &formats, lh) { /* 遍历已注册的 binfmt */
if (!try_module_get(fmt->module))
continue;
read_unlock(&binfmt_lock);

retval = fmt->load_binary(bprm); /* 调用对应加载器,如 load_elf_binary */

read_lock(&binfmt_lock);
put_binfmt(fmt);
if (bprm->point_of_no_return || retval != -ENOEXEC) {
read_unlock(&binfmt_lock);
return retval;
}
}
...
}

binfmt 链表按注册顺序排列,典型格式有 ELF(binfmt_elf)、脚本(binfmt_script,处理 #!)、misc(binfmt_misc,处理 .jar/.py 等)。

4.5 load_elf_binary:ELF 加载详解

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
/* fs/binfmt_elf.c: 823–(节选) */
static int load_elf_binary(struct linux_binprm *bprm)
{
struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf;

/* 魔数检查:\x7fELF */
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0) goto out;
if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN) goto out;

elf_phdata = load_elf_phdrs(elf_ex, bprm->file); /* 读取程序头表 */

/* 扫描程序头,查找 PT_INTERP(动态链接器路径) */
for (i = 0; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type != PT_INTERP) continue;
/* 读取 /lib64/ld-linux-x86-64.so.2 等路径 */
elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
elf_read(bprm->file, elf_interpreter, ...);
interpreter = open_exec(elf_interpreter); /* 打开 ld.so */
break;
}

/* begin_new_exec:不可回头点,替换旧进程上下文 */
retval = begin_new_exec(bprm);
/* 从这里开始,旧地址空间已销毁,只有前进没有后退 */

/* 将 PT_LOAD segment 逐一 mmap 进新地址空间 */
for (i = 0, elf_ppnt = elf_phdata; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type == PT_LOAD) {
/* elf_map() 调用 do_mmap() 建立文件映射 */
...
}
}

/* 若有动态链接器,同样加载 ld.so 的 PT_LOAD */
if (interpreter) {
elf_entry = load_elf_interp(interp_elf_ex, interpreter, ...);
/* 入口地址改为 ld.so 的入口,而非程序自身的 e_entry */
}
...
}

ELF 加载后的栈布局(从高地址到低地址):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
高地址
┌─────────────────────────────────┐
│ NULL(栈结束标记) │
│ envp 字符串数据 │
│ argv 字符串数据 │
│ filename 字符串 │
│ AT_NULL(auxv 结束) │
│ 辅助向量 auxv(AT_PHDR/AT_ENTRY/│
│ AT_PAGESZ/AT_RANDOM…) │
│ NULL(envp 结束) │
│ envp[n-1] ... envp[0] │
│ NULL(argv 结束) │
│ argv[argc-1] ... argv[0] │
│ argc │ ← rsp 初始指向此处
└─────────────────────────────────┘
低地址

auxv(辅助向量)向 ld.so 传递内核信息:AT_PHDR 指向程序头表在内存中的位置,AT_ENTRY 是程序真实入口(ld.so 完成重定位后跳转到此),AT_RANDOM 是 16 字节随机数用于 PIE/ASLR 种子。

4.6 begin_new_exec:不可回头点

begin_new_exec(fs/exec.c: 1244)是 exec 过程的分水岭,在此之后:

  • 调用 exec_mmap(bprm->mm) 将旧 mm 替换为新 mm,旧地址空间被销毁
  • 调用 flush_signal_handlers() 将所有非 SIG_IGN 的信号处理器重置为 SIG_DFL
  • 关闭所有标有 O_CLOEXEC 的文件描述符
  • 更新进程名(comm)为新程序的 basename
  • 若此时发生错误,进程将以 SIGSEGV 终止(因为旧环境已消失)

五、进程退出:do_exit 与僵尸进程

5.1 do_exit 的退出流程

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
/* kernel/exit.c: 806–924(节选) */
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;

exit_signals(tsk); /* 设置 PF_EXITING 标志,阻止新信号投递 */

group_dead = atomic_dec_and_test(&tsk->signal->live);
/* group_dead = true 说明这是线程组的最后一个线程 */

tsk->exit_code = code;

exit_mm(); /* 解除地址空间:tsk->mm = NULL,mmput() 递减引用计数 */
exit_sem(tsk); /* 释放 System V 信号量 */
exit_shm(tsk); /* 解除共享内存段 */
exit_files(tsk); /* 关闭所有文件描述符(files_struct 引用计数 -1) */
exit_fs(tsk); /* 释放 fs_struct 引用 */
exit_task_namespaces(tsk); /* 释放命名空间引用 */
exit_thread(tsk); /* 释放架构相关资源(如 FPU 状态) */

perf_event_exit_task(tsk);
cgroup_exit(tsk);

exit_notify(tsk, group_dead); /* 通知父进程,进入 ZOMBIE 状态 */

do_task_dead(); /* 调用 schedule() 永不返回 */
}

5.2 exit_mm:地址空间的释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* kernel/exit.c: 532–567 */
static void exit_mm(void)
{
struct mm_struct *mm = current->mm;

exit_mm_release(current, mm); /* 清理 futex robust list 等 */
if (!mm) return;

sync_mm_rss(mm);
mmap_read_lock(mm);
mmgrab_lazy_tlb(mm); /* 转换为 lazy TLB 进程 */

task_lock(current);
current->mm = NULL; /* 脱离地址空间 */
enter_lazy_tlb(mm, current); /* 告知 CPU 使用 lazy TLB 模式 */
task_unlock(current);

mmap_read_unlock(mm);
mm_update_next_owner(mm); /* 将 mm->owner 转移给其他共享线程 */
mmput(mm); /* 引用计数 -1;若为 0 则真正释放页表 */
}

mmput() 只是递减引用计数。若进程有共享同一 mm 的线程(线程组),mm 不会被立刻释放,直到最后一个线程退出。

5.3 exit_notify:从 RUNNING 到 ZOMBIE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* kernel/exit.c: 730–764(节选) */

write_lock_irq(&tasklist_lock);
forget_original_parent(tsk, &dead); /* 将子进程过继给 init 或 subreaper */

tsk->exit_state = EXIT_ZOMBIE; /* 进入僵尸态 */

if (thread_group_leader(tsk)) {
/* 向父进程发送 SIGCHLD(或 exit_signal 指定的信号) */
autoreap = do_notify_parent(tsk, tsk->exit_signal);
}

if (autoreap) {
tsk->exit_state = EXIT_DEAD; /* 父进程忽略 SIGCHLD,直接变 DEAD */
}
write_unlock_irq(&tasklist_lock);

进程在 exit_state = EXIT_ZOMBIE 后:task_struct 仍然存在于内存中,保留 PID、退出码等信息,等待父进程调用 wait() 来回收。这就是”僵尸进程”。

5.4 wait4 与 do_wait:父进程回收子进程

1
2
3
4
5
6
7
8
9
10
11
12
/* kernel/exit.c: 1801–1812 */
SYSCALL_DEFINE4(wait4, pid_t, upid, int __user *, stat_addr,
int, options, struct rusage __user *, ru)
{
struct rusage r;
long err = kernel_wait4(upid, stat_addr, options, ru ? &r : NULL);
if (err > 0) {
if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
return -EFAULT;
}
return err;
}

kernel_wait4 → do_wait → wait_consider_task:扫描子进程列表,找到 EXIT_ZOMBIE 状态的子进程,收集退出码后调用 release_task() 释放 task_struct,PID 归还 pidmap。

5.5 孤儿进程:find_new_reaper

当父进程先于子进程退出,子进程成为孤儿:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* kernel/exit.c: 618–651 */
static struct task_struct *find_new_reaper(struct task_struct *father,
struct task_struct *child_reaper)
{
/* 优先:父进程线程组内的存活线程 */
thread = find_alive_thread(father);
if (thread) return thread;

/* 其次:祖先中标记了 is_child_subreaper 的进程(如 systemd/docker init) */
if (father->signal->has_child_subreaper) {
for (reaper = father->real_parent; ...; reaper = reaper->real_parent) {
if (reaper->signal->is_child_subreaper) {
thread = find_alive_thread(reaper);
if (thread) return thread;
}
}
}

/* 最终:PID 命名空间的 init 进程(pid=1) */
return child_reaper;
}

这就是为什么容器内 PID 1 必须正确处理 SIGCHLD 并调用 wait()——否则容器内所有孤儿进程都会成为其僵尸子进程,逐渐耗尽内核资源。


六、写时复制(COW):fork 廉价的秘密

6.1 COW 的实现原理

fork 时调用 dup_mm → dup_mmap,后者遍历父进程的所有 VMA(虚拟内存区域),将每个页表项拷贝到子进程,但同时将父子两边的 PTE 都标记为只读并清除 dirty bit。

当任一方尝试写入,CPU 触发缺页异常(Protection Fault),内核的 do_wp_page 函数被调用:

  1. 检查页面的 _mapcount(被多少 PTE 引用)
  2. _mapcount == 1(只有自己引用),直接将 PTE 改回可写(”破坏单映射”)
  3. _mapcount > 1,分配新物理页,拷贝内容,更新 PTE 为新页并设为可写

这样,只有真正被写入的页面才会发生物理复制,大量只读的代码页、rodata 页永远不会被复制。

6.2 fork + exec 的极致优化:vfork

如果紧接着 fork 就要 exec,COW 的页表复制本身也是浪费,vfork 为此而生:

1
2
3
4
5
6
7
8
9
/* kernel/fork.c: 3016–3025 */
SYSCALL_DEFINE0(vfork)
{
struct kernel_clone_args args = {
.flags = CLONE_VFORK | CLONE_VM,
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
  • CLONE_VM:父子共享同一 mm,不复制页表
  • CLONE_VFORK:父进程阻塞在 wait_for_vfork_done() 直到子进程调用 exec_exit

vfork 的约束极为严格:子进程只能调用 exec 系列或 _exit,不得修改任何全局状态,也不得 return 出创建它的函数——因为父子共享栈和 mm,任何写操作都会污染父进程。

6.3 为何 fork + exec 几乎不复制内存

即使不用 vfork,普通 fork 后立即 exec,实际代价也很小:

  1. fork 时仅复制页表结构(4级页表的高层节点),并将所有 PTE 标记为 COW 只读
  2. exec 调用 begin_new_exec → exec_mmap,直接将新 mm 替换旧 mm,mmput(old_mm) 递减引用计数后释放旧页表,所有 COW 只读标记都随之消失
  3. 整个过程:父进程没有任何页面被真正复制,唯一的开销是页表遍历和清理

这就是为什么 shell 每秒可以创建数千个子进程,而内存用量并不会暴增。


七、诊断方法

7.1 /proc/PID/status:进程状态全览

1
2
3
4
5
6
7
8
9
10
11
$ cat /proc/1234/status
Name: nginx
State: S (sleeping)
Tgid: 1234 # 线程组 ID = 主线程 PID
Pid: 1234 # 当前线程 PID
PPid: 1 # 父进程 PID
Threads: 4 # 线程数(CLONE_THREAD 创建的)
VmRSS: 12488 kB # 物理内存使用量(Resident Set Size)
VmSize: 458752 kB # 虚拟内存总量
SigBlk: 0000000000000000 # 被阻塞的信号集(位图,16进制)
SigCgt: 0000000180014a03 # 有自定义处理器的信号集

7.2 /proc/PID/task/:查看所有线程

1
2
3
4
5
6
$ ls /proc/1234/task/
1234 1235 1236 1237 # 每个子目录对应一个线程(tid)

$ cat /proc/1234/task/1235/status | grep -E "^(Pid|Tgid):"
Tgid: 1234 # 所有线程 tgid 相同
Pid: 1235 # 各线程 pid 不同

7.3 pstree -p:可视化进程树

1
2
3
4
5
6
$ pstree -p 1
systemd(1)─┬─sshd(892)───sshd(1041)───bash(1042)───pstree(2300)
├─nginx(1234)─┬─nginx(1235)
│ ├─nginx(1236)
│ └─nginx(1237)
└─...

7.4 strace -f:追踪进程创建系统调用

1
2
3
4
# 追踪 fork/clone/execve(-f 跟踪子进程)
$ strace -f -e trace=fork,clone,clone3,execve ls /tmp
execve("/usr/bin/ls", ["ls", "/tmp"], 0x7ffd.../* 40 vars */) = 0
# 显示实际加载的 ELF 文件和参数

7.5 bpftrace:动态追踪 copy_process

1
2
3
4
5
6
7
# 追踪所有进程创建,打印父子 PID 和进程名
$ bpftrace -e '
kretprobe:copy_process
/retval != 0/ {
printf("fork: ppid=%d pcomm=%s cpid=%d\n",
pid, comm, ((struct task_struct *)retval)->pid);
}'

7.6 perf stat:量化进程创建开销

1
2
3
4
5
6
7
8
9
$ perf stat -e task-clock,context-switches,cpu-migrations,page-faults \
-r 5 bash -c 'for i in $(seq 100); do /bin/true; done'

Performance counter stats for 'bash -c ...' (5 runs):

312.45 msec task-clock # CPU 时间
850 context-switches # 上下文切换(每次 fork/exec 各一次)
2 cpu-migrations # 跨核迁移
14230 page-faults # 缺页(含 COW 触发)

7.7 /proc/PID/maps:观察 COW 页面状态

1
2
3
4
5
6
$ cat /proc/1234/maps | head -5
55a3c2000000-55a3c2001000 r--p 00000000 fd:01 123456 /usr/bin/nginx
55a3c2001000-55a3c2100000 r-xp 00001000 fd:01 123456 /usr/bin/nginx # 代码段只读
55a3c2100000-55a3c2120000 r--p 00100000 fd:01 123456 /usr/bin/nginx # rodata 只读
55a3c2121000-55a3c2122000 rw-p 00120000 fd:01 123456 /usr/bin/nginx # data 可写
7f8a00000000-7f8a00020000 rw-p 00000000 00:00 0 [heap]

r--pr-xp 中的 p(private)表示这是写时复制映射,fork 后父子进程共享这些物理页,直到写入为止。


总结

操作 内核入口 共享资源 代价
fork() kernel_clone({.exit_signal=SIGCHLD}) 无(COW 页表) 页表复制 + 子进程入队
vfork() `kernel_clone({CLONE_VFORK CLONE_VM})` mm 完全共享
pthread_create `clone(CLONE_VM CLONE_THREAD …)`
execve() do_execveat_common 无(全新 mm) ELF 加载 + 页表建立

forkdup_task_struct 的内核栈分配,到 copy_threadchildregs->ax = 0 让子进程”看到” 返回值 0,再到 exec 路径上 begin_new_exec 的不可回头设计,以及 exit_zombie 状态下 task_struct 对父进程的等待——这条完整的进程生命周期展现了 Linux 内核在正确性、性能和可观测性之间精妙的平衡。


参考资料

  • include/linux/sched.h — struct task_struct 定义
  • kernel/fork.c — fork/clone/vfork 实现
  • fs/exec.c — execve 实现
  • fs/binfmt_elf.c — ELF 加载器
  • kernel/exit.c — 进程退出
  • kernel/sched/core.c — wake_up_new_task
  • arch/x86/kernel/process.c — x86 copy_thread
  • include/uapi/linux/sched.h — CLONE_* 标志定义

内存问题是生产系统中最难排查的故障类型之一。症状可能表现为 OOM 崩溃、响应延迟飙升、Swap 风暴,也可能是长达数天才显现的缓慢内存泄漏。本文从内核数据结构出发,系统讲解 /proc/meminfo/proc/vmstat 的每个字段含义,结合五大实战案例与 bpftrace 诊断脚本,构建一套完整的内存诊断与调优方法论。

Read more »
0%