简介

在 Linux 容器、云服务器、嵌入式多业务隔离场景中,cgroup 组调度(CFS task_group)是实现 CPU 资源配额隔离、多业务公平分时的底层核心支撑,K8s 容器 CPU 限额、系统用户会话资源隔离、工控多业务进程分组限流全部依赖这套机制落地。普通进程生命周期分为创建、就绪、运行、阻塞、退出五个阶段,task_dead是 CFS 调度类在进程彻底消亡(TASK_DEAD)阶段专属回调接口,在finish_task_switch调度收尾流程中被触发,核心职责是从任务所属 task_group 分组中注销调度实体、逐层修正 PELT(Per-Entity Load Tracking)负载统计、更新各级 cfs_rq 运行队列计数与组权重信息,保证剩余存活任务按照组配额公平瓜分 CPU 时间片。

在实际工程落地中,大量线上故障都和 task_dead 异常相关:容器内进程频繁退出引发组负载统计漂移、CPU 配额不准、同组新任务抢占异常、系统 CPU 利用率统计失真,本质都是任务退出时负载未被 task_dead 正确回收所致。对于内核驱动工程师、云平台容器研发、嵌入式实时 Linux 调试人员、做调度优化方向的研究生而言,吃透 task_dead 内部组负载更新链路,既能排查线上资源隔离故障,也能基于原生组调度框架做定制化资源管控开发,同时是撰写调度方向毕业论文、内核性能调优报告的必备理论基础。本文立足 Linux5.15/6.1 长期稳定内核,从概念、环境搭建、源码拆解、用户态实操、故障排查、工程优化全链路落地,配套可直接编译运行的测试代码与 ftrace 调试指令,兼顾新手入门与深度调研需求。

一、核心概念与术语解析

1.1 CFS 组调度基础架构

组调度依托CONFIG_FAIR_GROUP_SCHED内核配置开启,基于 cgroup v1/cgroup v2 cpuset、cpu 子系统实现任务分组管理,三大核心结构体贯穿 task_dead 全流程:

  1. struct task_group:任务组顶层容器,对应一个 cgroup 分组,内部挂载每个 CPU 专属的组调度运行队列cfs_rq,维护全组聚合负载、组权重、父子分组链表(支持层级嵌套分组,父组包含多个子 task_group);
  2. struct cfs_rq:CPU 私有 CFS 运行队列,分为任务级 cfs_rq(task-cfs_rq)组级 cfs_rq(group-cfs_rq),每个 task_group 在单个 CPU 上独占一个 group-cfs_rq,队列存储红黑树调度实体、nr_running 就绪计数、PELT 可运行负载runnable_load_avg、阻塞负载blocked_load_avg、负载向上传导标记 propagate 等关键字段;
  3. struct sched_entity:调度实体,分为 task-se(单进程调度实体,绑定 task_struct)、group-se(分组调度实体,挂载至父组 cfs_rq),task 退出时需要注销自身 task-se,并向上递归修正父级 group-se 负载数据。

1.2 task_dead 调用时机与作用边界

进程调用 exit/_exit 系统调用后,内核执行 do_exit→schedule→finish_task_switch,当进程状态置为 TASK_DEAD(彻底死亡,不再参与任何调度)时,内核依据进程调度类指针sched_class->task_dead触发对应回调,CFS 对应实现为task_dead_fair(),定义在kernel/sched/fair.c。 task_dead 三大核心工作:

  • 从当前 CPU 的 cfs_rq 红黑树移除退出任务的 task-se;
  • 基于 PELT 算法扣除该任务对本级 cfs_rq 的负载贡献;
  • 沿着 task_group 层级向上逐层回溯父分组,逐级更新上层 group-cfs_rq 负载与运行计数,完成全链路负载回收,避免已消亡任务继续占用组负载配额。

1.3 PELT 负载统计规则

PELT 是 Linux 现代调度负载计算标准,以 1024us 为一个负载周期,按任务可运行时长衰减统计平均负载,task 退出时 task_dead 需要从各级负载总和中剔除消亡任务历史负载,若跳过此步骤,消亡任务负载会永久残留在 task_group 统计中,造成组负载虚高、同组任务被不公平限流。

1.4 关键状态标识

  • on_rq:sched_entity 成员,标记调度实体是否挂载在 cfs_rq 就绪队列,task_dead 优先判断该字段,避免重复出队;
  • nr_running:cfs_rq 就绪任务计数,任务退出自减,若计数归零则清空对应组负载缓存;
  • propagate:cfs_rq 负载传导标记,负载变更后标记置 1,触发向上父组负载同步更新。

二、环境准备

2.1 软硬件环境清单

分类 版本与配置参数
宿主 OS Ubuntu20.04.6 / Ubuntu22.04 x86_64
内核源码 Linux5.15.80 / Linux6.1.32 LTS(源码调度逻辑完全一致)
CPU 硬件 x86_64 4 核及以上,推荐 8 核 16G 内存,支持 cgroup v2
编译依赖 gcc11、make、bison、flex、libelf-dev、libncurses-dev
调试工具 ftrace、trace-cmd、perf、gdb、cgroup-tools

2.2 内核源码下载与编译配置

步骤 1:安装编译依赖(一键复制执行)
sudo apt update -y
sudo apt install build-essential libncurses-dev bison flex libssl-dev libelf-dev cgroup-tools -y
步骤 2:下载并解压指定内核源码
# 下载Linux6.1 LTS源码
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.1.tar.xz
tar -xf linux-6.1.tar.xz
cd linux-6.1
步骤 3:开启组调度与调试配置
# 沿用当前系统内核配置
cp /boot/config-$(uname -r) .config
make menuconfig

必须开启以下内核选项(关键配置,缺一则无法复现组调度与 task_dead 逻辑):

CONFIG_FAIR_GROUP_SCHED=y    # 启用CFS组调度核心开关
CONFIG_CGROUP_SCHED=y       # cgroup绑定调度
CONFIG_CGROUP_CPU=y         # CPU子系统cgroup
CONFIG_DEBUG_KERNEL=y       # 内核调试
CONFIG_SCHED_DEBUG=y        # 调度调试开关
CONFIG_FTRACE=y             # ftrace函数跟踪,观测task_dead调用链路
CONFIG_KPROBES=y            # kprobe动态探针调试

配置完成保存退出。

步骤 4:编译安装自定义内核
make -j$(nproc)
sudo make modules_install
sudo make install
sudo update-grub

重启服务器,在 GRUB 启动项选择新编译内核进入系统。

2.3 源码路径说明

task_dead_fair 与组负载更新核心源码存放路径:

kernel/sched/fair.c      # task_dead_fair、组负载更新全部实现代码
kernel/sched/sched.h     # task_group、cfs_rq、sched_entity结构体定义
include/linux/sched.h    # sched_class调度类函数指针定义(task_dead原型)

2.4 cgroup v2 环境初始化

实操需要 cgroup 分组,一键挂载 cgroup v2:

sudo mkdir -p /sys/fs/cgroup2
sudo mount -t cgroup2 none /sys/fs/cgroup2

三、应用场景(302 字)

云计算 K8s 容器资源管控是 task_dead 最典型落地场景,容器本质依托单个 task_group 实现 CPU 配额限制,容器内业务进程批量启停、异常崩溃退出时,内核通过 task_dead 批量回收消亡进程负载,保证剩余容器进程严格按照 limit 配额占用 CPU。在嵌入式车载域控系统中,仪表显示、车载多媒体、底盘控制三类业务被划分至三个独立 task_group,某一类业务进程异常退出后,task_dead 及时修正分组负载,防止空分组继续占用预留 CPU 算力,富余资源自动分配给高优先级业务分组。此外,企业 Linux 服务器按业务模块分组(数据库组、Web 服务组、日志采集组),Web 进程频繁短链接创建销毁场景下,海量进程退出依赖 task_dead 精准清理负载,避免负载残留导致数据库分组 CPU 资源被无端挤占,保障多业务资源隔离稳定性。

四、实际案例与步骤 + 源码剖析

4.1 结构体源码定义(截取内核原生代码,带工程注释)

4.1.1 task_group、cfs_rq、sched_entity 关键字段(sched.h)
/* 任务组结构体,对应一个cgroup CPU分组 */
struct task_group {
    /* 每个CPU对应一个组级cfs_rq */
    struct cfs_rq **cfs_rq;
    /* 父任务组,实现分组层级嵌套 */
    struct task_group *parent;
    /* 组权重,决定同层级分组CPU分配占比 */
    unsigned int shares;
    /* 全组聚合PELT负载 */
    u64 load_avg;
    /* cgroup css资源结构体 */
    struct css_set *css;
};

/* CFS运行队列,任务/分组共用 */
struct cfs_rq {
    /* 红黑树根,挂载调度实体 */
    struct rb_root tasks_timeline;
    /* 当前队列就绪任务数量 */
    unsigned int nr_running;
    /* PELT可运行平均负载 */
    struct sched_avg avg;
    /* 负载向上传导标记 */
    int propagate;
    /* 归属的任务组 */
    struct task_group *tg;
    /* 父cfs_rq,组调度层级向上指针 */
    struct cfs_rq *parent;
};

/* CFS调度实体 */
struct sched_entity {
    /* 红黑树节点 */
    struct rb_node rb_node;
    /* PELT负载统计 */
    struct sched_avg avg;
    /* 标记是否在就绪队列 */
    int on_rq;
    /* 归属任务组 */
    struct task_group *tg;
    /* 绑定的task_struct(task-se有效,group-se为空) */
    struct task_struct *task;
};

代码说明:task 退出后,task_dead 通过 se->tg 找到所属分组,逐层顺着 cfs_rq->parent 向上遍历父分组完成负载扣减。

4.2 CFS task_dead_fair 内核源码分步拆解(fair.c)

/* CFS调度类任务退出回调,task_dead核心实现 */
static void task_dead_fair(struct task_struct *p)
{
    struct sched_entity *se = &p->se;
    struct cfs_rq *cfs_rq;

    /* 步骤1:判断调度实体是否还在就绪队列,不在则直接返回,无需处理 */
    if (!se->on_rq)
        return;

    /* 获取当前调度实体所在的本级CFS运行队列 */
    cfs_rq = task_cfs_rq(p);

    /* 步骤2:从红黑树移除退出任务的调度实体,更新nr_running计数 */
    dequeue_entity(cfs_rq, se, DEQUEUE_SLEEP);
    cfs_rq->nr_running--;

    /* 步骤3:核心逻辑,扣除该任务PELT负载,逐层向上更新task_group各级负载 */
    update_entity_load_avg(se, 1);
    /* 标记本级cfs_rq负载发生变更,触发向上负载传导 */
    cfs_rq->propagate = 1;
    propagate_load_avg(cfs_rq);

    /* 步骤4:解绑任务与task_group关联,释放分组引用计数 */
    detach_task_group(p, se);
}

逐行场景说明

  1. se->on_rq校验:任务提前阻塞出队时 on_rq 为 0,task_dead 跳过出队逻辑,避免二次删除引发内核 Oops;
  2. dequeue_entity:将 task-se 从 cfs_rq 红黑树摘除,是任务脱离调度队列的基础操作;
  3. update_entity_load_avg(se,1):入参 1 代表任务永久消亡,PELT 算法彻底剔除该实体历史负载,区别于普通任务临时休眠的负载暂存逻辑;
  4. propagate_load_avg:从当前 cfs_rq 出发,沿着 parent 指针向上递归所有父 task_group 对应的 cfs_rq,逐级扣减消亡任务带来的负载贡献,实现全分组负载同步修正,是组调度公平性的关键函数;
  5. detach_task_group:解除 task_struct 与 task_group 的绑定关系,task_group 引用计数递减,分组资源可在无任务后释放。
4.2.1 propagate_load_avg 负载向上传导源码片段
static void propagate_load_avg(struct cfs_rq *cfs_rq)
{
    struct cfs_rq *parent_cfs_rq = cfs_rq->parent;
    /* 无父分组,到达根cgroup,终止传导 */
    if (!parent_cfs_rq || !cfs_rq->propagate)
        return;

    /* 用子队列变更后的负载,修正父分组cfs_rq负载 */
    sub_cfs_rq_load_avg(parent_cfs_rq, cfs_rq);
    /* 父队列标记负载变更,继续向上递归 */
    parent_cfs_rq->propagate = 1;
    propagate_load_avg(parent_cfs_rq);
    /* 清除本级传导标记 */
    cfs_rq->propagate = 0;
}

作用:实现分组层级负载联动,子分组任务退出负载下降,父分组同步下调统计负载,保证上层分组 CPU 配额计算依据实时有效负载。

4.3 用户态测试代码:创建 cgroup 分组 + 批量进程自动退出,复现 task_dead 流程

新建cgroup_task_test.c,代码可直接复制编译,功能:创建 cgroup 分组、批量 fork 子进程加入分组,子进程运行 200ms 主动 exit 触发 task_dead。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <string.h>

#define CG_PATH "/sys/fs/cgroup2/test_group"
#define PROC_NUM 20     // 批量创建20个测试进程

/* 将当前进程加入指定cgroup分组 */
static int add_proc_to_cgroup(pid_t pid)
{
    int fd = open(CG_PATH "/cgroup.procs", O_WRONLY);
    if(fd < 0){
        perror("open cgroup.procs fail");
        return -1;
    }
    char buf[32];
    sprintf(buf, "%d", pid);
    write(fd, buf, strlen(buf));
    close(fd);
    return 0;
}

int main(void)
{
    /* 创建cgroup目录 */
    mkdir(CG_PATH, 0755);
    printf("create cgroup test_group success, start fork child proc\n");

    for(int i = 0; i < PROC_NUM; i++){
        pid_t pid = fork();
        if(pid == 0){
            /* 子进程:加入cgroup,短暂运行后退出 */
            add_proc_to_cgroup(getpid());
            usleep(200000); // 运行200ms
            exit(0); // 触发do_exit→task_dead_fair
        }
    }
    /* 父进程等待所有子进程回收 */
    while(wait(NULL) > 0);
    printf("all child proc exit complete\n");
    return 0;
}
编译与运行指令
# 编译
gcc cgroup_task_test.c -o cgroup_test
# root权限运行(cgroup操作需要管理员)
sudo ./cgroup_test

实操现象:20 个子进程陆续退出,每个进程消亡瞬间内核调用 task_dead_fair,逐层更新 test_group 分组负载。

4.4 Ftrace 跟踪 task_dead_fair 与负载更新函数(一键调试脚本)

新建 trace_task_dead.sh,复制全量内容执行,动态抓取 task_dead 调用栈,直观验证负载更新链路:

#!/bin/bash
mount -t debugfs none /sys/kernel/debug 2>/dev/null
TRACER_DIR=/sys/kernel/debug/tracing
# 清空历史跟踪数据
echo > $TRACER_DIR/trace
# 筛选跟踪目标函数
echo task_dead_fair > $TRACER_DIR/set_ftrace_filter
echo propagate_load_avg >> $TRACER_DIR/set_ftrace_filter
echo update_entity_load_avg >> $TRACER_DIR/set_ftrace_filter
echo dequeue_entity >> $TRACER_DIR/set_ftrace_filter
# 开启函数跟踪
echo function > $TRACER_DIR/current_tracer
echo 1 > $TRACER_DIR/tracing_on

# 后台运行测试程序
sudo ./cgroup_test &
sleep 3

# 关闭跟踪
echo 0 > $TRACER_DIR/tracing_on
# 输出跟踪日志
cat $TRACER_DIR/trace
# 添加执行权限、运行跟踪脚本
chmod +x trace_task_dead.sh
sudo ./trace_task_dead.sh

日志解读:每条子进程退出都会打印task_dead_fair→dequeue_entity→update_entity_load_avg→propagate_load_avg完整调用链,对应源码中四步负载更新逻辑。

4.5 kprobe 动态探针调试(可选进阶实操)

通过 kprobe 在内核 task_dead_fair 入口埋点,打印退出任务 PID 与所属 task_group 地址:

# 挂载kprobe跟踪
echo 'p:task_dead_entry task_dead_fair pid=%p->pid' >> /sys/kernel/debug/kprobes/enable
# 开启事件跟踪
echo 1 > /sys/kernel/debug/tracing/events/kprobes/enable
# 运行测试程序
sudo ./cgroup_test
# 查看探针日志
cat /sys/kernel/debug/tracing/trace

五、常见问题与解答

Q1:进程已经 exit 僵尸态,为什么部分场景不会触发 task_dead_fair?

解答:僵尸进程(EXIT_ZOMBIE)仅退出用户态,task_struct 资源未释放,状态非 TASK_DEAD,只有父进程调用 wait/waitpid 回收子进程,在 finish_task_switch 中将进程置为 TASK_DEAD 后才会触发 task_dead。若父进程异常僵死未回收,子进程长期僵尸挂着,task_dead 延迟触发,组负载残留至回收时刻才修正。

Q2:cgroup 分组内进程全部退出后,分组负载依旧不为 0,是什么原因?

解答:大概率两种诱因:① task_dead 执行异常,propagate_load_avg递归中途异常中断,上层 task_group 负载未同步扣减;② 部分进程被 SIGSTOP 暂停(TASK_STOPPED),未真正退出,任务仍挂载在 cfs_rq,负载正常统计。排查方式:ftrace 抓取 propagate_load_avg 返回值,查看 cfs_rq->nr_running 确认队列剩余任务数量。

Q3:同 cgroup 内新进程创建后 CPU 占用远低于配置 shares 配额,和 task_dead 有关系吗?

解答:有关系,历史大量进程退出时 task_dead 负载更新失败,消亡任务负载残留在 task_group,组总负载虚高,新任务分摊 CPU 时间被异常压缩。解决方案:重启分组清空负载或用 perf 查看组负载统计,配合 ftrace 定位 task_dead 异常调用。

Q4:任务从 cgroup A 迁移至 cgroup B,是否触发 task_dead?

解答:不会,任务迁移调用sched_move_task走调度实体迁移逻辑,仅修改 se->tg 指针,只有进程彻底销毁(TASK_DEAD)才进入 task_dead。迁移场景下内核主动调用负载更新函数,分别修正源分组、目标分组负载,不走 task_dead 回收链路。

Q5:关闭 CONFIG_FAIR_GROUP_SCHED 后,task_dead_fair 还会执行组负载更新吗?

解答:关闭组调度配置后内核编译剔除 task_group 相关代码,task_dead_fair 仅做单任务本级 cfs_rq 出队与负载清理,取消向上 propagate_load_avg 递归传导,无分组负载更新逻辑。

六、实践建议与最佳实践

6.1 内核调试最佳技巧

  1. 故障排查优先级:容器 CPU 配额异常→先通过 ftrace 跟踪 task_dead_fair 调用频次,判断进程退出时回调是否正常触发,再逐级核查 propagate_load_avg 负载传导链路,最后核对 PELT 负载数值,大幅缩短故障定位周期;
  2. 定制内核调试:自研调度优化时,禁止直接删除 task_dead 内部负载传导代码,如需修改组负载规则优先扩展 propagate_load_avg 逻辑,原生 task_dead 的出队与解绑逻辑尽量保留,规避负载统计崩坏。

6.2 线上业务优化规范

  1. 容器业务开发:避免容器内高频短生命周期进程(毫秒级创建销毁),海量进程频繁 exit 会造成内核密集调用 task_dead,高频 PELT 负载计算损耗 CPU 算力,可通过进程池复用减少启停次数;
  2. cgroup 运维规范:废弃不用的 cgroup 分组及时删除,残留空分组会占用 task_group 结构体内存,历史残留错误负载无法自动清零;
  3. 嵌入式系统优化:工控设备固定业务分组绑定固定 CPU(taskset+cpuset),减少跨 CPU 任务迁移,降低 task_dead 触发时跨 CPU 多级负载同步开销。

6.3 内核二次开发优化点

  1. 高并发短进程场景,可在 task_dead 中增加批量负载合并更新逻辑,减少 propagate_load_avg 频繁递归;
  2. 为 task_group 增加负载异常监控节点,通过 proc 文件系统导出各级负载数值,线上异常快速定位 task_dead 负载遗漏场景。

七、总结与应用延伸

本文从组调度架构理论、环境搭建、内核源码逐行解析、用户态实操测试、故障排查、工程优化完整落地 task_dead_fair 任务退出负载更新全流程,明确 task_dead 是 CFS 组调度负载闭环的收尾关键函数:任务退出→移出 cfs_rq 队列→本级 PELT 负载扣减→逐层向上递归更新全 task_group 负载→解绑分组关联,整套流程保障 cgroup 资源隔离的公平性与负载统计准确性。

从技术落地层面,task_dead 是云原生 K8s、容器虚拟化、嵌入式多业务隔离系统的底层基石,所有基于 cgroup 做 CPU 资源限额的业务都依赖这套负载回收机制稳定运行;从学术研究角度,task_dead 内部 PELT 负载衰减、分组层级负载传导设计,是调度算法论文、内核性能优化报告的优质研究切入点,读者可基于本文测试代码修改内核源码,注释掉 propagate_load_avg 函数复现负载残留故障,直观观测分组 CPU 分配异常现象。

建议读者基于 6.1 内核源码修改 task_dead 局部逻辑,配合 ftrace 反复调试不同进程退出场景(正常 exit、kill -9 异常杀死、僵尸进程回收),吃透不同退出路径下负载更新差异,将理论落地到容器调优、嵌入式 Linux 调度裁剪的真实项目中。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐