前言
在几个月前,我曾经写了一篇文章,Kotlin 协程中的并发问题:我明明用 mutex 上锁了,为什么没有用?,讲述在某次 debug 某个问题时,发现同事写的 Koltin 协程某个不恰当的地方,并最终诱发了 BUG 的过程。
时隔几个月,我又重新开始检查这部分代码,这次倒不是因为有新的 BUG,而是因为老板觉得这地方太“卡”了,让我看看是什么原因导致的,有没有办法优化一下性能。
这一看,又看出了一个 “反直觉” 的现象:为什么,所有的耗时逻辑都加上了协程,运行速度反而更慢了?
不得不说,这个同事是真会埋坑,每次都能给我玩出新的花样来。
正文
复现场景
在我所说的这个地方的代码可以简化为这么一个场景:
在某段业务逻辑,需要轮询某个数据,这个数据有时有有时无,如果轮询到有数据则会对其进行一系列的处理,而这些处理都是耗时任务。同时为了用户使用体验更佳,我们会在轮询到数据的第一时间更新读取到的数据到 UI 上,然后开始对这些数据进行处理,处理完成再继续把处理后的数据更新到 UI 上。
按照这个设计,在实际使用时应该是用户提供了数据后(程序轮询的数据可以简单的看成就是在等用户提供数据)UI 立马更新能够立即显示的数据,同时开始使用协程异步开始处理这些数据,处理完后立即把处理好的数据完整的显示到 UI 上。
但是实际测试下来,往往却是用户提供了数据后,需要等待很久 UI 才会更新,而此时甚至数据的异步处理也已经完成了。这就造成了一种很卡的假象。
那么,究竟为什么会造成了这一现象呢?
在开始之前我们先来复习一下有关协程的一些基础知识,如果你比较熟悉这些知识的话,可以直接跳过。
协程基础知识
协程作用域(CoroutineScope)
启动一个协程需要一个协程的作用域,也就是 CoroutineScope
。
因为协程的 launch
和 async
等创建方法都是 CoroutineScope
的扩展方法:
Defines a scope for new coroutines. Every coroutine builder (like [launch], [async], etc.)
is an extension on [CoroutineScope] and inherits its [coroutineContext][CoroutineScope.coroutineContext]
to automatically propagate all its elements and cancellation.
使用协程作用域可以控制通过它启动的所有协程的生命周期(取消协程)。
一般来说我们可以通过以下几种方式创建协程作用域:
val scope1 = CoroutineScope(Dispatchers.Default)
val scope2 = GlobalScope
val scope3 = MainScope()
他们的区别在于,GlobalScope
和 CoroutineScope
都是没有绑定到程序的任何生命周期中,也就是说使用这两个方法创建的作用域启动的协程不会在程序或某个页面销毁时自动取消,这在某些情况下可能会造成内存泄漏等问题。
而使用 MainScope
创建的作用域启动的协程,默认运行在主线程(UI线程)
还有一些具有生命周期的组件也提供了创建协程作用域的方法,例如 ViewModel
提供了 :viewModelScope
,他的生命周期跟随 ViewModel
,如果 ViewModel
结束,它也会被取消。
另外,lifecycle
组件也提供了 lifecycleScope
,它和 ViewModel
类似,绑定了 Activity
或 Fragment
的生命周期,在它们生命周期结束时,它的协程也会被取消,不同点在于,lifecycleScope
可以感知 Activity
或 Fragment
的生命周期,例如,可以在 onResumed
时启动协程 lifecycleScope.launchWhenResumed
。
协程调度器(Dispatchers)
其实协程可以简单的理解为对线程的封装,它可以帮我们管理程序在不同的线程上运行。
所以我们启动协程时一般都需要指定它的调度器,即这个协程中的代码应该在什么线程中去运行。
当然,协程作用域一般都提供了默认协程调度器,所以有时候我们会看到我们启动协程时没有提供调度器。
协程一共有四个调度器:
Dispatchers.Default
默认调度器,一般用于计算密集型的任务使用,它一般只会运行在 CPU 核心数个线程上(保证至少有两个线程),并且保证在此调度器中运行的并行任务不超过线程数:
It is backed by a shared pool of threads on JVM. By default, the maximal level of parallelism used
by this dispatcher is equal to the number of CPU cores, but is at least two.
Level of parallelism X guarantees that no more than X tasks can be executed in this dispatcher in parallel.
Dispatchers.Main
它表示把协程运行在主线程(UI线程)中,一般用于进行 UI 操作时使用,
Dispatchers.Unconfined
未指定调度器,在调用者的线程上执行。
Dispatchers.IO
IO 调度器,适合用于执行涉及到大量 IO 计算的任务,例如长期处于等待的任务而非计算密集任务。它使用的最大线程数为系统属性设定的值或 CPU 核心数的最大值决定,系统属性值一般设置的是 64,也就是说,一般来说,该调度器可能会创建 64 个线程来执行任务:
Additional threads in this pool are created and are shutdown on demand.
The number of threads used by tasks in this dispatcher is limited by the value of
“kotlinx.coroutines.io.parallelism
” ([IO_PARALLELISM_PROPERTY_NAME]) system property.
It defaults to the limit of 64 threads or the number of cores (whichever is larger).
复现 demo 以及原因分析
上面我们已经简要介绍了本文需要用到的协程基础知识,下面我们直接写一个最小复现 demo:
activity_main.xml:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"android:layout_width="match_parent"android:layout_height="match_parent"tools:context=".MainActivity"><TextViewandroid:id="@+id/text1"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="Hello World!"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent" /><Buttonandroid:id="@+id/button1"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="click"app:layout_constraintStart_toStartOf="@+id/text1"app:layout_constraintEnd_toEndOf="@+id/text1"app:layout_constraintTop_toBottomOf="@+id/text1" /><Buttonandroid:id="@+id/button2"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="Start"app:layout_constraintStart_toStartOf="@+id/text1"app:layout_constraintEnd_toEndOf="@+id/text1"app:layout_constraintTop_toBottomOf="@+id/button1" /></androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.kt:
private const val TAG = "el"class MainActivity : AppCompatActivity() {private val scope = MainScope()private lateinit var textView1: TextViewprivate lateinit var button1: Buttonprivate lateinit var button2: Buttonoverride fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_main)textView1 = findViewById(R.id.text1)button1 = findViewById(R.id.button1)button2 = findViewById(R.id.button2)button1.setOnClickListener {textView1.text = "${System.currentTimeMillis()}"}button2.setOnClickListener {scope.launch {task(2000)task(5000)task(1000)task(500)changeUi(0)task(2000)task(5000)task(1000)task(500)changeUi(1)}}}private fun changeUi(flag: Int) {val startTime = System.currentTimeMillis()scope.launch {Log.i(TAG, "changeUi($flag): launch time = ${System.currentTimeMillis() - startTime}")timeConsuming(100)textView1.text = "${System.currentTimeMillis()}-From changeUi $flag"}}private fun task(delay: Int) {val startTime = System.currentTimeMillis()scope.launch {Log.i(TAG, "task: launch($delay) time = ${System.currentTimeMillis() - startTime}")timeConsuming(delay)textView1.text = "${System.currentTimeMillis()}-From task($delay)"}}private fun timeConsuming(times: Int) {val file = File(cacheDir, "test.txt")if (!file.exists()) {file.createNewFile()}repeat(times * 100) {file.appendText("${System.currentTimeMillis()} - balabala - ${it} \n")}}
}
注意:这里的代码其实有个问题,那就是 changeUi(1)
实际上应该是要在所有 task()
都执行完再执行,而不是像 demo 里一样和它们同时执行。
在我实际的项目中,每个 Activity
都继承自自定义的 BaseActivity
,而 BaseActivity
实现了 MainScope
,所以我这里直接使用了一个 private val scope = MainScope()
来模拟这个情况。
在这个 demo 中,我定义了一个方法 timeConsuming
在这个方法中通过向缓存文件写入指定数量的内容来模拟耗时操作。
之所以不直接使用 delay
来模拟耗时操作,是因为 delay
实际上只是将函数挂起,它所在的线程还是可以继续执行其他任务,所以这里不能使用 delay
来模拟耗时任务。
在这个任务中,我们模拟了在 复现场景 一节中所说的情况,轮询数据 task(xxx)
- 轮询到数据后立即更新 UI changeUi(0)
- 然后开始执行数据处理 task(xxx)
- 数据处理完成后继续更新 UI 。
运行日志输出:
task: launch(2000) time = 0
task: launch(5000) time = 2127
task: launch(1000) time = 6596
task: launch(500) time = 7431
changeUi(0): launch time = 7831
task: launch(2000) time = 7911
task: launch(5000) time = 9532
task: launch(1000) time = 13622
task: launch(500) time = 14438
changeUi(1): launch time = 14838
看起来,似乎,没有问题?除了为什么协程启动需要耗时这么久?
哦,真的只是这样吗?
感兴趣的可以把代码 copy 一下,然后运行,就会发现。UI 完全没有更新 From changeUi 0
和 From task($delay)
,而是直接在所有任务都执行结束之后只更新了 From changeUi 1
。
其实这里并不是 UI 没有更新其他内容,而是因为更新几乎是在同时完成的,所以在我们人眼看下来就是其他内容完全没有更新,只更新了 From changeUi 1
。
但是即使是这样,这也完全不符合我们的需求啊,谁要它在所有任务执行完之后再一起更新啊 ,我们要的就是先有数据就先更新数据啊,而且,为什么 launch 一个协程也会这么耗时啊?不是说好的协程的启动和切换非常的轻量级的吗?
其实此时相信只要是看了我前言中提到的那篇文章和上一节的协程基础知识的读者已经很轻易的就看出问题来了:
整个代码的所有协程都是从同一个协程作用域 MainScope
中启动的,且都没有提供任何协程调度器,也就是说使用的都是 MainScope
的默认调度器。而 MainScope
的默认调度器是 Dispatchers.Main
,换句话说,我们所有的逻辑都是运行在主线程中的……
所以,即使我们使用了协程,但是事实上它们都是位于一同个线程中,而且还是主线程,这也能解释为何启动协程居然需要耗费这么多时间,因为当第一个耗时任务在运行时,主线程就已经被占用了,其他的协程想要启动也得等前面的任务运行完了才能运行,所以越靠后的协程启动耗时越长。
当然,咱们写文章也不能昧着良心黑自己的同事不是嘛,就算这个同事老是喜欢写一些带大坑的代码,但也不至于不知道把耗时协程切到其他调度器去运行吧,所以事实上在项目中的耗时任务的代码应该是这样的:
private fun task(delay: Int) {val startTime = System.currentTimeMillis()scope.launch(Dispatchers.IO) {Log.i(TAG, "task: launch($delay) time = ${System.currentTimeMillis() - startTime}")timeConsuming(delay)withContext(Dispatchers.Main) {textView1.text = "${System.currentTimeMillis()}-From task($delay)"}}}
此时运行,日志输出为:
task: launch(2000) time = 0
task: launch(5000) time = 0
task: launch(1000) time = 0
task: launch(500) time = 0
task: launch(2000) time = 0
task: launch(5000) time = 0
task: launch(1000) time = 0
changeUi(0): launch time = 1
task: launch(500) time = 1
changeUi(1): launch time = 190
好了,现在算是正常了,协程启动时间也恢复了它应该有的时间。
再看一下 UI,似乎也正常,确实也是按照我们的需求在更新。
那么,还有什么问题呢??
你别说,还真有,仔细观察日志,你会发现 changeUi(1):
的协程启动时间居然还是挺高的。
并且,在实际项目中,changeUi(0)
等效代码协程启动耗时也十分高,通常在 300-400 ms。
另外,只要看了我前言中那篇文章的读者就会知道,这部分的代码写的非常 “奇葩”,因为它在其中嵌套启动了“无数个”协程,更离谱的是,这个“无数个”显然超过了 Dispatchers.IO
调度器的可用线程数量。
换句话说,即使所有耗时任务都切到了 Dispatchers.IO
调度器,还是依然会出现最开始的 demo 中的情况:协程启动需要等待有可用的线程。这也就造成了,在实际项目中,首次 UI 更新的不及时,甚至需要等到所有耗时任务都执行结束了才会更新……
最后,依然是到了我堆屎的时候,正如在前言的文章中所说的,这代码嵌套的太深了,我可不敢乱动,我只敢继续往上堆屎,只要能解决老板的需求就行了。
于是,我选择的解决方案是:把 changeUI
的 scope.launch { }
删掉,你还真别说,这就解决了问题,哈哈哈哈。
当然,因为我在本文中举的例子是非常简单的最小复现 demo,所以在这个 demo 中使用这个方法并不能解决问题哦,这个方法只是在我这个实际项目中确实起作用了。
虽然但是,我还是无法理解,这哥们,为什么要在更新 UI 时也scope.launch { }
而且还是不指定调度器的 launch
,我尝试还原一下这位老哥的内心想法:
“老板让我在这里如果轮询到数据就先立即更新 UI 显示数据,然后再异步处理数据。哦,异步处理……立即更新……我知道了,用协程!那我把他们全部放在一个 scope.launch { }
不就得了,而且更新这么多 UI 应该也是耗时任务吧,那我也把它放到协程中去,可不能耽误了后面的数据异步处理,毕竟这里是要同时执行的!”
当然,实际上这里的最优解决方法应该是做好协程的管理,不要滥用 launch
,应该梳理出哪些任务是可以同时执行的,哪些任务是需要等待上一个任务执行再接着执行的,然后灵活运用 suspend
挂起和恢复函数。而不是一把梭哈全部使用 launch
启动,然后在出现需要等待其他耗时任务的执行结果时加锁,导致可读性和可维护性极其差(关于加锁这个,我在上篇文章中已经吐槽并且分析过了,感兴趣的可以去看看)。
总结
以上就是我在优化项目中运行性能时发现的一个协程的错误用法导致的运行性能反而更加低下的情况和分析。
这个例子告诉我们,使用协程一定要根据需要去灵活的使用它的不同特性,而不是不管三七二十一,直接梭哈。