Linux 内核调度与容器基础
#操作系统 · #调度器 · #CFS · #cgroup · #namespace · #容器
Linux 内核调度器决定了哪个进程/线程在何时使用 CPU。cgroup 和 namespace 则为资源隔离和限制提供了基础——它们是 Docker、Kubernetes 等容器技术的基石。
一、Linux 进程调度器
调度器演进
Linux 2.4: O(n) 调度器 — 简单,但扩展性差
Linux 2.6: O(1) 调度器 — 两类队列(active/expired),O(1)选择
Linux 2.6.23: CFS (完全公平调度器) — 红黑树,vruntimeCFS 调度器
核心思想:每个进程应有相同比例的 CPU 时间。
vruntime 公平性完整推导:
公平目标: 所有 SCHED_OTHER 进程在 CPU 时间分配上完全公平。
CFS 不直接分配"绝对时间片",而是追踪每个进程的 vruntime:
vruntime = 实际运行时间 × (NICE_0_LOAD / weight)
NICE_0_LOAD = 1024 (nice=0 的基准权重)
nice 值与权重的完整对照表:
nice值 权重(weight) 相对CPU份额(1024基准)
-20 88761 88761/1024 ≈ 86.7×
-15 29154 29154/1024 ≈ 28.5×
-10 11056 11056/1024 ≈ 10.8×
-5 3356 3356/1024 ≈ 3.3×
0 1024 1024/1024 = 1.0× ← 基准
5 335 335/1024 ≈ 0.33×
10 110 110/1024 ≈ 0.11×
15 36 36/1024 ≈ 0.035×
19 15 15/1024 ≈ 0.015×
权重换算公式 (Linux 内核): weight ≈ 1024 / (1.25^nice)
公式解读:
高权重进程 (nice=-20, weight=88761):
同样的实际运行时间,vruntime 增长只有基准的 1024/88761 ≈ 1.15%
→ vruntime 增长极慢 → 总在红黑树最左边 → 总是被选中 → 分到更多 CPU
低权重进程 (nice=19, weight=15):
同样的实际运行时间,vruntime 增长是基准的 1024/15 ≈ 68.3 倍
→ vruntime 增长极快 → 很快跑到红黑树右边 → 很少被选中 → 分到更少 CPU
公平性验证:
假设两个进程 nice=0 (weight=1024) 和 nice=5 (weight=335)
在一个 CPU 上长时间运行:
设 nice=0 进程 1 秒内运行 T₀ 秒,nice=5 进程运行 T₅ 秒
公平要求: T₀/1024 = T₅/335 (vruntime 增长相等)
→ T₀/T₅ = 1024/335 ≈ 3.06
实际 CPU 分配: nice=0 得到约 75% CPU,nice=5 得到约 25% CPU ✅为什么 CFS 用红黑树?
| 数据结构 | 取最小 vruntime | 插入新进程 | 删除进程 | 更新 vruntime |
|---|---|---|---|---|
| 有序数组 | O(1) | O(n) | O(n) | O(n) 需重排 |
| 最小堆 | O(1) | O(log n) | O(n) 需查找 | O(n) 需查找 |
| 排序链表 | O(1) | O(n) | O(1) | O(n) 需重排 |
| 红黑树 | O(1) | O(log n) | O(log n) | O(log n) |
红黑树是唯一能同时满足"快速取最小"+"快速更新 key"+"自平衡"的结构。vruntime 作为 key 频繁变化,每次时钟中断都会更新当前进程的 vruntime → 需要 O(log n) 的删除+重新插入操作 —— 只有红黑树能高效完成。
CFS 数据结构
c
// 每个 CPU 的运行队列
struct cfs_rq {
struct rb_root_cached tasks_timeline; // 红黑树,key=vruntime
struct sched_entity *curr; // 当前运行的进程
struct sched_entity *next;
u64 min_vruntime; // 最小 vruntime(防溢出)
};
// 进程的调度实体
struct sched_entity {
u64 vruntime; // 虚拟运行时间(红黑树 key)
u64 sum_exec_runtime; // 总实际运行时间
u64 prev_sum_exec_runtime;
struct load_weight load; // 权重
struct rb_node run_node; // 红黑树节点
};CFS 调度流程
mermaid
flowchart TB
A["时钟中断<br/>scheduler_tick()"] --> B{"当前进程<br/>时间片用完?"}
B -->|否| C["继续运行<br/>更新 vruntime"]
B -->|是| D["TIF_NEED_RESCHED 标记"]
D --> E["schedule()"]
E --> F["pick_next_task()"]
F --> G["从红黑树取最左节点<br/>(vruntime 最小)"]
G --> H["context_switch()"]
H --> I["切换到新进程"]
I --> A调度类
c
// 按优先级排序的调度类链
stop_sched_class // 最高优先级:停止 CPU 用于 RCU/migration
dl_sched_class // Deadline 调度:保证任务在 deadline 前完成
rt_sched_class // 实时调度:FIFO/RR
fair_sched_class // CFS:普通进程
idle_sched_class // Idle:CPU 空闲时运行调度策略
| 策略 | 类型 | 优先级 | 适用 |
|---|---|---|---|
| SCHED_OTHER | CFS | 0 | 普通进程 |
| SCHED_BATCH | CFS | 0 | 批处理,减少切换 |
| SCHED_IDLE | CFS | 极低 | 后台任务 |
| SCHED_FIFO | RT | 1-99 | 实时,先到先运行 |
| SCHED_RR | RT | 1-99 | 实时,时间片轮转 |
| SCHED_DEADLINE | DL | 最高 | 硬实时 |
抢占时机
1. 时钟中断检查时间片用尽
2. 唤醒更高优先级的进程(try_to_wake_up)
3. 新进程加入 CFS 队列,vruntime 远小于当前进程
4. 内核态返回用户态时检查 TIF_NEED_RESCHED二、cgroup(Control Group)
作用
- 限制:CPU、内存、I/O、网络带宽上限
- 隔离:进程组间的资源隔离
- 统计:各组的资源使用量
cgroup 子系统
| 子系统 | 作用 |
|---|---|
| cpu | CPU 使用时间限制 |
| cpuacct | CPU 使用统计 |
| cpuset | CPU 核心 + 内存节点绑定 |
| memory | 内存使用限制、OOM 控制 |
| blkio | 块设备 I/O 限制 |
| net_cls | 网络包标记(配合 tc 限流) |
| pids | 限制进程数 |
| devices | 设备访问控制 |
| freezer | 冻结/恢复进程组 |
cgroup v2 示例
bash
# 创建一个 cgroup
mkdir /sys/fs/cgroup/myapp
# 限制 CPU:只使用 1.5 个核
echo "150000 100000" > /sys/fs/cgroup/myapp/cpu.max
# ↑配额 ↑周期(微秒) → 每个周期最多运行 150ms → 1.5 核
# 限制内存:最多 512MB
echo "536870912" > /sys/fs/cgroup/myapp/memory.max
# 将进程加入 cgroup
echo $PID > /sys/fs/cgroup/myapp/cgroup.procsDocker 中的 cgroup
bash
docker run --cpus=1.5 --memory=512m nginx
# 实际创建的 cgroup 路径:
# /sys/fs/cgroup/system.slice/docker-<container-id>.scope/三、namespace(命名空间)
七种 namespace
| namespace | 隔离内容 | 内核版本 |
|---|---|---|
| PID | 进程 ID(容器内 PID 1 独立) | 2.6.24 |
| NET | 网络栈(IP、端口、路由表、iptables) | 2.6.29 |
| IPC | System V IPC、POSIX 消息队列 | 2.6.19 |
| UTS | 主机名和域名 | 2.6.19 |
| MNT | 文件系统挂载点 | 2.4.19 |
| USER | 用户和组 ID | 3.8 |
| CGROUP | cgroup 根目录视图 | 4.6 |
示例:手动创建 namespace
bash
# unshare 创建新 namespace
unshare --pid --net --mount --uts --fork /bin/bash
# 在新 namespace 中:
echo $$ # → 1(PID namespace 隔离)
ip link # → 只有 lo(NET namespace 隔离)
hostname isolated-container # → 不影响宿主机(UTS namespace)Go 中创建 namespace
go
cmd := exec.Command("/bin/bash")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID | // 新 PID namespace
syscall.CLONE_NEWNET | // 新 NET namespace
syscall.CLONE_NEWNS | // 新挂载 namespace
syscall.CLONE_NEWUTS, // 新 UTS namespace
}
cmd.Run()四、容器 = namespace + cgroup + rootfs
mermaid
flowchart TB
subgraph Container["容器"]
Proc["进程 (PID ns 隔离)"]
Net["网络栈 (NET ns 隔离)"]
Fs["文件系统 (MNT ns)"]
Host["主机名 (UTS ns)"]
User["用户 (USER ns)"]
end
subgraph Limits
Cgroup["cgroup 限制<br/>CPU / 内存 / IO / PIDs"]
end
subgraph Image
Rootfs["rootfs<br/>镜像层 (overlay2)"]
end
Container --- Limits --- Image容器镜像(overlay2)
mermaid
flowchart TB
subgraph Layers["overlay2 分层存储"]
RW["Container Layer (RW)<br/>容器运行时写入"]
L3["Image Layer 3<br/>app code"]
L2["Image Layer 2<br/>runtime dependency"]
L1["Image Layer 1<br/>base OS"]
end
RW --> L3 --> L2 --> L1
Unified["联合挂载视图<br/>所有层叠加为一个统一文件系统"]
Layers --> UnifiedDocker 实现简析
docker run 做了这些事:
1. 拉取/加载镜像(rootfs)
2. 创建各 namespace(clone + unshare)
3. 设置 cgroup 限制
4. 配置网络(veth pair + bridge)
5. pivot_root 切换根文件系统
6. exec 容器入口进程4.1 容器网络详解
容器网络的核心机制是 veth pair(虚拟网线)——一对连接的虚拟网卡,一端在容器 namespace 中(eth0),另一端在宿主机 namespace 中(vethXXXX),通过 bridge 互通。
mermaid
flowchart TB
subgraph Host["宿主机"]
Eth0["eth0<br/>192.168.1.10"]
subgraph Bridge["docker0 Bridge<br/>172.17.0.1"]
direction LR
end
V1["vethA1<br/>宿主机端"]
V2["vethB1<br/>宿主机端"]
end
subgraph ContainerA["容器 A"]
CA["eth0<br/>172.17.0.2"]
end
subgraph ContainerB["容器 B"]
CB["eth0<br/>172.17.0.3"]
end
V1 ---|"veth pair"| CA
V2 ---|"veth pair"| CB
V1 --- Bridge
V2 --- Bridge
Bridge -->|"NAT (iptables)"| Eth0
Eth0 -->|"出公网"| Internet["互联网"]容器间通信(同宿主机):
bash
# 容器 A → 容器 B (172.17.0.3):
# 1. A 查路由表 → 目标在 172.17.0.0/16 → 走 docker0
# 2. 经过 veth pair → 到达宿主机 docker0 bridge
# 3. bridge 查 MAC 表 → 转发到 vethB1
# 4. vethB1 → 容器 B 的 eth0 → 到达容器访问外网(SNAT):
bash
# iptables MASQUERADE 规则:
iptables -t nat -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
# 容器 (172.17.0.2) → 外部 (8.8.8.8):
# 1. 容器发出 → docker0 bridge
# 2. iptables SNAT: 源 IP 172.17.0.2 → 宿主机 IP 192.168.1.10
# 3. 宿主机路由发出
# 4. 回包: DNAT 还原 → docker0 → veth → 容器外网访问容器(DNAT/端口映射):
bash
# docker run -p 8080:80 nginx 等价于:
iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to 172.17.0.2:80
# 外部 → 宿主机:8080:
# 1. iptables DNAT: 目标 172.17.0.2:80
# 2. 路由到 docker0 bridge
# 3. bridge 转发到容器五、Kubernetes 资源管理
Pod 的资源请求与限制
yaml
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
resources:
requests: # 调度时使用的最小保证
cpu: "500m" # 0.5 核
memory: "256Mi"
limits: # 运行时的硬限制
cpu: "1" # 1 核
memory: "512Mi"QoS 等级
| QoS | 条件 | OOM 优先级 |
|---|---|---|
| Guaranteed | requests == limits (CPU+Mem 都要) | 最低(最后被杀) |
| Burstable | requests < limits | 中 |
| BestEffort | 没设 requests/limits | 最高(最先被杀) |
CPU 限流问题
yaml
# 问题:设置了 limits.cpu=1 但进程只用 200ms
# → CPU 被 throttle,即使有空闲 CPU
# CPU Manager 静态策略(K8s 1.26+)
# 绑定 CPU 核心,减少限流
spec:
containers:
- name: app
resources:
limits:
cpu: "2"
requests:
cpu: "2"参考
六、Linux CFS vs Go GMP 调度对比
两者都是"公平调度"思想,但层次和目标不同:
| 维度 | Linux CFS | Go GMP |
|---|---|---|
| 调度对象 | 线程/进程 | goroutine |
| 调度层次 | 内核态 | 用户态 |
| 数据结构 | 红黑树 (vruntime) | 本地队列 + 全局队列 |
| 时间片 | 动态(基于 nice 值) | 无固定时间片(协作+抢占) |
| 抢占方式 | 时钟中断强制抢占 | 函数调用点检查 + 信号抢占 (Go 1.14+) |
| 上下文切换 | ~1-5μs(内核态切换) | ~100-200ns(用户态切换) |
| 调度延迟 | 保证 O(log n) | 近似 O(1)(本地队列) |
| 公平性 | 严格公平(vruntime) | 尽力公平(work stealing) |
| 优先级 | nice -20 ~ 19 | 无优先级(所有 goroutine 平等) |
| 亲和性 | CPU affinity | P 绑定(goroutine 优先在本地 P 运行) |
内核调度 vs 用户态调度的本质差异——为什么 goroutine 切换比线程快 10-50 倍?
线程上下文切换的完整代价 (~1-5μs):
1. 保存当前线程的寄存器状态到 task_struct
- 通用寄存器 (16个 × 8B = 128B)
- 浮点/SIMD 寄存器 (AVX-512: 32个 × 64B = 2KB)
- 段寄存器、控制寄存器等
2. 切换页表 (如果跨进程)
- 写 CR3 寄存器 → TLB 全部失效 → 后续访存全部 miss
- 同进程线程切换不需要换页表 → 但仍需内核态切换
3. 内核态切换
- 用户态 → 内核态: syscall 指令 (~100ns)
- 内核态 → 用户态: sysret 指令 (~100ns)
- 中间还有安全检查、调度决策等
4. 缓存污染
- 新线程的工作集不在 L1/L2 cache 中
- 前几百次内存访问都是 cache miss
- 这个"cache warmup"代价往往比切换本身更大!
goroutine 上下文切换的代价 (~100-200ns):
1. 保存当前 G 的少量寄存器到 gobuf
- 只保存: SP, PC, BP, 少量 callee-saved 寄存器
- 不保存浮点寄存器(Go 编译器知道哪些需要保存)
- 总共约 50-100 字节
2. 不切换页表
- 所有 goroutine 共享同一个进程地址空间
- TLB 不失效 ✅
3. 不进入内核态
- GMP 调度完全在用户态完成
- 没有 syscall/sysret 的开销
- 没有内核安全检查
4. 缓存亲和性好
- 同一个 P 上的 goroutine 共享 mcache
- 工作集可能仍在 L1/L2 中(如果切换频繁)
量化对比:
线程切换: 寄存器保存(200ns) + 内核态切换(200ns) + 调度决策(100ns) + cache warmup(500-4000ns)
≈ 1000-5000ns
goroutine切换: 寄存器保存(30ns) + 调度决策(50ns) + 队列操作(20ns)
≈ 100-200ns
比值: 10-50 倍Go 1.14+ 异步抢占的实现——为什么需要信号?
Go 1.14 之前的问题:
goroutine 只在"协作点"(函数调用、channel 操作等)检查是否需要让出 CPU
如果一个 goroutine 执行纯计算循环(无函数调用)→ 永远不会被抢占
→ 其他 goroutine 饿死 → GC 无法 STW(需要所有 G 到达安全点)
Go 1.14+ 的解决方案: 基于信号的异步抢占
1. sysmon 检测到某个 G 运行超过 10ms
2. 向该 G 所在的 M 发送 SIGURG 信号
3. 信号处理函数 asyncPreempt 被调用
4. asyncPreempt 保存当前寄存器状态 → 切换到 g0 栈 → 执行调度
为什么选 SIGURG?
- 不会被用户程序使用(TCP 带外数据信号,几乎没人用)
- 不会干扰 SIGPROF(pprof 使用)
- 不会干扰 SIGIO(网络 I/O 使用)协作关系
mermaid
flowchart TB
subgraph Kernel["Linux 内核"]
CFS["CFS 调度器"]
Thread1["OS Thread 1 (M)"]
Thread2["OS Thread 2 (M)"]
Thread3["OS Thread 3 (M)"]
CFS --> Thread1
CFS --> Thread2
CFS --> Thread3
end
subgraph GoRuntime["Go Runtime (用户态)"]
GMP["GMP 调度器"]
P1["P0: G1, G2, G3"]
P2["P1: G4, G5, G6"]
GMP --> P1
GMP --> P2
end
Thread1 ---|"绑定"| P1
Thread2 ---|"绑定"| P2
Thread3 ---|"空闲 M(等待)"| GMP
Note["CFS 调度 M(OS 线程)<br/>GMP 调度 G(goroutine)<br/>两层调度协作"]关键认知:Go 程序中,CFS 调度的是 M(OS 线程),GMP 调度的是 G(goroutine)。当容器设置
cpu.max=100000/100000(1 核)时,CFS 限制的是所有 M 的总 CPU 时间,而 GMP 可能创建了多个 M(GOMAXPROCS > 1),导致 CPU throttle。
七、容器 CPU Throttle 排查实战
问题现象
症状:
- 服务 P99 延迟周期性飙升
- CPU 使用率看起来不高(如 60%)
- 但 cgroup 统计显示大量 throttle
根因:
容器 limits.cpu=2,但 GOMAXPROCS=8(默认取宿主机 CPU 数)
→ Go 创建 8 个 OS 线程并行运行
→ CFS 在 100ms 周期内只允许使用 200ms CPU 时间
→ 8 个线程 25ms 就用完配额 → 剩余 75ms 被 throttle排查步骤
bash
# 第 1 步:确认是否被 throttle
cat /sys/fs/cgroup/cpu.stat
# 输出:
# usage_usec 123456789
# user_usec 100000000
# system_usec 23456789
# nr_periods 50000 ← CFS 周期数
# nr_throttled 12000 ← 被限流的周期数(>0 说明有 throttle)
# throttled_usec 8000000 ← 被限流的总时间(微秒)
# throttle 比例 = nr_throttled / nr_periods = 24% → 严重!
# 第 2 步:查看 CPU 配额
cat /sys/fs/cgroup/cpu.max
# 输出: 200000 100000
# 含义: 每 100ms 周期,最多使用 200ms CPU → 2 核
# 第 3 步:查看 Go 程序的 GOMAXPROCS
curl http://localhost:6060/debug/pprof/goroutine?debug=1 | head -5
# 或在代码中打印 runtime.GOMAXPROCS(0)解决方案
go
// 方案 1: 使用 automaxprocs(推荐)
// 自动根据 cgroup CPU 配额设置 GOMAXPROCS
import _ "go.uber.org/automaxprocs"
// 方案 2: 手动设置
// 容器 limits.cpu=2 → GOMAXPROCS=2
runtime.GOMAXPROCS(2)
// 方案 3: K8s 中设置环境变量
// env:
// - name: GOMAXPROCS
// value: "2"Throttle 监控 PromQL
promql
# 容器 CPU throttle 比例
rate(container_cpu_cfs_throttled_periods_total[5m])
/ rate(container_cpu_cfs_periods_total[5m])
# 告警:throttle 比例 > 25%
container_cpu_cfs_throttled_periods_total
/ container_cpu_cfs_periods_total > 0.25八、K8s Pod OOM 排查流程
mermaid
flowchart TB
OOM["Pod OOMKilled"] --> Check{"哪种 OOM?"}
Check -->|"容器 OOM<br/>(cgroup 内存限制)"| CgroupOOM["cgroup OOM"]
Check -->|"节点 OOM<br/>(Node 内存不足)"| NodeOOM["Node OOM Killer"]
CgroupOOM --> C1["查看 Pod events"]
C1 --> C2["kubectl describe pod → OOMKilled"]
C2 --> C3{"内存使用合理吗?"}
C3 -->|"确实需要更多内存"| Fix1["增大 limits.memory"]
C3 -->|"内存泄漏"| Fix2["排查泄漏<br/>(pprof heap)"]
C3 -->|"JVM/Go 堆设置不当"| Fix3["调整运行时参数<br/>GOMEMLIMIT / -Xmx"]
NodeOOM --> N1["查看 Node 事件"]
N1 --> N2["dmesg | grep oom"]
N2 --> N3["被杀的是哪个 Pod?<br/>(oom_score 最高的)"]
N3 --> N4["设置合理的 requests<br/>避免 BestEffort QoS"]排查命令
bash
# 第 1 步:确认 OOM 类型
kubectl describe pod <pod-name> | grep -A5 "Last State"
# Reason: OOMKilled, Exit Code: 137
# 第 2 步:查看内存使用历史
kubectl top pod <pod-name> --containers
# 第 3 步:查看 cgroup 内存统计
kubectl exec <pod-name> -- cat /sys/fs/cgroup/memory.current
kubectl exec <pod-name> -- cat /sys/fs/cgroup/memory.max
kubectl exec <pod-name> -- cat /sys/fs/cgroup/memory.events
# 输出: oom 3 oom_kill 3 ← 被 OOM Kill 了 3 次
# 第 4 步:Go 程序内存分析
kubectl exec <pod-name> -- wget -O- http://localhost:6060/debug/pprof/heap > heap.prof
go tool pprof heap.profGo 程序防 OOM 最佳实践
go
// Go 1.19+: 设置软内存上限
// 当内存接近限制时,GC 更积极地回收
// 环境变量: GOMEMLIMIT=400MiB(容器 limits=512Mi 时留 20% 余量)
// 或代码中设置
import "runtime/debug"
debug.SetMemoryLimit(400 * 1024 * 1024) // 400 MiB
// K8s 配置建议:
// resources:
// requests:
// memory: "256Mi"
// limits:
// memory: "512Mi"
// env:
// - name: GOMEMLIMIT
// value: "400MiB" # limits 的 ~80%
登录后即可发表评论 👇