线程开的越多就越好吗|趣谈线程池

前言

线程优化一直是启动优化中的一个必不可少的项目。作为一个 Android 程序员,你肯定希望应用启动的时候,火力全开,线程池拉满,每一个 CPU 核心满载而行。

可你把线程池拉满的时候,启动时长就一定会降低吗?

结果显然是否定的,之前我在进行启动优化的时候,就遇到了类似的问题。我引入了有向无环图类似的启动库后,又将线程池的数量设置为:

CPU核心数 * 2 + 1

看似没什么问题,后续启动时长居然还增长了一点点。

摆烂

为什么会出现这样的问题?我们今天就好好聊聊。

一、做个实验

先做个实验,在应用启动过程中,主要做了两步:

  • 主线程循环 10w 次,做一些简单的计算
  • 线程池做一些异步任务,读取文件,然后将读取到的数据写入数据库,这个异步任务提交了 1000 次

核心线程数 = 2 * CPU 核心数 + 1,变量最大线程数:

  1. 实验一:最大线程数 = 2 * CPU 核心数 + 1
  2. 实验二:最大线程数 = Int.MAX_VALUE

在模拟器上,实验二平均启动时长 6505 ms,实验一平均启动市场 5521 ms,从这点看,线程开太多对主线程是有影响的。

二、基础知识

在启动流程中基础知识必不可少,从上往下讲就是线程、线程池、内核 和 CPU,这些知识都是老生常谈了。

1. 线程

线程是操作系统进行运算调度的最小单位,可以理解为它就是系统执行的任务。

作为任务,它会有各种状态:

  1. NEW(新建):新创建的线程,还没有启动
  2. RUNNABLE(可运行):可以运行的线程
  3. BLOCKED(阻塞):阻塞状态的线程
  4. WAITTING(等待):等待状态
  5. TIME_WAITTING(计时等待)
  6. TERMINATED(终止)

各种状态可以进行如下转换:

MainGroup使用流程

处于可运行状态的线程不一定处于运行中,如果 CPU 核心数 < 线程数量,在某个时间点,处于运行中的线程数量最多也只能等于 CPU 核心数。

除此以外,只有处于可运行状态的线程才有机会获取 CPU 的青睐,从而分到时间片,得以执行。

2. 线程池

线程池的知识都很熟悉了,简单了解一下。

2.1 核心线程

简单来说,我们想了解的部分就是线程池的核心线程和非核心线程:

  • 核心线程:核心线程会一直存在
  • 非核心线程:当非核心线程闲置超过指定的时间,就会被销毁

通过配置合适的核心线程数和非核心线程数可以帮助我们管理好线程,可以带来以下好处:

  1. 降低资源消耗:重复利用线程,降低资源消耗
  2. 提供响应速度:任务一来就执行
  3. 管理好线程资源:避免无节制的使用线程,引发性能问题

除此以外,在配置核心线程数和非核心线程数的时候,还需要根据业务场景,将 CPU 密集型和 I/O 密集型任务考虑进去。

2.2 任务划分

我们经常将任务分为 I/O密集型 和 CPU密集型 任务,那么这两种有什么区别呢?

I/O 密集型任务指的是该任务的大部分时间用来提交 I/O 请求或者等待 I/O 请求。这类任务常常运行很短暂的一会儿,然后进入阻塞状态,等待更多的 I/O 请求。常见的如数据库操作、网络操作、键盘事件、屏幕操作等。

CPU 密集型任务指的是任务的大部分代码用来执行代码。该类任务常常会一直运行并占用着 CPU,直到时间片用完。常见的如数据计算、无限循环等。

那线程数如何设置?我们下面再去讲。

3. 内核

哪个线程先运行?什么时间运行?运行多久?这些都是调度程序说了算!

3.1 调度程序

调度程序是一个内核子系统,它是多任务操作系统的基础。多任务操作系统就是能够同时并发地交互执行多个进程的操作系统。

即使是单核处理器,它也可以并发的处理多个任务,只不过在一个时间点,只有一个正在执行的任务。

就好比安卓开发小王,身背几个需求,被产品要求同一天上线,虽然也能够完成,但他在某个时间点,只能写一个需求,如果想一个时间点同时进行两个需求,那得加人,也就是我们通常说的双核处理器,这就具备了并行的能力。

并行和并发

3.2 抢占式和非抢占式

多任务操作系统可以分为两种类型:非抢占式多任务和抢占式多任务。

Android 使用的是抢占式多任务,在这种模式下,每个任务都会被分配到一定的时间用来执行,一旦时间片用完,就会自动切换到下一个任务,分配的时间我们称之为时间片。

还拿小王来举例,小王身背三个需求,每天的计划中,上午需求 A,下午需求 B,晚上需求C。到了下午,即使需求 A 没做完,也要去做需求 B,这样可以保证了每个需求每天都会有进度。

从启动的角度来说,我们肯定不希望主线程和子线程分得同样的时间片,这可能会让我们的应用看着很慢。

为了给主线程分得更长的时间片,每个进程都有一个 nice 值,它会影响时间片的分配,但我们改不了这个,我们能够处理的就是给线程设置优先级,Android 中线程的优先级从 -19 到 19,值越低代表优先级越高,分得的时间片也就越长。

3.3 线程多了会怎么分配

上面的这些东西看似和我们应用层开发没关系,实则不然。

比如线程数量多了以后,我们先拿小王举例:

原先小王手里有 5 个需求,每个 2 天工时,做完一个再做下一个,10天能搞定。

现经理要求他同时开发 5 个需求,保证 5 个需求每天都有进度,那可就麻烦了,先不算 10 天开发时间,还得加上如下时间:

  • 每天切其他四个项目时间成本
  • 思考时间:每次切到下一个项目,都会想上次开发到哪,上次的思路是什么

加上这些乱七八槽的,原来 10 天能搞定的东西,现在得变成 12 天。

线程多了,也会有这样的问题,每次切换时间片都是成本。另外,线程的闲置率会上升,像这样运行 14ms 要等 185 ms:

idle率

还拿小王来看,原先五个需求,桉顺序做,每个需求的生命周期就 2 天,但是并行开发后,每个需求的生命周期都拉长了,到了 12 天左右。对于启动的主线程来讲可不是好事!

理想的情况应该是量力而行,当小王开发一个需求遇到问题需要等产品回复而停滞,在等待的这段时间内,开发另外一个需求,知道产品回复完,再找一个合适的时间切回来,这样,反而会提升效率,将工作时间缩短到 9 天。

4. CPU

在2022年发布的 Android 低端机上,也都标配了 8 核心的 CPU,核心数越多,就意味着并行能力越强。

注意,这里用的是并行,而不是并发。

专业团队

一个核心,就代表着团队只有一个开发,8 核代表着团队有八个开发,意味着一个时间点最高可以有8个需求同时进行。

二、线程数如何设置

上面说了那么多,大家最想知道的就是线程数如何设置。

一般而言,核心线程数和最大线程数都设置为 CPU核心数 * 2 +1 ,阻塞队列使用 LinkedBlockingDeque

1. 任务因素

但这个数字肯定不是绝对的,我们需要考虑到 CPU 密集型任务 和 IO 密集型任务的区别。

如果我们使用子线程都是处理网络、数据库、读文件等操作,这个数字就可以设置大一点;如果子线程仅执行一些耗时的计算代码,这个数字就可以设置小一点。

2. 任务闲置

即使我们自己设置的线程池没什么问题,但程序一启动,任务执行时候的线程闲置率一看就知道还有问题,比如这张图:

idle率

为什么会出现这种闲置率太高的情况,原因可能如下:

  1. 过多使用 New Thread 或者不节制的使用线程池
  2. 很多第三方 SDK 都使用自身的线程池或者线程

查看闲置率有两种,分别是使用Android Studio中的Profiler和Shell命令。

推荐大家使用 Profiler,好处可太多了:

  • 可以查看线程总数
  • 可以查看CPU的负载情况
  • 可以查看每个任务的闲置率
  • ...

直接使用 Profiler 中的 System TraceView 只能查看系统级别的方法,如果是我们想查看的方法,需要这么处理:

public void test{
    Trace.beginSection("名称");
    //... 代码省略
    Trace.endSection();
}

对每个方法做上述过程确实太麻烦,所以都是配合函数插桩使用。

另外一个就是使用 Shell 命令,我们可以在 Android Studio 中 Logcat 窗口看到应用的进程 Id,进入 adb shell 后,就可以通过输入命令 cat /proc/{进程ID}/schedstat 查看:

emulator64_x86_64_arm64:/ $ cat /proc/7775/schedstat
5511910111 2055599424 6712
// 参数一 CPU运行时间
// 参数二 该进程等待时间
// 参数三 主动切换和被动切换的次数

这些数据只能够我们查看大概的情况。

总结

关于线程我们能做的并不多,尽量去收敛线程:

  1. 禁止使用 New Thread 方式去创建线程
  2. 统一应用内线程池,并制定合适的核心线程和最大线程数量
  3. 编写公司库的时候,如需使用线程池,提供设置线程池的接口
  4. 可以设置自身线程池的第三方库,优先设置应用内线程池,比如 OkHttp
  5. Hook 第三方库使用 New Thread,改为应用内线程池
  6. 能懒加载尽量懒加载第三方库,避免过早的竞争系统资源

主要就这些,如有不对的地方,评论区见~

推荐阅读更多精彩内容