前语

在几个月前,我曾经写了一篇文章,Kotlin 协程中的并发问题:我分明用 mutex 上锁了,为什么没有用?,讲述在某次 debug 某个问题时,发现搭档写的 Koltin 协程某个不恰当的当地,并最终诱发了 BUG 的过程。

时隔几个月,我又重新开端检查这部分代码,这次倒不是由于有新的 BUG,而是由于老板觉得这当地太“卡”了,让我看看是什么原因导致的,有没有办法优化一下功能。

这一看,又看出了一个 “反直觉” 的现象:为什么,一切的耗时逻辑都加上了协程,运转速度反而更慢了?

不得不说,这个搭档是真会埋坑,每次都能给我玩出新的花样来。

正文

复现场景

在我所说的这个当地的代码能够简化为这么一个场景:

在某段事务逻辑,需求轮询某个数据,这个数据有时有有时无,假如轮询到有数据则会对其进行一系列的处理,而这些处理都是耗时使命。一起为了用户运用体会更佳,咱们会在轮询到数据的第一时刻更新读取到的数据到 UI 上,然后开端对这些数据进行处理,处理完结再持续把处理后的数据更新到 UI 上。

依照这个规划,在实践运用时应该是用户供给了数据后(程序轮询的数据能够简单的看成便是在等用户供给数据)UI 立马更新能够当即显现的数据,一起开端运用协程异步开端处理这些数据,处理完后当即把处理好的数据完好的显现到 UI 上。

可是实践测试下来,往往却是用户供给了数据后,需求等候好久 UI 才会更新,而此时甚至数据的异步处理也现已完结了。这就造成了一种很卡的假象。

那么,究竟为什么会造成了这一现象呢?

在开端之前咱们先来温习一下有关协程的一些基础知识,假如你比较了解这些知识的话,能够直接跳过。

协程基础知识

协程效果域(CoroutineScope)

发动一个协程需求一个协程的效果域,也便是 CoroutineScope

由于协程的 launchasync 等创立办法都是 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.

运用协程效果域能够操控经过它发动的一切协程的生命周期(撤销协程)。

一般来说咱们能够经过以下几种办法创立协程效果域:

  1. val scope1 = CoroutineScope(Dispatchers.Default)
  2. val scope2 = GlobalScope
  3. val scope3 = MainScope()

他们的差异在于,GlobalScopeCoroutineScope 都是没有绑定到程序的任何生命周期中,也便是说运用这两个办法创立的效果域发动的协程不会在程序或某个页面销毁时主动撤销,这在某些状况下可能会造成内存泄漏等问题。

而运用 MainScope 创立的效果域发动的协程,默许运转在主线程(UI线程)

还有一些具有生命周期的组件也供给了创立协程效果域的办法,例如 ViewModel 供给了 :viewModelScope ,他的生命周期跟从 ViewModel ,假如 ViewModel 完毕,它也会被撤销。

另外,lifecycle 组件也供给了 lifecycleScope ,它和 ViewModel 相似,绑定了 ActivityFragment 的生命周期,在它们生命周期完毕时,它的协程也会被撤销,不同点在于,lifecycleScope 能够感知 ActivityFragment 的生命周期,例如,能够在 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">
    <TextView
        android: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" />
    <Button
        android: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" />
    <Button
        android: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: TextView
    private lateinit var button1: Button
    private lateinit var button2: Button
    override 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 0From 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 更新的不及时,甚至需求比及一切耗时使命都履行完毕了才会更新……

最后,依然是到了我堆屎的时分,正如在前语的文章中所说的,这代码嵌套的太深了,我可不敢乱动,我只敢持续往上堆屎,只需能处理老板的需求就行了。

于是,我选择的处理方案是:把 changeUIscope.launch { } 删掉,你还真甭说,这就处理了问题,哈哈哈哈。

当然,由于我在本文中举的比如是十分简单的最小复现 demo,所以在这个 demo 中运用这个办法并不能处理问题哦,这个办法只是在我这个实践项目中的确起效果了。

虽然可是,我仍是无法理解,这哥们,为什么要在更新 UI 时也scope.launch { } 而且仍是不指定调度器的 launch ,我尝试复原一下这位老哥的内心想法:

“老板让我在这儿假如轮询到数据就先当即更新 UI 显现数据,然后再异步处理数据。哦,异步处理……当即更新……我知道了,用协程!那我把他们全部放在一个 scope.launch { } 不就得了,而且更新这么多 UI 应该也是耗时使命吧,那我也把它放到协程中去,可不能耽误了后面的数据异步处理,毕竟这儿是要一起履行的!”

当然,实践上这儿的最优处理办法应该是做好协程的办理,不要滥用 launch ,应该梳理出哪些使命是能够一起履行的,哪些使命是需求等候上一个使命履行再接着履行的,然后灵活运用 suspend 挂起和康复函数。而不是一把梭哈全部运用 launch 发动,然后在呈现需求等候其他耗时使命的履行成果时加锁,导致可读性和可维护性极其差(关于加锁这个,我在上篇文章中现已吐槽而且剖析过了,感兴趣的能够去看看)。

总结

以上便是我在优化项目中运转功能时发现的一个协程的过错用法导致的运转功能反而更加低下的状况和剖析。

这个比如告知咱们,运用协程一定要根据需求去灵活的运用它的不同特性,而不是不管三七二十一,直接梭哈。