Android Cgroup概念精讲

3 阅读11分钟

本文出现的文件路径,均以Android U为准,不同版本文件路径可能略有不同,只讲解Cgroup V1版本,Cgroup V2版本概念类似,只是挂载路径略有不同。Cgroup V1版本,Cgroup V2可能会混用,分别管理不同的资源控制器。

一、出现背景

Android 引入 Cgroup,根本原因是:手机上长期同时存在很多进程,而系统必须根据应用的重要程度,动态分配有限的 CPU、内存、I/O 和功耗预算。

Linux 原有机制为什么不够?

早期 Linux 主要提供进程级控制:

  • nice:调整单个进程的 CPU 调度优先级。
  • sched_setscheduler():设置线程调度策略。
  • setrlimit():限制单进程资源。
  • taskset/CPU affinity:指定线程在哪些 CPU 上运行。
  • OOM killer:内存耗尽后选择进程杀死。

这些机制很难解决 Android 的实际问题。一个应用通常包含多个进程、几十个线程,还可能不断创建子进程。Android需要表达的是:

“这个应用整体现在处于后台,所以它的所有进程和线程都应该少用 CPU、限制大核、降低 I/O 优先级,并且在内存紧张时优先回收。”

这属于一组进程的资源管理,正是 Linux Cgroup 要解决的问题。

二、Cgroup概念

2.1 Cgroup 的核心模型

Cgroup 全称 Control Group。它只做两件事:

  1. 把进程或线程组织成组。
  2. 给每个组配置资源规则。

可以把它想成:

任务 → 放入某个组 → 内核按照该组规则调度

例如:

所有任务(进程/线程)
├── top-app
├── foreground
├── background
└── system-background

Android 来判断应用当前是前台还是后台,再把它放入对应组。

这里有一个非常重要的边界:

Linux 内核并不知道什么是 Activity、Launcher、前台应用或缓存应用。这些概念由 Android Framework 判断,cgroup 只负责执行最后的资源策略。

2.2 Cgroup 通过虚拟文件接口来管理

Cgroup 使用一种虚拟文件系统作为内核接口。目录表示组(上面提到的组概念),文件表示该组的参数或操作。

例如:

/dev/cpuctl/
├── cpu.shares
├── cgroup.procs
├── background/
│   ├── cpu.shares
│   └── cgroup.procs
└── top-app/
    ├── cpu.shares
    └── cgroup.procs

如果执行:

echo 1234 > /dev/cpuctl/background/cgroup.procs

含义是:

把 PID 1234 对应的进程迁移到 CPU background 组

如果执行:

echo 1250 > /dev/cpuctl/background/tasks

含义是:

把 TID 1250 对应的线程迁移到 CPU background 组

这些节点虽然看起来是文件,但本质上是内核提供给外部调用的接口,一般是给Android Framework调用的:

  • 写 PID/TID:迁移任务。
  • 写数字:改变资源规则。
  • 读文件:查询配置、状态或统计结果。

2.3 Cgroup有不同的Controller

Cgroup 本身只负责分组,真正管理资源的是 Controller。

Android 常用的几个 Controller 是:

Controller解决的问题当前仓库默认路径
cpu各组如何竞争 CPU 时间/dev/cpuctl
cpuset任务允许在哪些 CPU 上运行/dev/cpuset
blkio块设备 I/O 权重/dev/blkio
memory内存记账、保护和限制/dev/memcg
freezer暂停和恢复进程/sys/fs/cgroup

仓库默认定义在system/core/libprocessgroup/profiles/cgroups.json。

例如:

{
  "Controller": "cpu",
  "Path": "/dev/cpuctl"
}

它告诉 Android:

名字为 cpu 的 Controller,可以在 /dev/cpuctl 找到

这里给出一份完整的cgroups.json作为参考,这里同时用到了Cgroups和Cgroups2两个版本,Cgroups2只管理freezer Controller,Cgroups管理其他Controller:

{
  "Cgroups": [
    {
      "Controller": "blkio",
      "Path": "/dev/blkio",
      "Mode": "0775",
      "UID": "system",
      "GID": "system"
    },
    {
      "Controller": "cpu",
      "Path": "/dev/cpuctl",
      "Mode": "0755",
      "UID": "system",
      "GID": "system"
    },
    {
      "Controller": "cpuset",
      "Path": "/dev/cpuset",
      "Mode": "0755",
      "UID": "system",
      "GID": "system"
    },
    {
      "Controller": "memory",
      "Path": "/dev/memcg",
      "Mode": "0700",
      "UID": "root",
      "GID": "system",
      "Optional": true
    }
  ],
  "Cgroups2": {
    "Path": "/sys/fs/cgroup",
    "Mode": "0775",
    "UID": "system",
    "GID": "system",
    "Controllers": [
      {
        "Controller": "freezer",
        "Path": "."
      }
    ]
  }
}

以上就是Cgroup的基本概念,重要的两个概念是分组和Controller, 理解了上述概念之后就可以看看实际工作中是如何配置的了

三、Cgroup系统在Android中的构建过程

3.1 init进程如何创建这些 Controller

启动早期,PID 1 的 init 调用 CgroupSetup()

入口在 /system/core/init/init.cpp,具体实现在 system/core/libprocessgroup/setup/cgroup_map_write.cpp。

核心过程是:

init 启动
  ↓
读取 /etc/cgroups.json
  ↓
读取 API 级别配置
  ↓
读取 /vendor/etc/cgroups.json
  ↓
挂载各个 Controller 
  ↓
生成运行时 cgroup 映射文件

源码中的顺序在 /system/core/libprocessgroup/setup/cgroup_map_write.cpp:227:

ReadDescriptorsFromFile(CGROUPS_DESC_FILE, descriptors);
ReadDescriptorsFromFile(api_cgroups_path, descriptors);
ReadDescriptorsFromFile(CGROUPS_DESC_VENDOR_FILE, descriptors);

因此优先级是:

system 默认配置
       ↓ 可以被覆盖
API level 配置
       ↓ 可以继续被覆盖
vendor 配置

同名 Controller 会被后加载的配置覆盖,新名字则会被添加。挂载之后cgroups.json中定义的Controller路径就可以使用了

3.2 理解 Android 的 Task Profile

Android 不希望 Framework 到处写这种代码:

write(pid, "/dev/cpuctl/background/cgroup.procs");
write(pid, "/dev/cpuset/background/cgroup.procs");

因为不同内核、Android 版本和设备的路径可能不同。

所以 Android 增加了 Task Profile:

上层只说:我要应用 CPUSET_SP_BACKGROUND配置

配置文件决定:
  加入哪个 CPU 组
  加入哪个 Cpuset 组
  加入哪个 I/O 组
  设置多少 Timer Slack

配置文件是:

/system/core/libprocessgroup/profiles/task_profiles.json,这里只截取了部分,方便后续讲解:

{
  "Attributes": [
    {
      "Name": "LowCapacityCPUs",
      "Controller": "cpuset",
      "File": "background/cpus"
    },
    {
      "Name": "HighCapacityCPUs",
      "Controller": "cpuset",
      "File": "foreground/cpus"
    },
    {
      "Name": "MaxCapacityCPUs",
      "Controller": "cpuset",
      "File": "top-app/cpus"
    },
    {
      "Name": "MemStats",
      "Controller": "memory",
      "File": "memory.stat"
    }
  ],

  "Profiles": [
    {
      "Name": "HighEnergySaving",
      "Actions": [
        {
          "Name": "JoinCgroup",
          "Params":
          {
            "Controller": "cpu",
            "Path": "background"
          }
        }
      ]
    },
    {
      "Name": "Frozen",
      "Actions": [
        {
          "Name": "SetAttribute",
          "Params":
          {
            "Name": "FreezerState",
            "Value": "1"
          }
        }
      ]
    }
  ],

  "AggregateProfiles": [
    {
      "Name": "SCHED_SP_DEFAULT",
      "Profiles": [ "TimerSlackNormal" ]
    },
    {
      "Name": "SCHED_SP_BACKGROUND",
      "Profiles": [ "HighEnergySaving", "LowIoPriority", "TimerSlackHigh" ]
    },
    {
      "Name": "SCHED_SP_FOREGROUND",
      "Profiles": [ "HighPerformance", "HighIoPriority", "TimerSlackNormal" ]
    }
}

里面有三层概念。

第一层:Attribute

Attribute 给内核节点取一个抽象名字:

{
  "Name": "FreezerState",
  "Controller": "freezer",
  "File": "cgroup.freeze"
}

含义是:

FreezerState对应:freezer Controller (freezer Controller 路径在哪里需要看2.3节说的cgroups.json)下的 cgroup.freeze 文件

这里定义好别名之后,方便后面调用这个节点,只用写别名就行,不用写完整路径。

第二层:Profile

Profile 是一组具体动作,具体动作是什么要看,Actions的Name, 例如下面例子中的JoinCgroup和SetAttribute,就对应着把任务加入某个分组和写某个属性。

例如:

{
  "Name": "ProcessCapacityLow",
  "Actions": [
    {
      "Name": "JoinCgroup",
      "Params": {
        "Controller": "cpuset",
        "Path": "background"
      }
    }
  ]
}

它的含义是:

如果应用 ProcessCapacityLow 的话
        ↓
把任务加入 cpuset/background分组

另一个例子:

{
  "Name": "Frozen",
  "Actions": [
    {
      "Name": "SetAttribute",
      "Params": {
        "Name": "FreezerState",
        "Value": "1"
      }
    }
  ]
}

含义是:

如果应用 Frozen 的话
       ↓
找到 FreezerState(cgroup.freeze的别名)这个属性
       ↓
写 cgroup.freeze = 1

第三层:AggregateProfile

AggregateProfile 把多个 Profile (多个动作)组合起来。

例如 CPUSET_SP_BACKGROUND

{
  "Name": "CPUSET_SP_BACKGROUND",
  "Profiles": [
    "HighEnergySaving",
    "ProcessCapacityLow",
    "LowIoPriority",
    "TimerSlackHigh"
  ]
}

具体这几个Profiles干了什么事情,就要到定义他们的地方去找了。

所以 CPUSET_SP_BACKGROUND 实际不是只设置 Cpuset,而是一套完整的后台资源策略。

这也是为什么不要只根据 API 名字猜行为,应该展开 AggregateProfiles 看实际动作。

3.3 跟踪应用进入前台后Cgroup变化的完整调用链

现在跟一条真正的源码链路,帮助读者理解Android Framework如何利用Cgroup机制调整应用资源。

假设 Launcher 启动某个应用,这个应用成为当前 top app。

  1. Framework 计算进程状态

OomAdjuster 根据 Activity、Service、Provider、进程依赖等信息计算进程的重要程度。

当调度组发生变化时,代码进入: frameworks/base/services/core/java/com/android/server/am/OomAdjuster.java

关键映射是:

case SCHED_GROUP_BACKGROUND:
    processGroup = THREAD_GROUP_BACKGROUND;
    break;

case SCHED_GROUP_TOP_APP:
case SCHED_GROUP_TOP_APP_BOUND:
    processGroup = THREAD_GROUP_TOP_APP;
    break;

然后通过 Handler 异步执行:

mProcessGroupHandler.sendMessage(...)

使用 Handler 是因为迁移整个进程可能要处理许多线程,不适合一直占用 AMS 的关键锁。

  1. Java 调用 JNI

Handler 最终调用:

Process.setProcessGroup(pid, group);

JNI 实现在:

/frameworks/base/core/jni/android_util_Process.cpp

核心代码:

SetProcessProfilesCached(
        0,
        pid,
        {get_cpuset_policy_profile_name((SchedPolicy)grp)});

如果 grp 是 SP_TOP_APP,profile 名字就是:

CPUSET_SP_TOP_APP

所以调用链现在变成:

OomAdjuster
  ↓
Process.setProcessGroup(pid, THREAD_GROUP_TOP_APP)
  ↓
JNI
  ↓
SetProcessProfilesCached(pid, "CPUSET_SP_TOP_APP")
  1. libprocessgroup 找到 Profile

如果是线程级 API,则 /system/core/libprocessgroup/sched_policy.cpp 直接映射:

case SP_TOP_APP:
    return SetTaskProfiles(
            tid,
            {"CPUSET_SP_TOP_APP"},
            true) ? 0 : -1;

SetTaskProfiles() 在 /system/core/libprocessgroup/task_profiles.cpp中:

TaskProfile* profile = GetProfile(name);
profile->ExecuteForTask(tid);

逻辑是:

用名字查找 Profile
  ↓
遍历 Profile 中的所有 Action
  ↓
逐个执行
  1. JoinCgroup 变成内核文件写入

加载 JSON 时,JoinCgroup 会转换成 SetCgroupAction

profile->Add(
    std::make_unique<SetCgroupAction>(controller, path));

位置在 /system/core/libprocessgroup/task_profiles.cpp。

执行进程级迁移时:

std::string procs_path =
        controller()->GetProcsFilePath(path_, uid, pid);

open(procs_path, O_WRONLY);
AddTidToCgroup(pid, fd, controller()->name());

见 /system/core/libprocessgroup/task_profiles.cpp。

最终效果相当于:

echo PID > 对应控制组的 cgroup.procs

完整链路因此是:

应用变成 top app
       ↓
OomAdjuster 得到 SCHED_GROUP_TOP_APP
       ↓
Process.setProcessGroup()
       ↓
SetProcessProfilesCached()
       ↓
CPUSET_SP_TOP_APP
       ↓
展开 AggregateProfile
       ↓
执行多个 JoinCgroup / SetTimerSlack
       ↓
写入 cgroup.procs 等内核节点
       ↓
内核按 top-app 策略调度

这条链路就是 Android Framework 策略落到 Linux 内核机制的过程

3.4 理解进程级和线程级 Profile

Android代码有两个重要接口:

SetProcessProfiles(uid, pid, profiles)
SetTaskProfiles(tid, profiles)

区别是:

接口操作对象常见写入节点
SetProcessProfiles整个进程cgroup.procs
SetTaskProfiles单个线程tasks

对应实现在:

ExecuteForProcess()
  → GetProcsFilePath()
  → 写 PID

ExecuteForTask()
  → GetTasksFilePath()
  → 写 TID

代码见 /system/core/libprocessgroup/task_profiles.cpp。

为什么要支持线程级操作?

因为一个 Android 应用进程内部有很多性质不同的线程:

应用进程
├── Main Thread
├── RenderThread
├── Binder Thread
├── Audio Thread
└── 后台工作线程

某些线程需要单独调整。例如:

  • RenderThread 需要低延迟。
  • Audio Thread 可能使用实时调度。
  • 后台计算线程不应该抢占 UI 线程。

因此排查问题时,不要只看:

cat /proc/PID/cgroup

还要看关键线程:

cat /proc/PID/task/TID/cgroup

特别是 UI 卡顿场景,应分别检查:

主线程
RenderThread
Binder 线程

3.4 组资源参数如何设置,并且怎样产生实际效果

组参数可以通过rc脚本中的write在启动时写入

1. Cpuset

例如 ohm 设备配置:/device/amlogic/ohm/init.amlogic.board.rc

write /dev/cpuset/top-app/cpus 0-3
write /dev/cpuset/foreground/cpus 0-3
write /dev/cpuset/background/cpus 0-1
write /dev/cpuset/system-background/cpus 0-1
write /dev/cpuset/restricted/cpus 0-1

它表示:

top-app           可运行在 CPU 0-3
foreground        可运行在 CPU 0-3
background        只能运行在 CPU 0-1
system-background 只能运行在 CPU 0-1
restricted        只能运行在 CPU 0-1

如果一个后台线程持续做大量计算,它最多只能在 CPU 0、1 范围内被调度。

注意:

cpus = 0-3

表示任务可以在这些 CPU 中选择,并不表示一个线程能同时占用四个 CPU。

2. CPU shares

Amlogic mbox 的公共配置在:/device/amlogic/common/products/mbox/init.amlogic.system.rc

其中:

write /dev/cpuctl/background/cpu.shares 1024
write /dev/cpuctl/system/cpu.shares 20480
write /dev/cpuctl/foreground/cpu.shares 20480
write /dev/cpuctl/top-app/cpu.shares 20480

在相同父组下、CPU 出现竞争时,可以粗略理解为:

background : top-app
1024       : 20480
1          : 20

即 top-app 的相对 CPU 权重明显更高。

但 cpu.shares 不是硬限制。

如果系统里只有 background 任务在运行,它仍然可以使用空闲 CPU。权重主要在多个组同时竞争时体现。

3. Uclamp

同一文件还设置:

write /dev/cpuctl/background/cpu.uclamp.max 50
write /dev/cpuctl/system-background/cpu.uclamp.max 50
write /dev/cpuctl/dex2oat/cpu.uclamp.max 60

Uclamp 控制的是调度器看到的性能需求范围。

可以这样理解:

uclamp.min:任务至少希望获得怎样的性能水平
uclamp.max:不让任务表现出高于某个上限的性能需求

它可能影响:

  • 调度器选择高性能核还是低性能核。
  • schedutil 调频器选择的 CPU 频率。
  • 任务是否被当作延迟敏感任务。

它不等于 CPU 使用率上限,也不等于 CFS quota。

以上几个参数只是抛砖引玉,Cgroup还有很多其他参数可以设置,当需要修改的时候可以修改rc脚本

四、在设备上完成一次观察实验

找一个应用包名:

adb shell pidof com.example.app

假设得到 PID 4321

观察进程归属

adb shell cat /proc/4321/cgroup

然后找线程:

adb shell ps -T -p 4321

假设 RenderThread 的 TID 是 4380

adb shell cat /proc/4321/task/4380/cgroup

观察进程和线程的Cpuset

adb shell cat /proc/4321/status | grep Cpus_allowed_list
adb shell cat /proc/4321/task/4380/status | grep Cpus_allowed_list

再读取系统控制器Controller组是如何配置的:

adb shell cat /dev/cpuset/top-app/cpus
adb shell cat /dev/cpuset/background/cpus
adb shell cat /dev/cpuset/foreground/cpus

观察 CPU 权重

adb shell cat /dev/cpuctl/top-app/cpu.shares
adb shell cat /dev/cpuctl/background/cpu.shares
adb shell cat /dev/cpuctl/foreground/cpu.shares

做一次状态切换,看看Cgroup是如何变化的

  1. 让应用显示在最前面。
  2. 读取 /proc/PID/cgroup
  3. 按 Home 返回 Launcher。
  4. 再读取一次。
  5. 等进程进入 cached 状态,再次读取。

可能会有类似变化:

在前台交互:
  cpuset/top-app 或 foreground
  schedtune/top-app 或 foreground

退到后台:
  cpuset/background
  schedtune/background
  blkio/background

实际变化取决于该应用是否还有前台服务、绑定关系、音频播放等活动。

读取上述路径有助于我们分析系统给对应的APP分配的资源情况。