Android系统上的进程管理:进程的调度

Posted on Aug 19, 2019


之前我写过一些文章讲解Android系统上的进程管理,那几篇文章主要是从ActivityManagerService的角度来讲解。而这篇文章,将从更底层,从Linux内核层的角度讲解Android系统对于进程的调度管理。

前言

之前我写过几篇文章讲解Android系统上的进程管理,包括:

那几篇文章主要是从ActivityManagerService的角度讲解。在这篇文章中,我们更深入一些。结合Linux内核,来看看Android系统对于进程的调度管理。

为了便于讲解,下文在相关内容中会贴出相应的源码,源码的版本如下:

进程调度

进程调度是操作系统最核心的功能之一。

在现代的操作系统中(无论是个人电脑还是手机),同一时刻会有几十个甚至几百个进程同时运行。但CPU的数量远没有进程那么多,而进程调度算法就是需要确定:有限的CPU资源该如何分配给进程。

进程的调度算法对整个系统的运行状态有深远的影响。好的调度算法至少需要综合考虑下面四个因素:

  • 快速的进程响应时间,这对于终端系统(例如手机)尤其重要。
  • 后台作业能有较大的吞吐量。
  • 避免进程饥饿。饥饿(starvation)是指进程一直处于等待状态,永远得不到执行的机会。
  • 能够很好的协调高优先级和低优先级进程。

Linux的进程调度是基于时间片(timeslice)的技术,即:将CPU时间划分成一个个小段,然后将这些小段分配给进程。如果某个进程的时间片用完,则强制切换到另外一个进程。

这称之为抢占式(Preemption)多任务。

Linux内核在将CPU时间片分配给进程之前会考虑进程的优先级。并且,每个进程的优先级是动态计算出来的。

需要注意的是,CPU时间片的取值既不能过大,也不能过小。时间片过大会导致每个进程的等待时间过长,整个系统的响应速度变慢。而时间片过小,会导致大部分的时间都消耗在进程的上下文切换上,无效耗费的时间太多。

在讨论进程调度的时候,常常会对进程做一些分类。通常的分类有两种方式。

方式一将进程分为:

  • I/O密集型进程:这类进程有大量时间都在等待输入输出,例如:接受用户的输入事件,或者进行文件IO。
  • CPU密集型进程:这类进程大部分时间都在利用CPU执行计算任务。

方式二将进程分为:

  • 交互式进程:长时间与用户进行交互,例如应用程序的界面部分。
  • 批处理进程:这类进程不与用户交互,一直在后台执行任务。例如编译器,数据库引擎等。
  • 实时进程:这类进程有非常高的实时要求,这通常是控制硬件相关的进程。

实时任务指的是:必须保证任务的完成在规定的时间范围内。但实际上,Linux本身并非真正的实时操作系统,而仅仅是一个软实时(soft real-time)的系统。它只是尽可能的保证任务的完成时间,但使用者如果将其用在可能导致致命的系统上(例如:自动驾驶)就可能会出现问题。

Linux调度器

目前的Linux内核中,是一套调度框架中包含了几个调度器来共同完全调度任务。

每个调度器都有一个优先级,调度框架根据优先级来选择调度器,然后再由调度器来选择进程。

调度器的实现位于 /kernel/sched 目录下。这其中几个核心文件说明如下:

文件 说明 调度策略
core.c 调度框架核心逻辑 -
fair.c CFS实现 SCHED_NORMAL
rt.c 实时调度器 SCHED_FIFO,SCHED_RR
deadline.c Deadline调度器 SCHED_DEADLINE

CFS全称是Completely Fair Scheduler。这是普通进程的默认调度器,也是整个调度框架的核心。

这里的调度策略会在下文中讲解。

Linux调度器的发展历史

Linux内核经过了近30年的开发,其中的进程调度器自然也经历了很多次改进。

A complete guide to Linux process scheduling》这篇论文中详细描述了每个版本的调度器实现,因此这里仅仅做一个简要的对比。

调度器名称 版本 时间 介绍
初始版本 0.01 1991年 只有一个进程队列,每次调度遍历以选择执行的进程
$O(n)$调度器 2.4 2001年 与初始版本类似,但引入了goodness来描述进程优先级
$O(1)$调度器 2.6.8.1 2004年 基于全局的优先队列完成调度选择,无需遍历所有进程
实时调度器 2.6.21 2007年 实现了SCHED_FIFO,SCHED_RR两个策略。
CFS 2.6.23 2007年 基于红黑树数据结构,实现“公平”调度。
Deadline调度器 3.14 2014年 实时调度器,实现了SCHED_DEADLINE策略。

除了上面这些调度器,Con Kolivas 开发的Staircase调度器RSDL调度器,以及BFS调度器也值得了解。事实上,他的算法直接影响了CFS调度器。

Linux上的进程与线程

进程是运行中的程序。内核需要记录和管理进程运行中的相关信息,包括:地址空间,内存映射,打开的文件,进程状态以及其中包含的线程等。

在Linux中,线程又称做轻量级进程。线程包含了一个执行的上下文。一个进程中可以包含一个或多个线程,每个线程有自己的线程id(tid),程序计数器,程序栈以及寄存器。同一个进程中的多个线程共享进程的地址空间,这使得线程之间共享数据非常方便。

在Linux中,通常通过下面的方式创建进程:

clone(SIGCHLD, 0);

SIGCHLD意味着当子进程退出时,需要发送这个信号给父进程。

而创建线程的方法如下:

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

可以看出,这与创建进程非常的相似,区别在于:这里同时复制了当前进程的地址空间(CLONE_VM),文件系统(CLONE_FS),打开文件(CLONE_FILES)以及信号量处理器(CLONE_SIGHAND)。

系统调用

Linux内核提供了以下的系统调用让程序来参与系统的调度策略:

系统调度 说明
nice 设置当前线程的nice值,下文会详细讲解nice值
getpriority 查询某个线程,或者进程组的nice值
setpriority 设置线程或者进程组的nice值
sched_setscheduler 设置指定线程的调度策略和参数
sched_getscheduler 查询指定线程的调度策略和参数
sched_setparam 设置指定线程的调度参数
sched_getparam 查询指定线程的调度参数
sched_get_priority_max 查询特定调度策略的最大优先级值
sched_get_priority_min 查询特定调度策略的最小优先级值
sched_rr_get_interval 查询round-robin策略下线程的定量
sched_yield 使得当前线程让出CPU给其他线程使用
sched_setaffinity 设置指定线程的CPU掩码
sched_getaffinity 查询指定线程的CPU掩码
sched_setattr 设置指定线程的调度策略和调度参数
sched_getattr 查询指定线程的调度策略和调度参数

通过nicesetpriority,和sched_setattr三个系统调度可以设置进程/线程的nice值。

nice值指的是自身相对于其他人的“友好”程度,因此:nice值越大,优先级越低。反之则反。

在POSIX标准中,nice值是一个进程相关的值,因此进程中所有线程的nice值是一样的。但在Linux中,nice值是一个线程相关的值,因此同一个进程的不同线程可以设置不同的nice值。

Linux上,nice值的范围是$[-20, 19]$。其中 -20 是最高优先级,19 是最低优先级。

特权与资源限制

上面这些系统调用可能会影响整个系统的调度情况,因此为了保证系统的稳定,并非所有进程都能随意进行设置。对于这些系统调用的限制如下:

在Linux 2.6.12之前的版本上,只有特权线程可以设置非0的静态优先级(使用实时调度策略)。非特权线程只允许设置使用SCHED_NORMAL策略,并且这个改动还要求调用者的Effective user ID和目标线程的Real user ID或者Effective user ID一致。

只有具有CAP_SYS_NICE特权的线程允许设置或者更改SCHED_DEADLINE策略。

从Linux 2.6.12开始,RLIMIT_RTPRIO定义了非特权线程对于SCHED_RRSCHED_FIFO静态优先级设置的上限。相关规则如下:

  • 如果非特权线程具有非零的RLIMIT_RTPRIO软限制,则它可以更改其调度策略和优先级,但其优先级不能设置为超过当前优先级的最大值以及其RLIMIT_RTPRIO限制。

  • 如果RLIMIT_RTPRIO软限制为0,则只允许降低优先级,或切换到非实时策略。

  • 对于修改其他线程的线程来说,遵循相同的策略。并且需要调用者的Effective user ID和目标线程的Real user ID或者Effective user ID一致。

  • SCHED_IDLE使用不一样的策略。在Linux 2.6.39之前,一个使用此策略的非特权线程始终无法更改其调度策略,无论其RLIMIT_RTPRIO值是什么。在Linux 2.6.39之后的版本中,非特权的线程可以切换到SCHED_BATCHSCHED_NORMAL策略,只要其值在RLIMIT_NICE所允许的范围即可。

另外,特权(CAP_SYS_NICE)线程不受RLIMIT_RTPRIO限制;

内核中的数据结构

与调度相关的核心结构和常量定义在下面两个头文件中:

其中最重要的就是描述进程的数据结构task_struct

这个结构体非常的大,里面包含了非常多的用来描述进程的字段。这里我们只关心与进程调度相关的内容,它们如下所示:

struct task_struct {
  int prio;
  int static_prio;
  int normal_prio;
  unsigned int rt_priority;

  const struct sched_class *sched_class;
  struct sched_entity se;
  struct sched_rt_entity rt;
  ...

  unsigned int policy;
  cpumask_t cpus_allowed;
  ...
  pid_t pid;
}

这些字段说明如下:

  • priostatic_prionormal_prio:描述了进程的优先级,它们之间存在互相的关联关系。
  • rt_priority:实时进程使用,描述进程的实时优先级。
  • sched_class:进程所属的调度器类。
  • se:调度的实体,既可能是一个线程,也可以是一组进程。
  • policy:所使用的调度策略,见下文。
  • cpus_allowed:允许运行的CPU。可以通过sched_setaffinity来设置。
  • pid:进程的id。

调度策略

在目前的Linux内核中,一共有六种调度策略,它们可以分为三类:

  • 普通调度策略:SCHED_NORMAL, SCHED_BATCHSCHED_IDLE
  • 实时调度策略:SCHED_FIFO, SCHED_RR
  • Deadline调度策略:SCHED_DEADLINE

六种调度策略说明如下:

  • SCHED_NORMAL:也叫SCHED_OTHER。这是进程的默认调度策略,也就是时间共享策略。绝大部分进程都使用这个调度策略。
  • SCHED_BATCH:与SCHED_NORMAL类似。不同的是,内核会认为该进程是CPU密集型,因此在调度会有小的惩罚。这种策略适用于那些非交互的后台进程。
  • SCHED_IDLE:最低优先级的调度策略,nice值不被考虑。因此它的调度将低于SCHED_NORMALSCHED_BATCH
  • SCHED_FIFO:FIFO全称是First in-first out。这种策略不会使用时间片算法,在同优先级的情况下,会按照先进先出的方法按顺序执行。作为一种实时调度策略,属于该调度策略的进程会一直执行直到被IO阻塞或者被更高优先级的进程抢占。
  • SCHED_RR:RR的全称是Round-robin。这是对于SCHED_FIFO增强的实时策略,它使用了时间片共享的方式来调度进程。
  • SCHED_DEADLINE:指定了预计完成时间的调度策略,它拥有超过所有其他策略的最高优先级。

实时调度策略(SCHED_FIFOSCHED_RR)的优先级始终高于普通调度策略(SCHED_NORMALSCHED_BATCHSCHED_IDLE),整体来说,六种调度策略的优先级排列如下:

SCHED_DEADLINE > SCHED_FIFO = SCHED_RR > SCHED_NORMAL > SCHED_BATCH > SCHED_IDLE

进程优先级

include/linux/sched/prio.h 文件中,定义了一系列宏用来描述进程的优先级:

#define MAX_NICE             19
#define MIN_NICE             -20
#define NICE_WIDTH           (MAX_NICE - MIN_NICE + 1)

#define MAX_USER_RT_PRIO     100
#define MAX_RT_PRIO          MAX_USER_RT_PRIO
#define MAX_PRIO             (MAX_RT_PRIO + NICE_WIDTH)
#define DEFAULT_PRIO         (MAX_RT_PRIO + NICE_WIDTH / 2)

#define NICE_TO_PRIO(nice)   ((nice) + DEFAULT_PRIO)
#define PRIO_TO_NICE(prio)   ((prio) - DEFAULT_PRIO)

#define USER_PRIO(p)         ((p)-MAX_RT_PRIO)
#define TASK_USER_PRIO(p)    USER_PRIO((p)->static_prio)
#define MAX_USER_PRIO        (USER_PRIO(MAX_PRIO))

整体来说,Linux中进程的优先级范围是:[0, 139]。值越小,优先级越高

其中,实时进程占用了[0, 99]的范围。普通进程占用了[100, 139]的范围。nice值最终会映射到 [100, 139]的范围内。

图示如下:

查看进程的调度信息

通过ps命令,我们可以查看进程的调度信息。

不过在不同的系统上,该命令的参数不一样。

在Linux系统上,可以通过下面这条命令查看:

ps ax -o uname,pid,cls,pri,rtprio,cmd

-o指定了输出的列。列名说明如下:

  • uname: 进程所属的用户名称。
  • pid: 进程id。
  • cls:进程的调度策略。TS表示SCHED_OTHERFF表示SCHED_FIFORR表示SCHED_RRB表示SCHED_BATCHIDL表示SCHED_IDLE
  • pri:进程的优先级。
  • rtprio:进程的实时优先级。
  • cmd:进程的可执行文件名称。

如果是希望查看所有线程,可以使用这样的参数

ps ax -L -o uname,pid,tid,cls,pri,rtprio,cmd,comm

tid即线程id。

在Android系统上,可以通过下面这条命令查看相关信息:

ps -A -o PID,TID,SCHED,PRI,RTPRIO,NICE,PCY,NAME,CMD

这里的列名说明如下:

  • PID:进程id。
  • TID:线程id。
  • SCHED:进程/线程的调度器:0=other, 1=fifo, 2=rr, 3=batch, 4=iso, 5=idle。
  • PRI:进程/线程的优先级。值越大,优先级越高。
  • RTPRIO:进程/线程的实时优先级。
  • NICE:进程/线程的nice值。
  • PCY:进程/线程的Android调度策略,可能是fg(前台)或者bg(后台)。
  • NAME:进程名称。
  • CMD:线程名称。

下面是一个输出样例:

 PID   TID  SCH PRI RTPRIO  NI PCY NAME                        CMD
  ...                       
 1095  1095   0  21      -  -2  fg system_server               system_server
 1095  1102   0  39      - -20  fg system_server               Jit thread pool
 1095  1103   0  39      - -20  fg system_server               Runtime worker 
 1095  1104   0  39      - -20  fg system_server               Runtime worker 
 1095  1105   0  39      - -20  fg system_server               Runtime worker 
 1095  1106   0  39      - -20  fg system_server               Runtime worker 
 1095  1107   0  39      - -20  fg system_server               Signal Catcher
 1095  1108   0  15      -   4  fg system_server               HeapTaskDaemon
 1095  1109   0  15      -   4  fg system_server               ReferenceQueueD
 1095  1110   0  15      -   4  fg system_server               FinalizerDaemon
 1095  1111   0  15      -   4  fg system_server               FinalizerWatchd
 1095  1112   0  19      -   0  fg system_server               Binder:1095_1
 1095  1113   0  19      -   0  fg system_server               Binder:1095_2
 1095  1143   0  19      -   0  fg system_server               android.fg
 1095  1144   0  21      -  -2  ta system_server               android.ui
 1095  1145   0  19      -   0  fg system_server               android.io
 ...

cgroup

cgroup是control group的缩写。这是一个Linux内核的特性。用来对进程所使用的资源(如CPU、内存、磁盘输入输出等)进行限制、统计与隔离。

cgroup 单数形式用于指定整个特性,也用作“cgroup控制器”中的限定符。 当明确指代多个单独的控制组时,使用复数形式“cgroups”。

这个项目最早是由Google的工程师(主要是Paul Menage和Rohit Seth)在2006年发起。该功能于2008年(Android的发布也是在这一年)合入到Linux 2.6.24版本中。这是cgroup的第一个版本。

后来cgroup由Tejun Heo维护。他重新设计并重写了cgroup,第二个版本的cgroup在2016年Linux 4.5版本中发布。

实现 cgroup 的主要目的是为不同用户层面的资源管理提供一个统一的接口。从单个任务的资源控制到操作系统层面的虚拟化,cgroup 提供了四大功能:

  • 资源限制:cgroup 可以对任务是要的资源总额进行限制。比如设定任务运行时使用的内存上限,一旦超出就发 OOM。
  • 优先级分配:通过分配的 CPU 时间片数量和磁盘 IO 带宽,实际上就等同于控制了任务运行的优先级。
  • 资源统计:cgoup 可以统计系统的资源使用量,比如 CPU 使用时长、内存用量等。这个功能非常适合当前云端产品按使用量计费的方式。
  • 任务控制:cgroup 可以对任务执行挂起、恢复等操作。

cgroup主要由两个部分组成:

  • 核心部分:主要负责按层级组织管理进程。
  • 控制器:cgroup包含了多个控制器,一个控制器负责一类特定系统资源的管理。控制器也可以称之为子系统。例如 CPU 子系统可以控制 CPU 的时间分配,内存子系统可以限制内存的使用量。

cgroup以树型结构进行组织,系统中的每个进程都属于且只属于一个cgroup。创建时,所有进程都放在父进程所属的cgroup中。 进程可以迁移到另一个cgroup。 迁移进程不会影响后代进程。

在一些结构性约束下,可以在cgroup上选择性地启用或禁用某个控制器。 所有控制器行为都是分层的 - 如果在某个cgroup上启用了控制器,则它会影响属于该cgroup子层次的所有进程。 在嵌套的cgroup上启用控制器时,它始终会进一步限制资源使用。

Version 1

cgroup第一版本的官方文档见这里:cgroup-v1

cgroup的第一个版本目前包含了下面13个子系统:

名称 起始Linux版本 说明
cpu 2.6.24 限制 CPU 时间片的分配,与 cpuacct 挂载在同一目录。
cpuacct 2.6.24 生成 cgroup 中的任务占用 CPU 资源的报告,与 cpu 挂载在同一目录。
cpuset 2.6.24 给 cgroup 中的任务分配独立的 CPU(多处理器系统)和内存节点。
memory 2.6.25 对 cgroup 中的任务的可用内存进行限制,并自动生成资源占用报告。
devices 2.6.26 允许或禁止 cgroup 中的任务访问设备。
freezer 2.6.28 暂停/恢复 cgroup 中的任务。
net_cls 2.6.29 对cgroup中的任务进行网络限制。
blkio 2.6.33 对块设备的 IO 进行限制。
perf_event 2.6.39 允许使用 perf 工具来监控 cgroup。
net_prio 3.3 允许基于 cgroup 设置网络流量(netowork traffic)的优先级。
hugetlb 3.5 制使用的内存页数量。
pids 4.3 限制任务的数量。
rdma 4.11 限制RDMA/IB相关资源

使用方法

可以通过下面这条命令来挂载 cgroup 系统并使能所有子系统:

mount -t cgroup xxx /sys/fs/cgroup

cgroup不会处理这里的”xxx”,但它会出现在/proc/mounts文件中以便辨识。

如果只想使用部分子系统,可以通过下面的方法:

mount -t tmpfs cgroup_root /sys/fs/cgroup
mkdir /sys/fs/cgroup/rg1
mount -t cgroup -o cpuset,memory hier1 /sys/fs/cgroup/rg1

对于每一个子系统都会包含一个cgroup.procs文件和一个tasks文件。前者记录了属于该子系统的进程id,而后者是线程id

对于cgroup的使用就是将进程或线程的id写入到这个文件中。

/bin/echo PID_n > cgroup.procs

需要注意的是,这里一次只能写入一个pid,不能写入多个。

示例

以启用cgroup,并且使用cpuset子系统为例,其操作步骤如下:

  1. mount -t tmpfs cgroup_root /sys/fs/cgroup 挂在cgroup根系统
  2. mkdir /sys/fs/cgroup/cpuset 创建cpuset子系统目录
  3. mount -t cgroup -o cpuset cpuset /sys/fs/cgroup/cpuset 挂载cpuset子系统
  4. 启动任务的根进程
  5. echo the_pid > /sys/fs/cgroup/cpuset/tasks 将任务根进程的pid写入子系统中
  6. 由根进程执行任务或者fork子进程来执行任务

可以通过下面这条命令查看系统中cgroup的挂载情况:

cat /proc/mounts  | grep cgroup

对于某个具体的进程,可以根据pid查看其proc文件系统下的文件以确认其cgroup的使用情况:

cat /proc/[pid]/cgroup

下面是一个示例:

$ cat /proc/2924/cgroup                                                                                                                                                                               
4:cpuset:/restricted
3:cpu:/
2:schedtune:/top-app
1:cpuacct:/uid_10009/pid_2924

这里是Android上的一个进程,对于具体的配置在下文中会讲解。

Version 2

cgroup第二版本的官方文档见这里:cgroup-v2

随着时间的推移,cgroup添加了各种控制器。然而这些控制器的开发在很大程度上是不协调的,其结果是控制器之间出现了许多不一致,导致cgroup层次结构的管理变得相当复杂。

于是就出现了第二个版本。虽然说v2是用来替代v1的。但是旧的系统仍然存在,出于兼容性的考虑,也不太可能被移除。当前,v2版本只实现了v1的部分控制器。并且v1和v2的控制器可以同时在一个系统上使用。例如:可以使用那些v2支持的控制器,同时也使用v2尚不支持的v1控制器。不过需要注意的是:同一个控制器不能同时用于v1层次结构和v2层次结构中。

v2与v1的差异点包括下面这些:

  1. v2为所有挂载的控制器提供了一个统一的层次结构。
  2. 不允许出现”内部”进程。除了根cgroup以外,所有进程只允许出现在叶子节点上。
  3. 必须通过cgroup.controllerscgroup.subtree_control文件激活cgroup。
  4. tasks被移除。cpuset控制器使用的cgroup.clone_children文件也被移除了。
  5. 改进的空cgroup通知通过cgroup.events文件提供。

cgroup的v2包含的控制器:

名称 起始Linux版本 说明
io 4.5 v1 blkio 控制器的后继版本
memory 4.5 v1 memory 控制器的后继版本
pids 4.5 和v1 pids 控制器一样
perf_event 4.11 和v1 perf_event 控制器一样
rdma 4.11 和v1 rdma控制器一样
cpu 4.15 v1 cpu和cpuacct控制器的后继版本

systemd 与 cgroup

systemd 是Linux上新的初始化系统。

当前绝大多数的Linux发行版都已采用systemd,包括下面这些:

  • Fedora 15及后续版本
  • Mageia 2
  • Mandriva 2011
  • openSUSE 12.1 及后续版本
  • Red Hat Enterprise Linux 7及后续版本,包括其派生品CentOS、Scientific Linux、Oracle Linux等
  • Chakra GNU/Linux,在2012.10的光盘映像档发布后默认使用systemd
  • Debian GNU/Linux,在2014年的技术委员会的init系统投票中决定在Debian 8“Jessie”中以Linux为核心的版本转换到systemd
  • Ubuntu 15.04及后续版本

systemd中包含了对于cgroup的支持。在系统的开机阶段,systemd 会把支持的 控制器(subsystem 子系统)挂载到默认的 /sys/fs/cgroup/ 目录下面。

因此如果你使用了systemd,你不需要自己初始化cgroup了。

以Ubuntu 18.04为例,这个系统上的cgroup启用情况如下:

tmpfs /sys/fs/cgroup tmpfs ro,nosuid,nodev,noexec,mode=755 0 0
cgroup /sys/fs/cgroup/unified cgroup2 rw,nosuid,nodev,noexec,relatime,nsdelegate 0 0
cgroup /sys/fs/cgroup/systemd cgroup rw,nosuid,nodev,noexec,relatime,xattr,name=systemd 0 0
cgroup /sys/fs/cgroup/net_cls,net_prio cgroup rw,nosuid,nodev,noexec,relatime,net_cls,net_prio 0 0
cgroup /sys/fs/cgroup/pids cgroup rw,nosuid,nodev,noexec,relatime,pids 0 0
cgroup /sys/fs/cgroup/cpuset cgroup rw,nosuid,nodev,noexec,relatime,cpuset 0 0
cgroup /sys/fs/cgroup/cpu,cpuacct cgroup rw,nosuid,nodev,noexec,relatime,cpu,cpuacct 0 0
cgroup /sys/fs/cgroup/perf_event cgroup rw,nosuid,nodev,noexec,relatime,perf_event 0 0
cgroup /sys/fs/cgroup/hugetlb cgroup rw,nosuid,nodev,noexec,relatime,hugetlb 0 0
cgroup /sys/fs/cgroup/rdma cgroup rw,nosuid,nodev,noexec,relatime,rdma 0 0
cgroup /sys/fs/cgroup/memory cgroup rw,nosuid,nodev,noexec,relatime,memory 0 0
cgroup /sys/fs/cgroup/devices cgroup rw,nosuid,nodev,noexec,relatime,devices 0 0
cgroup /sys/fs/cgroup/blkio cgroup rw,nosuid,nodev,noexec,relatime,blkio 0 0
cgroup /sys/fs/cgroup/freezer cgroup rw,nosuid,nodev,noexec,relatime,freezer 0 0

针对systemd和cgroup,有以下两个命令会很有用:

  • systemd-cgls :以树状结构显示cgroup的详细状况。
  • systemd-cgtop:按资源使用情况显示top控制组。

很自然,systemd中提供了配置项用来设置cgroup资源,相关内容可以参阅这里:systemd.resource-control

Android的进程调度

Android是基于Linux内核的操作系统,因此其进程调度自然会使用上面提到的这些机制。

下面我们结合具体的设备和源码来分析一下。

了解状况

以下内容以我手上的 Pixel XL 手机为例来进行分析。

只要是原生Android的系统,其基本机制就是一致的,不同的仅仅是参数的配置不一样而已。因此如果你拥有的是其他设备,也可以用同样的方法来分析。

当然你可以对比一下你设备的参数与Pixel XL有什么不同,以及为什么那样配置。想要更深入理解一项技术,我们不仅需要知道是什么,更需要知道为什么。

以下这些信息涉及系统最底层的配置,出于安全的考虑,系统有些信息是不允许普通用户查看的。因此,想要进行这些分析,你可能需要先 root 你的设备。

我手上这台设备的版本信息如下图所示:

首先我们通过下面的命令来确认其使用的cgroup情况:

marlin:/ $ cat /proc/mounts  | grep cgroup                                                                                                                       
none /acct cgroup rw,nosuid,nodev,noexec,relatime,cpuacct 0 0
none /dev/stune cgroup rw,nosuid,nodev,noexec,relatime,schedtune 0 0
none /dev/cpuctl cgroup rw,nosuid,nodev,noexec,relatime,cpu 0 0
none /dev/cpuset cgroup rw,nosuid,nodev,noexec,relatime,cpuset,noprefix,release_agent=/sbin/cpuset_release_agent 0 0

从这个输出可以看出,这个设备的系统上:

  • 使用的是cgroup v1版本。
  • 使用了cpuacctschedtunecpucpuset四个控制器。它们mount的地址分别是:/acct/dev/stune/dev/cpuctl/dev/cpuset

这里的schedtune我们下面会提到,其他三个控制器前面已经都提到过。

除了上面这个命令,还可以通过cat /proc/cgroups来了解cgroup的情况。

marlin:/ # cat /proc/cgroups                                                                                                                                                                                                  
#subsys_name  hierarchy num_cgroups enabled
cpuset        4         14          1
cpu           3         1           1
cpuacct       1         249         1
schedtune     2         5           1
freezer       0         1           1
debug         0         1           1

对于这个文件的解释请参考这里:$cgroups(7)$

初始化cgroup

我们已经知道,在Android系统上,init进程负责了整个系统的启动逻辑。很自然的,init进程就要负责cgroup的初始化工作。

在根目录下的 /init.rc 文件包含了这部分配置:

如果你不理解这里的内容,请熟悉一下 Android Init Language,或者阅读我之前写过的文章:Android系统启动:init进程与init语言

on early-init
    # Mount cgroup mount point for cpu accounting
    mount cgroup none /acct nodev noexec nosuid cpuacct
    mkdir /acct/uid

    # root memory control cgroup, used by lmkd
    mkdir /dev/memcg 0700 root system
    mount cgroup none /dev/memcg nodev noexec nosuid memory
    # app mem cgroups, used by activity manager, lmkd and zygote
    mkdir /dev/memcg/apps/ 0755 system system
    # cgroup for system_server and surfaceflinger
    mkdir /dev/memcg/system 0550 system system
    ...
on init
    # Create energy-aware scheduler tuning nodes
    mkdir /dev/stune
    mount cgroup none /dev/stune nodev noexec nosuid schedtune
    mkdir /dev/stune/foreground
    mkdir /dev/stune/background
    mkdir /dev/stune/top-app
    mkdir /dev/stune/rt
    ...
    # Create cgroup mount points for process groups
    mkdir /dev/cpuctl
    mount cgroup none /dev/cpuctl nodev noexec nosuid cpu
    chown system system /dev/cpuctl
    chown system system /dev/cpuctl/tasks
    chmod 0666 /dev/cpuctl/tasks
    write /dev/cpuctl/cpu.rt_period_us 1000000
    write /dev/cpuctl/cpu.rt_runtime_us 950000

    # sets up initial cpusets for ActivityManager
    mkdir /dev/cpuset
    mount cpuset none /dev/cpuset nodev noexec nosuid
    
    # this ensures that the cpusets are present and usable, but the device's
    # init.rc must actually set the correct cpus
    mkdir /dev/cpuset/foreground
    copy /dev/cpuset/cpus /dev/cpuset/foreground/cpus
    copy /dev/cpuset/mems /dev/cpuset/foreground/mems
    mkdir /dev/cpuset/background
    copy /dev/cpuset/cpus /dev/cpuset/background/cpus
    copy /dev/cpuset/mems /dev/cpuset/background/mems

    # system-background is for system tasks that should only run on
    # little cores, not on bigs
    # to be used only by init, so don't change system-bg permissions
    mkdir /dev/cpuset/system-background
    copy /dev/cpuset/cpus /dev/cpuset/system-background/cpus
    copy /dev/cpuset/mems /dev/cpuset/system-background/mems

    # restricted is for system tasks that are being throttled
    # due to screen off.
    mkdir /dev/cpuset/restricted
    copy /dev/cpuset/cpus /dev/cpuset/restricted/cpus
    copy /dev/cpuset/mems /dev/cpuset/restricted/mems

    mkdir /dev/cpuset/top-app
    copy /dev/cpuset/cpus /dev/cpuset/top-app/cpus
    copy /dev/cpuset/mems /dev/cpuset/top-app/mems

这里挂载了cgroup子系统。并创建了一些分组。

libprocessgroup

为了将cgroup的处理逻辑集中管理,AOSP源码中,有一个库 libprocessgroup 专门负责相关工作。

这个库主要包含了以下几个功能:

  • 初始化cgroup (见下文)
  • 进程的CPU调度控制
  • cgroup 配置文件的读写
  • cgroup 配置参数调整

这个库主要被init进程和Process类使用。

CgroupSetup

前面我们已经看到,init.rc 中包含了cgroup的初始化工作。但实际上,libprocessgroup库提供的CgroupSetup接口也完成了相同的工作。

CgroupSetup接口会读取一个叫做cgroups.json的文件,并根据文件中的内容来配置cgroup。但在我的Pixel XL手机上,并没有cgroups.json这个文件,因此这里的初始化工作应该是没有使用到的。笔者觉得这可能是还在开发过程中,即:目前怀疑Android系统维护者计划将cgroup的初始化工作移到libprocessgroup中来完成,以减少init.rc中的配置。(至于这个猜想是否准确,等今后的版本更新就知道了。)

出于好奇心,我们可以大致来看一下这部分内容。

CgroupSetup函数中最主要的逻辑如下:

// load cgroups.json file
if (!ReadDescriptors(&descriptors)) {
    LOG(ERROR) << "Failed to load cgroup description file";
    return false;
}

// setup cgroups
for (auto& [name, descriptor] : descriptors) {
    if (SetupCgroup(descriptor)) {
        descriptor.set_mounted(true);
    } else {
        // issue a warning and proceed with the next cgroup
        LOG(WARNING) << "Failed to setup " << name << " cgroup";
    }
}

// mkdir <CGROUPS_RC_DIR> 0711 system system
if (!Mkdir(android::base::Dirname(CGROUPS_RC_PATH), 0711, "system", "system")) {
    LOG(ERROR) << "Failed to create directory for " << CGROUPS_RC_PATH << " file";
    return false;
}

这段代码应该很容易理解,就是读取配置文件,然后根据配置文件逐个设置每一个group。

AOSP中包含的cgroups.json文件内容如下:

{
  "Cgroups": [
    {
      "Controller": "blkio",
      "Path": "/dev/blkio",
      "Mode": "0755",
      "UID": "system",
      "GID": "system"
    },
    {
      "Controller": "cpu",
      "Path": "/dev/cpuctl",
      "Mode": "0755",
      "UID": "system",
      "GID": "system"
    },
    {
      "Controller": "cpuacct",
      "Path": "/acct",
      "Mode": "0555"
    },
    {
      "Controller": "cpuset",
      "Path": "/dev/cpuset",
      "Mode": "0755",
      "UID": "system",
      "GID": "system"
    },
    {
      "Controller": "memory",
      "Path": "/dev/memcg",
      "Mode": "0700",
      "UID": "root",
      "GID": "system"
    },
    {
      "Controller": "schedtune",
      "Path": "/dev/stune",
      "Mode": "0755",
      "UID": "system",
      "GID": "system"
    }
  ],
  "Cgroups2": {
    "Path": "/dev/cg2_bpf",
    "Mode": "0600",
    "UID": "root",
    "GID": "root"
  }
}

很显然,这个文件既支持cgroup v1的配置,也支持cgroup v2的配置。这为今后cgroup的版本切换做好了准备。

CpuSets

cgroup的cpusets文档参见这里:ocumentation/cgroup-v1/cpusets.txt

在多CPU或者多核CPU的情况下,cpusets限制了进程使用的CPU范围。如果你仔细看了前面 /init.rc 中的配置,你就会发现,那里对cpuset做了一些具体的分组,包括:

  • foreground
  • background
  • top-app
  • system-background
  • restricted

很明显的,这里是在对进程的类型做分类。有了这个分类的基础框架,其他地方就可以将进程放入对应的分类组中,这样就达到的资源合理分配和限制的目的。而这也正是使用cgroup的原因。

不过,/init.rc 是为整个AOSP项目使用的。这里面自然不能包含针对某个具体设备的配置。所以上面这个配置只是创建了这些cgroup,并没有进行设置。而完成这个具体参数设置工作的任务就要让具体的设备厂商来完成,这就是 /vendor/etc/init/init.rc 这个文件的任务了。

我的Pixel XL设备上/vendor/etc/init/init.rc这个文件中的相关内容如下:

on property:sys.boot_completed=1
    ...

    # update cpusets now that boot is complete and we want better load balancing
    write /dev/cpuset/top-app/cpus 0-3
    write /dev/cpuset/foreground/cpus 0-2
    write /dev/cpuset/background/cpus 0
    write /dev/cpuset/system-background/cpus 0-2
    write /dev/cpuset/restricted/cpus 0-1

当然,如果你的设备或者版本和我不一样,这个文件的内容也会不一样。

Pixel XL的CPU是4核的。通过这个配置可以看到,top-app进程可以使用所有的CPU。但是background进程只能使用CPU0。其他类型的进程也有一定的限制。

如果你完整的查看了/vendor/etc/init/init.rc这个文件你就会发现,这个文件中对有cputsets的设置远不止这一处。一方面,在init的启动过程中,包含了好几个阶段,在不同的阶段对于CPU的限制是不一样的。另一方面,除了上面这些分组,系统内部还有一些其他分组,它们也被设置了cpusets。

在Android Framework中,AcitivtyManagerService会根据进程的状态来设置进程组。在Java中,ProcessList类中以下几个常量描述了进程组:

static final int SCHED_GROUP_BACKGROUND = 0;
static final int SCHED_GROUP_RESTRICTED = 1;
static final int SCHED_GROUP_DEFAULT = 2;
static final int SCHED_GROUP_TOP_APP = 3;
static final int SCHED_GROUP_TOP_APP_BOUND = 4;

除了这个类以外,android.os.Process(这个类下文还会提到)类中也包含了类似的常量。它们之间有一定的对应关系,会在不同的场景下使用。

public static final int THREAD_GROUP_BG_NONINTERACTIVE = 0;
private static final int THREAD_GROUP_FOREGROUND = 1;
public static final int THREAD_GROUP_SYSTEM = 2;
public static final int THREAD_GROUP_AUDIO_APP = 3;
public static final int THREAD_GROUP_AUDIO_SYS = 4;
public static final int THREAD_GROUP_TOP_APP = 5;
public static final int THREAD_GROUP_RT_APP = 6;
public static final int THREAD_GROUP_RESTRICTED = 7;

SchedTune

SchedTune是一项与CPU调频相关的性能提升技术,它实现为一个cgroup控制器。

这个控制器提供了一个名称为schedtune.boost的配置参数,运行时系统可以使用它来更改该组中的进程的调度方式。

每当调整这个参数的时候,它会使受影响的进程看起来比实际更重(或更轻)。如果一个组被提升了25%,那么调度程序将期望它使用的CPU时间比它实际上要多25%,并且CPU频率调控器将相应地对处理器提速。因此,以这种方式“提升”进程不会影响其调度优先级,但会影响其最终运行的CPU的速度。

SchedTune扩展仅适用于负载较轻的系统。当系统饱和时,SchedTune应当自动禁用。

Pixel XL上/vendor/etc/init/init.rc文件中的相关配置如下:

# set default schedTune value for foreground/top-app (only affects EAS)
write /dev/stune/foreground/schedtune.prefer_idle 1
write /dev/stune/top-app/schedtune.boost 10
write /dev/stune/top-app/schedtune.prefer_idle 1
write /dev/stune/rt/schedtune.boost 30
write /dev/stune/rt/schedtune.prefer_idle 1

可以看到,这里为rttop-app两个进程组设置了处理器提速。

schedtune.prefer_idle是一个标志位,它向调度器指示用户空间希望调度器更关注功耗或者更关注性能。当这个值设为1,表示希望调度器尽可能减少改组中进程唤醒延迟(倾向于性能)。

对SchedTune感兴趣的读者可以以下面的链接为起点继续探索:

Android系统服务

Android系统中包含了很多的系统服务,这些服务的进程通常是常驻的,对于这些服务进程的调度自然也需要进行管理,而这个管理工作主要由相应的init配置文件来完成。

因为这些进程都是由init进程启动,并且在启动的时候就会将自身放入到对应的进程组中。由于这些系统服务并不像应用进程那样有明显的状态变化,所以通常它们不会频繁的从一个进程组移动到另外一个进程组。

可以通过下面这条命令搜索相关内容,然后对你感兴趣的服务进程查看其配置:

AOSP$ grep -rIn "writepid" system
...
system/vold/vold.rc:6:    writepid /dev/cpuset/foreground/tasks
system/core/logd/logd.rc:11:    writepid /dev/cpuset/system-background/tasks
system/core/logd/logd.rc:18:    writepid /dev/cpuset/system-background/tasks
system/core/debuggerd/tombstoned/tombstoned.rc:11:    writepid /dev/cpuset/system-background/tasks
...
system/core/rootdir/init.zygote32_64.rc:15:    writepid /dev/cpuset/foreground/tasks
system/core/rootdir/init.zygote32_64.rc:25:    writepid /dev/cpuset/foreground/tasks
system/core/rootdir/init.zygote64_32.rc:15:    writepid /dev/cpuset/foreground/tasks
system/core/rootdir/init.zygote64_32.rc:25:    writepid /dev/cpuset/foreground/tasks
system/core/rootdir/init.zygote64.rc:15:    writepid /dev/cpuset/foreground/tasks
system/core/rootdir/init.zygote32.rc:15:    writepid /dev/cpuset/foreground/tasks
system/core/lmkd/lmkd.rc:8:    writepid /dev/cpuset/system-background/tasks
system/core/logcat/logcatd.rc:76:    writepid /dev/cpuset/system-background/tasks
system/core/storaged/storaged.rc:6:    writepid /dev/cpuset/system-background/tasks
system/core/llkd/llkd.rc:45:    writepid /dev/cpuset/system-background/tasks
system/core/llkd/llkd-debuggable.rc:19:    writepid /dev/cpuset/system-background/tasks
system/core/gatekeeperd/gatekeeperd.rc:4:    writepid /dev/cpuset/system-background/tasks
system/security/keystore/keystore.rc:5:    writepid /dev/cpuset/foreground/tasks
system/update_engine/update_engine.rc:5:    writepid /dev/cpuset/system-background/tasks
system/hwservicemanager/hwservicemanager.rc:10:    writepid /dev/cpuset/system-background/tasks
system/iorap/iorapd.rc:19:    writepid /dev/cpuset/system-background/tasks
system/extras/cppreopts/cppreopts.rc:21:    writepid /dev/cpuset/foreground/tasks
system/extras/perfprofd/perfprofd.rc:5:    writepid /dev/cpuset/system-background/tasks

Process类

Android SDK中包含了一个类用来描述系统中的进程,那就是 android.os.Process 。这个类虽然应用开发者也能访问的,但其中很多接口(包括常量)都通过 @hide 注解对应用开发者屏蔽了,因为这些内容是只能系统服务(主要是ActivityManagerService)使用。

这个类中包含了很多描述进程状态的常量。例如:各种类型的uid,线程优先级等。

也包括进程调度器:

public static final int SCHED_OTHER = 0;
public static final int SCHED_FIFO = 1;
public static final int SCHED_RR = 2;
public static final int SCHED_BATCH = 3;
public static final int SCHED_IDLE = 5;

当然,android.os.Process 中包含了调整进程调度策略的接口,如下:

public static final native void setThreadGroup(int tid, int group)
        throws IllegalArgumentException, SecurityException;
    
public static final native void setThreadGroupAndCpuset(int tid, int group)
        throws IllegalArgumentException, SecurityException;

public static final native void setProcessGroup(int pid, int group)
        throws IllegalArgumentException, SecurityException;

public static final native int getProcessGroup(int pid)
        throws IllegalArgumentException, SecurityException;

public static final native int[] getExclusiveCores();
    
public static final native void setThreadPriority(int priority)
        throws IllegalArgumentException, SecurityException;
    
public static final native int getThreadPriority(int tid)
        throws IllegalArgumentException;
    
public static final native int getThreadScheduler(int tid)
        throws IllegalArgumentException;
    
public static final native void setThreadScheduler(int tid, int policy, int priority)
        throws IllegalArgumentException;

这些接口都是native,因为它们的实现都在C++层,在 frameworks/base/core/jni/android_util_Process.cpp 文件中。

这里的实现就会调用到libprocessgroup中的接口。关于这部分内容就不贴出更多代码了,有兴趣的读者可以自行阅读这部分代码。

ActivityManagerService

ActivityManagerService 负责了所有应用进程的管理。

Android系统中的进程管理:进程的优先级 一文中,我们已经看到 ActivityManagerService 对于应用进程的优先级计算逻辑。

简单来说,ActivityManagerService 会根据进程中四大组件的状态来调整进程的优先级。

applyOomAdjLocked 方法中,会根据计算好的调度组进行调度的设置:

setProcessGroup(app.pid, processGroup);
if (app.curSchedGroup == ProcessList.SCHED_GROUP_TOP_APP) {
    // do nothing if we already switched to RT
    if (oldSchedGroup != ProcessList.SCHED_GROUP_TOP_APP) {
        mVrController.onTopProcChangedLocked(app);
        if (mUseFifoUiScheduling) {
            // Switch UI pipeline for app to SCHED_FIFO
            app.savedPriority = Process.getThreadPriority(app.pid);
            scheduleAsFifoPriority(app.pid, /* suppressLogs */true);
            if (app.renderThreadTid != 0) {
                scheduleAsFifoPriority(app.renderThreadTid,
                    /* suppressLogs */true);
                if (DEBUG_OOM_ADJ) {
                    Slog.d("UI_FIFO", "Set RenderThread (TID " +
                        app.renderThreadTid + ") to FIFO");
                }
            } else {
                if (DEBUG_OOM_ADJ) {
                    Slog.d("UI_FIFO", "Not setting RenderThread TID");
                }
            }
        } else {
            // Boost priority for top app UI and render threads
            setThreadPriority(app.pid, TOP_APP_PRIORITY_BOOST);
            if (app.renderThreadTid != 0) {
                try {
                    setThreadPriority(app.renderThreadTid,
                            TOP_APP_PRIORITY_BOOST);
                } catch (IllegalArgumentException e) {
                    // thread died, ignore
                }
            }
        }
    }
} else if (oldSchedGroup == ProcessList.SCHED_GROUP_TOP_APP &&
           app.curSchedGroup != ProcessList.SCHED_GROUP_TOP_APP) {
    mVrController.onTopProcChangedLocked(app);
    if (mUseFifoUiScheduling) {
        try {
            // Reset UI pipeline to SCHED_OTHER
            setThreadScheduler(app.pid, SCHED_OTHER, 0);
            setThreadPriority(app.pid, app.savedPriority);
            if (app.renderThreadTid != 0) {
                setThreadScheduler(app.renderThreadTid,
                    SCHED_OTHER, 0);
                setThreadPriority(app.renderThreadTid, -4);
            }
        } catch (IllegalArgumentException e) {
            Slog.w(TAG,
                    "Failed to set scheduling policy, thread does not exist:\n"
                            + e);
        } catch (SecurityException e) {
            Slog.w(TAG, "Failed to set scheduling policy, not allowed:\n" + e);
        }
    } else {
        // Reset priority for top app UI and render threads
        setThreadPriority(app.pid, 0);
        if (app.renderThreadTid != 0) {
            setThreadPriority(app.renderThreadTid, 0);
        }
    }
}

这里的调用的几个方法都是前面提到的Process类中,ActivityManagerService 对它们进行了静态导入:

import static android.os.Process.setProcessGroup;
import static android.os.Process.setThreadPriority;
import static android.os.Process.setThreadScheduler;

setThreadPriority方法为例,其native方法实现如下:

void android_os_Process_setThreadPriority(JNIEnv* env, jobject clazz,
                                              jint pid, jint pri)
{
#if GUARD_THREAD_PRIORITY
    // if we're putting the current thread into the background, check the TLS
    // to make sure this thread isn't guarded.  If it is, raise an exception.
    if (pri >= ANDROID_PRIORITY_BACKGROUND) {
        if (pid == gettid()) {
            void* bgOk = pthread_getspecific(gBgKey);
            if (bgOk == ((void*)0xbaad)) {
                ALOGE("Thread marked fg-only put self in background!");
                jniThrowException(env, "java/lang/SecurityException", "May not put this thread into background");
                return;
            }
        }
    }
#endif

    int rc = androidSetThreadPriority(pid, pri);
    if (rc != 0) {
        if (rc == INVALID_OPERATION) {
            signalExceptionForPriorityError(env, errno, pid);
        } else {
            signalExceptionForGroupError(env, errno, pid);
        }
    }
}

这里的androidSetThreadPriority实现如下:

int androidSetThreadPriority(pid_t tid, int pri)
{
    int rc = 0;
    int lasterr = 0;

    if (pri >= ANDROID_PRIORITY_BACKGROUND) {
        rc = set_sched_policy(tid, SP_BACKGROUND);
    } else if (getpriority(PRIO_PROCESS, tid) >= ANDROID_PRIORITY_BACKGROUND) {
        rc = set_sched_policy(tid, SP_FOREGROUND);
    }

    if (rc) {
        lasterr = errno;
    }

    if (setpriority(PRIO_PROCESS, tid, pri) < 0) {
        rc = INVALID_OPERATION;
    } else {
        errno = lasterr;
    }

    return rc;
}

这里最终调用了到set_sched_policy,而set_sched_policy方法的实现就位于libprocessgroup中。

小结

最后,我们通过一幅图概括一下这里的调用逻辑。

如上图所示,在Android系统中,进程调度主要涉及三层:

  • 系统服务层
    • init 进程是Android上所有其他进程的祖先。它负责cgroup的初始化和配置工作。
    • ActivityManagerService 负责管理所有应用进程的调度工作。
  • 共享库层
    • Process 类中包含了接口用来调整某个进程的调度策略。
    • libprocessgroup 库提供了针对cgroup的接口。
  • 内核层:Linux内核提供了最底层调度策略和cgroup的实现。

参考资料与推荐读物


原文地址:《Android系统上的进程管理:进程的调度》 by 保罗的酒吧
 Contents