Skip to content

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 (完全公平调度器) — 红黑树,vruntime

CFS 调度器 ​

核心思想:每个进程应有相同比例的 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_OTHERCFS0普通进程
SCHED_BATCHCFS0批处理,减少切换
SCHED_IDLECFS极低后台任务
SCHED_FIFORT1-99实时,先到先运行
SCHED_RRRT1-99实时,时间片轮转
SCHED_DEADLINEDL最高硬实时

抢占时机 ​

1. 时钟中断检查时间片用尽
2. 唤醒更高优先级的进程(try_to_wake_up)
3. 新进程加入 CFS 队列,vruntime 远小于当前进程
4. 内核态返回用户态时检查 TIF_NEED_RESCHED

二、cgroup(Control Group) ​

作用 ​

  • 限制:CPU、内存、I/O、网络带宽上限
  • 隔离:进程组间的资源隔离
  • 统计:各组的资源使用量

cgroup 子系统 ​

子系统作用
cpuCPU 使用时间限制
cpuacctCPU 使用统计
cpusetCPU 核心 + 内存节点绑定
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.procs

Docker 中的 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
IPCSystem V IPC、POSIX 消息队列2.6.19
UTS主机名和域名2.6.19
MNT文件系统挂载点2.4.19
USER用户和组 ID3.8
CGROUPcgroup 根目录视图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 --> Unified

Docker 实现简析 ​

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 优先级
Guaranteedrequests == limits (CPU+Mem 都要)最低(最后被杀)
Burstablerequests < 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 CFSGo 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 affinityP 绑定(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.prof

Go 程序防 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%
批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记