前面讲过,任务调度是影响速度的本质因素之一,这一章我们就来讲讲任务调度的优化。
针对任务调度,我们很自然地就能想到:提高任务的优先级或者减少任务调度的耗时这两条优化方法论。减少调度耗时有不少优化方案,比如线程保活和使用协程等,这些我们都在前面的章节中讲过了,也就不再重复了。
这一章,我们就围绕如何提高任务的优先级,来介绍 2 种优化方案:
-
提升核心线程的优先级;
-
核心线程绑定 cpu 大核。
提升核心线程优先级
想要提升线程的优先级,我们需要先了解线程优先级这一概念的原理。第二章我们讲过,Linux 中的进程分为实时进程和普通进程这两类。实时进程一般通过 RTPRI(RealTimeRriority) 值来描述优先级,取值范围是 0 到 99。普通进程一般使用 Nice 值来描述进程的优先级,取值范围是 -20 到 19 。但是为了架构设计上的统一, Linux 系统会将 Nice 对齐成 Prio 值,即 Nice 取 -20 时,该进程的 Prio 值为 0 ,此时它的优先级依然比任何一个实时进程的优先级都要低。由于线程的本质就是进程,因此上述优先级规则也适用于线程。
我们可以通过进入手机的 shell 界面,执行 ps -p -t (高版本系统上这个指令可能失效了)查看所有进程的 Nice 值和 Prio 值。部分数据如下:
sql
ps -p -t
USER PID PPID VSIZE RSS PRIO NICE RTPRI SCHED WCHAN PC NAME
root 393 1 1554500 5256 20 0 0 0 ffffffff 000 S zygote
system 762 328 338336 9844 12 -8 0 0 ffffffff 00000000 S surfaceflinger
......
//测试demo的主线程和渲染线程
u0_a45 16632 393 2401604 60140 20 0 0 0 ffffffff 00000000 S com.example.test
u0_a45 16725 16632 2401604 60140 16 -4 0 0 ffffffff 00000000 S RenderThread
......
在 Android 中只有部分底层核心进程才是实时进程,如 SurfaceFlinger、Audio 等进程,大部分的进程都是普通进程,从上面数据也可以看到,我们 Demo 主线程的 Nice 值默认为 0,渲染线程的 Nice 值默认值为 -4。我们没法将普通进程调整成实时进程,也没法将实时进程调整成普通进程,只有操作系统有这个权限。但有一个例外,在Root 手机中,将 /system 目录下的 build.prop 文件中的 sys.use_fifo_ui 字段修改成 1 , 就能 将应用的主线程和渲染线程调整成实时进程,不过这需要 Root 设备才能操作,正常设备这个值都是 0,方案不具备通用性,就不展开讲了。
应用中的所有线程都属于普通进程的级别,所以针对线程优先级这一点,我们唯一能操作的就是修改线程的 Nice 值了,并且我们有两种方式来调整线程的 Nice 值。
调整线程优先级的方式
我们有 2 种方式改变线程的 nice 值:
-
Process.setThreadPriority(int priority) / Process.setThreadPriority(int pid,int priority);
-
Thread.setPriority(int priority)。
第一种方式是 Android 系统中提供的 API 接口。入参 pid 就是线程 id,也可以不传,会默认为当前线程,入参 priority 可以传 -20 到 19 之间的任何一个值,但建议直接使用 Android 提供的 Priority 定义常量,这样我们的代码具有更高的可读性,如果直接传我们自定义的数字进去,不利于代码的理解。
系统常量 | nice值 | 使用场景 |
---|---|---|
Process.THREAD_PRIORITY_DEFAULT | 0 | 默认优先级 |
Process.THREAD_PRIORITY_LOWEST | 19 | 最低优先级 |
Process.THREAD_PRIORITY_BACKGROUND | 10 | 后台线程建议优先级 |
Process.THREAD_PRIORITY_LESS_FAVORABLE | 1 | 比默认略低 |
Process.THREAD_PRIORITY_MORE_FAVORABLE | -1 | 比默认略高 |
Process.THREAD_PRIORITY_FOREGROUND | -2 | 前台线程优先级 |
Process.THREAD_PRIORITY_DISPLAY | -4 | 显示线程建议优先级 |
Process.THREAD_PRIORITY_URGENT_DISPLAY | -8 | 显示线程的最高级别 |
Process.THREAD_PRIORITY_AUDIO | -16 | 音频线程建议优先级 |
Process.THREAD_PRIORITY_URGENT_AUDIO | -19 | 音频线程最高优先级 |
在不进行调整前,我们主线程的 Nice 值默认为 0,渲染线程的默认 Nice 值为 -4。音频线程建议是最高级别优先级,因为如果音频线程优先级太低,就会出现音频播放卡顿的情况。
第二种方式是 Java 提供的 API 接口,Java 有自己对线程优先级的定义和规则,但是最后都会将这些规则转换成对应的 Nice 值大小。Java 线程提供的优先级以及转换成 Nice 值的规则如下:
常量值 | nice值 | 对应 Android API 值 | |
---|---|---|---|
Thread.MAX_PRIORITY | 10 | -8 | THREAD_PRIORITY_URGENT_DISPLAY |
Thread.MIN_PRIORITY | 0 | 19 | THREAD_PRIORITY_LOWEST |
Thread.NORM_PRIORITY | 5 | 0 | THREAD_PRIORITY_DEFAULT |
第二种方式能设置的优先级较少,不太灵活,并且因为系统的一个时序问题 Bug,在设置子线程的优先级时,可能因为子线程没创建成功而设置成了主线程的,会导致优先级设置异常,所以这里建议使用第一种方式来设置线程的优先级,避免使用第二种方式。
需要调整优先级的线程
了解了调整线程优先级的方式,我们再看看哪些线程需要调整,主要有两类:主线程和渲染线程(RenderThread)。
为什么要调整这两个线程呢?因为这两个线程对任何应用来说都非常重要。从Android5 开始,主线程只负责布局文件的 measure 和 layout 工作,渲染的工作放到了渲染ls线程,这两个线程配合工作,才让我们应用的界面能正常显示出来。所以通过提升这两个线程的优先级,便能让这两个线程获得更多的 CPU 时间,页面显示的速度自然也就更快了。
主线程的优先级好调整,我们直接在 Application 的 attach 生命周期中,调用 Process.setThreadPriority(-19),将主线程设置为最高级别的优先级即可。但是 render 线程怎么调整呢?这时我们需要知道 render 线程的线程 id,然后依然调用 Process.setThreadPriority 就可以了。下面我们就一起看一下如何找到 render 线程的线程 pid。
应用中线程的信息记录在 /proc/pid/task 的文件中,可以看到 task 文件中记录了当前应用的所有线程。以 11548 这个进程的数据为例,数据如下:
bash
/proc/11548/task $ ls
11548 11554 11556 11558 11560 11564 11566 12879 12883 12890 12917 14501 14617 15596 15598 15600 15602 15614
11553 11555 11557 11559 11562 11565 12878 12881 12884 12894 12920 14555 15585 15597 15599 15601 15613 15617
我们接着查看该目录里线程的 stat 节点,就能具体查看到线程的详细信息,如 Name、pid 等等。11548 进程的主线程 id 就是 11548,它的 stat 数据如下:
bash
blueline:/proc/11548/task $ cat 11548/stat
11548 (ndroid.settings) S 1271 1271 0 0 -1 1077952832 12835 0 1617 0 52 19 0 0 10 -10 36 0 59569858 15359959040 23690 18446744073709551615 1 1 0 0 0 0 4612 1 1073775864 0 0 0 17 4 0 0 0 0 0 0 0 0 0 0 0 0 0
在第十章中,我们已经详细介绍了 stat 数据中每个参数的含义,如果记不清了可以看看前面的知识点。上面的数据中,第一个参数是 pid,第二个参数是 name。
所以我们只需要遍历这个文件,查找名称为 "render" 的线程,就能找到渲染线程的 pid 了。那么下面就看一下具体的代码如何实现吧。
ini
public static int getRenderThreadTid() {
File taskParent = new File("/proc/" + Process.myPid() + "/task/");
if (taskParent.isDirectory()) {
File[] taskFiles = taskParent.listFiles();
if (taskFiles != null) {
for (File taskFile : taskFiles) {
//读线程名
BufferedReader br = null;
String cpuRate = "";
try {
br = new BufferedReader(new FileReader(taskFile.getPath() + "/stat"), 100);
cpuRate = br.readLine();
} catch (Throwable throwable) {
//ignore
} finally {
if (br != null) {
br.close();
}
}
if (!cpuRate.isEmpty()) {
String param[] = cpuRate.split(" ");
if (param.length < 2) {
continue;
}
String threadName = param[1];
//找到name为RenderThread的线程,则返回第0个数据就是 tid
if (threadName.equals("(RenderThread)")) {
return Integer.parseInt(param[0]);
}
}
}
}
}
return -1;
}
当我们拿到渲染线程的 pid 后,同样调用 Process.setThreadPriority(pid,-19) 将渲染线程设置成最高优先级即可。
当然,我们要提高的优先级线程并非只有这两个,我们可以根据业务需要,来提高核心线程的优先级,同时降低其他非核心线程的优先级,该操作可以在线程池中通过线程工厂来统一调整。提高核心线程优先级,降低非核心线程优先级,两者配合使用,才能更高效地提升应用的速度。
核心绑定 CPU 大核
接着,我们来看第二种优化方案:绑定 CPU 大核。这种方案虽然和操作系统的任务调度关系不大,但也属于一种提升线程优先级的方案,只不过它提升的是线程运行在性能更好的 CPU 上的优先级。
目前手机设备的 CPU 都是多核的,如下图的骁龙 888 这款 CPU 就有 8 个核,其中大核的性能是最好的,时钟周期频率为 2.84GHZ,其他的核性能都要差很多。
最差的核只有 1.8GHZ 的时钟周期频率,如果用它来执行我们核心线程的任务,性能就会差很多。这主要体现在主线程和渲染线程上,页面的显示速度会变慢。所以,如果我们能将核心线程绑定在大核上,那么应用的速度就会提升很多。
线程绑核方案
线程绑核并不是很复杂的事情,因为 Linux 系统有提供相应的 API 接口,系统提供的 pthread_setaffinity_np和 sched_setaffinity这里两个函数,都能实现线程绑核。但是在 Android 系统中,限制了 pthread_setaffinity_np 函数的使用,所以我们只能通过 sched_setaffinity 函数来进行绑核操作。
#include <sched.h>
int sched_setaffinity(pid_t pid , size_t cpusetsize **,cpu_set_t *** mask );
第一个入参是线程的 pid,如果pid的值为0,则表示指定的是主线程
第二个入参 cpusetsize 是 mask 所指定的数的长度
第三个入参是需要绑定的 cpu 序列的掩码
下面,我们就一起来看一下,如何通过这个函数实现线程绑核的操作。
scss
void bindCore(){
cpu_set_t mask; //CPU核的集合
CPU_ZERO(&mask); //将mask置空
CPU_SET(0,&mask); //将需要绑定的cpu核设置给mask,核为序列0,1,2,3......
if (sched_setaffinity(0, sizeof(mask), &mask) == -1){ //将线程绑核
printf("bind core fail");
}
}
我们可以看到,将主线程绑定序列为 0 的操作只要数行代码就能实现了。实现起来很简单,只需要在 native 层指定需要绑定线程的 pid,核序列的掩码 mask,作为入参调用 sched_setaffinity 函数即可。
可我们需要的是将主线程和渲染线程绑定大核,所以上面的代码并不完整,我们还需要传入正确的 pid 和核序列。主线程的 pid 入参传 0,渲染线程的 pid 也知道如何获取了,现在就差找到那个性能最高的大核了。
获取大核序列
我们可以通过 /sys/devices/system/cpu/ 目录下的文件,查看当前设备有几个 CPU 。用来测试的是一台 Pixel3,可以看到有 8 个 CPU,也就是 8 核。
bash
/sys/devices/system/cpu $ ls
core_ctl_isolated cpu1 cpu3 cpu5 cpu7 cpuidle hang_detect_gold hotplug kernel_max offline possible present
cpu0 cpu2 cpu4 cpu6 cpufreq gladiator_hang_detect hang_detect_silver isolated modalias online power uevent
然后进入到 cpuX/cpufreq 文件,查看具体序列的 CPU 详情。
bash
/sys/devices/system/cpu/cpu0/cpufreq $ ls
affected_cpus cpuinfo_max_freq cpuinfo_transition_latency scaling_available_frequencies scaling_boost_frequencies scaling_driver scaling_max_freq scaling_setspeed stats
cpuinfo_cur_freq cpuinfo_min_freq related_cpus scaling_available_governors scaling_cur_freq scaling_governor scaling_min_freq schedutil
这个文件中的 cpuinfo_max_freq 节点就是当前 CPU 的时钟周期频率。下面就是 piexl3 骁龙 845 芯片的每个核的时钟周期频率。
bash
/sys/devices/system/cpu $ cat cpu0/cpufreq/cpuinfo_max_freq
1766400
/sys/devices/system/cpu $ cat cpu1/cpufreq/cpuinfo_max_freq
1766400
/sys/devices/system/cpu $ cat cpu2/cpufreq/cpuinfo_max_freq
1766400
/sys/devices/system/cpu $ cat cpu3/cpufreq/cpuinfo_max_freq
1766400
/sys/devices/system/cpu $ cat cpu4/cpufreq/cpuinfo_max_freq
2803200
/sys/devices/system/cpu $ cat cpu5/cpufreq/cpuinfo_max_freq
2803200
/sys/devices/system/cpu $ cat cpu6/cpufreq/cpuinfo_max_freq
2803200
/sys/devices/system/cpu $ cat cpu7/cpufreq/cpuinfo_max_freq
2803200
可以看到,4、5、6、7 序列都是大核。如果查看 845 的参数,也可以发现是符合这个特性的。
所以我们在代码实现中,只需要遍历 /sys/devices/system/cpu/ 目录下的 cpu 节点,然后读取节点下的 cpuinfo_max_freq 就能找到大核了。下面就看一下如何实现吧。
- 统计该设备 CPU 有多少个核。
arduino
public static int getNumberOfCPUCores() {
int cores = new File("/sys/devices/system/cpu/").listFiles((file) -> {
String path = file.getName();
if (path.startsWith("cpu")) {
for (int i = 3; i < path.length(); i++) {
if (path.charAt(i) < '0' || path.charAt(i) > '9') {
return false;
}
}
return true;
}
return false;
}).length;
return cores;
}
- 遍历每个核,找出时钟频率最高的那个核。
ini
public static int getMaxFreqCPU() {
int maxFreq = -1;
try {
for (int i = 0; i < getNumberOfCPUCores(); i++) {
String filename = "/sys/devices/system/cpu/cpu" + i + "/cpufreq/cpuinfo_max_freq";
File cpuInfoMaxFreqFile = new File(filename);
if (cpuInfoMaxFreqFile.exists()) {
byte[] buffer = new byte[128];
FileInputStream stream = new FileInputStream(cpuInfoMaxFreqFile);
try {
stream.read(buffer);
int endIndex = 0;
//Trim the first number out of the byte buffer.
while (buffer[endIndex] >= '0' && buffer[endIndex] <= '9'
&& endIndex < buffer.length) endIndex++;
String str = new String(buffer, 0, endIndex);
Integer freqBound = Integer.parseInt(str);
if (freqBound > maxFreq) maxFreq = freqBound;
} catch (NumberFormatException e) {
} finally {
stream.close();
}
}
}
} catch (IOException e) {
}
return maxFreq;
}
至此,我们便找出了大核的序列,然后将大核序列以及线程 pid 传入 Native 层,调用 sched_setaffinity 进行绑核即可。当然,我们也可以直接在 native 通过 C++ 代码来解析文件获取渲染线程和大核,这样效率会更好一些,具体代码就不在这儿实现了。除了主线程和渲染线程,我们也可以根据业务需要,将其他核心线程绑定大核,比如上面提到的骁龙 845 有四个大核,我们就可以每个大核都绑定一个核心线程。
当我们通过上面的逻辑将主线程和渲染线程绑定大核后,可以通过 sched_getaffinity 函数或者通过 ps -p -t 等命令查看线程运行在在哪个核上,以此来确认是否绑定成功。到这里,你就学会了如何将线程绑定大核这一优化方案了,可以在课后试一试,看看绑定大核后,应用的启动速度和页面的打开速度提升了多少。
小结
这一章介绍的两种方案的代码实现都不难,很容易落地。但是我们真正需要掌握的不仅仅是这两种方案的实现,还有能诞生出这两种优化方案的方法论,即提高任务调度优先级的方法论。
那除了能想到本章中提到的两种方案,大家可以想想还有哪些方案能提升优先级。比如:替换或者优化调度算法,将普通进程变成实时进程,等等。只要我们能从原理出发,自下而上地思考,就一定能产生源源不断的灵感和思路!