目录

kotlin coroutine真的性能高吗?

Kotlin coroutine 真的性能高吗?

网上讲协程的文章很多, 但是光看文章不练你是不可能了解这东西是怎么用的, 我们来看几个案例

我们先来看一个 kotlin官方 想让我们看到的效果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
fun executorTest() = Executors.newCachedThreadPool().apply {
    repeat(10000) {
        execute { Thread.sleep(666) }
    }
}

fun ktLaunchTest() = runBlocking {
    repeat(10000) {
        launch { delay(666) }
    }
}

fun threadTest() = repeat(10000) {
    thread { Thread.sleep(666) }
}
fun trackTime(method: () -> Unit) = println(measureTimeMillis(method))
trackTime { executorTest() } // 1264
trackTime { ktLaunchTest() } // 926
trackTime { threadTest() } // 6570

没错, 在上述案例中, kotlin协程稳稳赢下了这场比赛 性能领先Executor 36%, 领先 new Thread 600%

但是不知道你们发现了没有, 其实协程作弊了, 协程没有使用 Thread.sleep() 方法, 而是使用了一个挂起的方法 delay(), 带着迷惑我们进行下面的测试:

这次我只用了 100 个线程(或协程), 原因是什么你们看下面代码的测试结果就知道了… 协程时间高得离谱

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fun executorTest() = Executors.newCachedThreadPool().apply {
    repeat(100) {
        execute { Thread.sleep(666) }
    }
}

fun ktLaunchTestIO() = runBlocking {
    repeat(100) {
        launch(Dispatchers.IO) { Thread.sleep(666) }
    }
}

fun ktLaunchTest() = runBlocking {
    repeat(100) {
        launch(Dispatchers.Default) { Thread.sleep(666) }
    }
}

fun threadTest() = repeat(100) {
    thread { Thread.sleep(666) }
}

trackTime { executorTest() } // 10
trackTime { ktLaunchTest() } // 4780
trackTime { ktLaunchTestIO() } // 1337
trackTime { threadTest() } // 7

可能这里有些人就要疑惑了, 你为什么 launch 后面要加参数?? 我想说: 你可以试试不加…. 不加就是在当前线程上执行任务, 直接卡死主线程, 时间飞起, 比上面这些还夸张 (实测:66774)

这里给出 kotlin协程很慢的原因: kotlin 协程本质上并不一定会切线程, 而根据不同的调度器会有不同的线程管理机制

我先说明我们常用的这些调度器大致是什么意思:

  • Dispatchers.IO: IO 密集型调度器, 适合处理文件/网络读写操作, 它默认以 64 个线程(或处理器逻辑内核数, 取较大者)为限制条件
  • Dispatchers.Default: 计算密集型调度器, 适合处理大量计算的任务, 它默认以处理器逻辑内核数为限制条件
  • Dispatchers.Main: 主线程, 不同平台有不同区别, 在 Android 平台就是 UI 线程, 是单线程的

明白这些以后, 我们就可以知道, 因为我的笔记本有 16 个逻辑核心, 因此在上面的实例中, 你就会发现 IO 调度器的速度是 Default 调度器的 4 倍, 而主线程则是单线程的, 所以是最慢的

那么现在我们回到之前的问题, delay() 方法是什么玩意啊! 它是个挂起函数罢了

明白了我上面讲的协程调度器的原理, 我这里就可以解释挂起这个东西了

挂起不会阻塞当前线程

我们看到在 IO,Default 等调度器中, 协程不一定只承载在一个线程上面, 因此这里的线程指的是协程所在的线程. 我来解释一下这个流程:

  1. 在我们执行 sleep 方法之后, 就会从这个地方挂起 sleep 方法, 然后当前线程继续执行其他任务, 只是这个代码之后的方法不会被执行罢了! (倘若你是Android的主线程, 就去继续刷新界面, 倘若你是后台线程, 就继续取执行其他需要在后台线程运行的任务)

  2. 那么 sleep 方法之后如果还有其他语句呢? 什么时候会调用他们呢? 答: sleep 方法执行结束后, 线程会继续回到当前调度器想让你回到的线程, 这里注意, 不一定会回到原来的线程!!, 如果是主线程当然是回到主线程, 但如果原来线程是其他线程, 那么回去以后到哪个线程是不可预测的! 这个是由调度器指定的, 我们可以执行下面的代码:

    1
    2
    3
    4
    5
    6
    7
    
    repeat(100) {
        launch(Dispatchers.IO) {
            val cur = Thread.currentThread()
            delay(666)
            if (cur != Thread.currentThread()) throw Exception()
        }
    }
    

    如果你尝试执行后你会发现控制台抛出了很多异常, 这时你可能会疑惑:
    我这都是一行一行写下去的代码而且也没有嵌套啊, 为什么线程被切换掉了呢???
    但如果你改成 Dispatchers.Main 你会发现并没有抛出异常

    不过这是 kotlin coroutine 真正的精髓:用同步的方式写出异步的代码

    在运行到 suspend 标记的方法的时候(注意! 并不准确, 详见下面), 当前线程就不干当前的事了, 去做别的事或者闲置了, 而当 suspend 方法执行完之后, 会继续执行之前没执行的代码, 只不过可能已经不在同一个线程了, 因为原来的线程跑去干别的事了

    注意!:suspend 并不会切换线程, 它只是一个标记, 真正执行切换的是内部的 withContext 方法(对于我们api调用者来说), 甚至 withContext 也不是真正的切换的方法, 但对于应用层来说, 我们姑且可以这样认为, 具体方法是其中的 suspendCoroutineUninterceptedOrReturn , 甚至, 有可能并不会切换线程!!! 看到这里你可能已经想说 mmp 了https://cdn.jsdelivr.net/gh/zsqw123/cdn@master/picCDN/20210404124531.jpg
    别慌! 具体解释可以看下面线程共享里面的解释

    上面解释的内容适合于任何一个 suspend 标记的函数, 不只是 sleep, 具体就一句话: 挂起后不会阻塞当前线程去干别的事, 但回到的线程并不一定是挂起之前的线程

    举个通俗的例子就是: 你和你女朋友是异地恋, 你和她在地铁站分开的时候就相当于你被挂起了, 有朝一日你再见到她的时候, 你的女朋友其实已经和别人跑了, 但是她找了一个别的人来给你, 这时候你就抛出异常了

协程比传统线程方式更高效吗?

或许高效, 但这个高效并不是来源于它有什么高端的地方, 是因为它的非阻塞式挂起, suspend 方法并没有阻塞这个线程, 使得这个线程可以去干别的事

但是很多情况下这样干有意义吗? 只有比如说从主线程切到后台线程, 提高主线程使用效率, 这个确实有意义, 但是你从子线程切子线程有意义吗? emm…其实也有意义, 比如用于多线程网络请求然后拼接

因此我们应该把 非阻塞式挂起 看成一种 线程切换工具, 没错, 它不会阻塞当前线程, 但是总归有一个线程去承接挂起的代码块!

不过对于 JVM 平台, 协程底层还是在操作 Thread 那套工具, 我们应该将其看作一种比 Executor 更好的封装
传统情况下我们有两种方式:

  1. Thread: 这个我们测试了, 它会导致每次都去 new 一个 Thread 对象, 性能较差, 这里我建议 Java 用户还是使用 Executor 提供的那一套线程工具吧!

  2. Executor: 这个有线程池复用什么的机制, 效率较高, 实现 Kotlin delay 那种 非阻塞 的挂起较为麻烦([见下文](####用 Java 实现非阻塞挂起)), 我们就照一般情况下我们进行测试:
    在这里协程我用了十倍的量! 但是其耗时却依旧小于 Executor, 这样拿非阻塞式挂起和阻塞方式比的话协程是一定可以得到不错的结果的, 协程这样的方式只有一个缺点: 挂起后不一定会回到原来的线程 但是很多情况下我们往往并不会在意后台任务在不在一个线程完成, 我们往往只是要: 给你一个任务, 后台给我去做, 最后返回值给我就行了, 因此极大多数情况下, 我相信协程得到的都是你想要的结果!

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    fun executorTest() = Executors.newCachedThreadPool().apply {
        repeat(100_000) {
            execute { Thread.sleep(666) }
        }
    }
       
    fun ktLaunchTest() = runBlocking {
        repeat(1_000_000) {
            launch { delay(666) }
        }
    }
    trackTime { executorTest() } // 6237
    trackTime { ktLaunchTest() } // 1014
    
  3. 什么? 这就结束了吗? 其实并没有, 我们可以换一种方式, 只要我们指定 Executor 线程池的大小, 我们就会发现 Executor 性能更高了

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    fun executorTest() = Executors.newFixedThreadPool(1).apply {
        repeat(1_000_000) {
            execute { Thread.sleep(666) }
        }
    }
       
    fun ktLaunchTest() = runBlocking {
        repeat(1_000_000) {
            launch { delay(666) }
        }
    }
       
    fun main() {
        trackTime { executorTest() } // 150
        trackTime { ktLaunchTest() } // 994
    }
    

    ? 什么??? 为什么会这样啊, 原因很简单, 我这样循环这么多次执行 execute, 不过是因为任务太多而线程池全部线程处于阻塞状态, 于是就把任务插到了队列中, 等待线程池有空闲线程, 因此并不会执行完成, 而 Kotlin Coroutine 则是货真价实的每个任务协程等了 600ms, 自然要比 Executor 要慢啦, 我们应该相对公平一点:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    fun executorTest(threadCount: Int) {
        val executorPool = Executors.newFixedThreadPool(threadCount).apply {
            repeat(1000) {
                execute { Thread.sleep(666) }
            }
        }
        executorPool.shutdown()
        executorPool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS)
    }
       
    fun ktLaunchTest() = runBlocking {
        repeat(1000) {
            launch { delay(666) }
        }
    }
       
    fun main() {
        trackTime { ktLaunchTest() } // 772
        trackTime { executorTest(1000) } // 741
        trackTime { executorTest(100) } // 6676
    }
    

    我们看到结果了, 只有当我们创建 1000 个线程的时候, Executor 才接近协程, 这时你可能想说: Kotlin Coroutine, 不过如此!

  4. 事实上, 在我运行上面代码的时候, Executor 消耗了我电脑 4GB 的内存, 而 kotlin 协程峰值情况下仅仅消耗了 120MB, 在 jvm 虚拟机中创建很多线程的代价是非常昂贵的, 因此在大多数情况下, 我们都应该使用 Kotlin Coroutine 去替代传统线程操作, 但是实际上, 用 Java 去实现 kotlin 的 delay 方法达到的那种非阻塞式挂起也是可以的: [用 Java 实现非阻塞挂起](####用 Java 实现非阻塞挂起)

线程共享

在很多情况下我们会有一种操作: 从数据库读取数据, 然后对数据进行解码, 这里就涉及到了两个操作, 分别是 IO 操作计算操作, 遇到这种情况下, 我们按理来讲应该先使用 Dispatchers.IO, 然后使用Dispatchers.Default, 就比如下面的代码, 会输出什么呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
suspend fun readDB(): Int = withContext(Dispatchers.IO) {
    Thread.sleep(1000)
    println(Thread.currentThread())
    1
}

suspend fun decodeDB(): Int = withContext(Dispatchers.Default) {
    Thread.sleep(1000)
    println(Thread.currentThread())
    2
}

runBlocking {
    println(Thread.currentThread())
    readDB()
    decodeDB()
    println(Thread.currentThread())
}

如果你多次执行上面的代码, 你会发现有时候输出的四个是同一个线程, 有时候又不是, 你可能会好奇, 我不是已经用 withContext 切换线程了吗? 这又是为什么呢?

事实上: Dispatchers.DefaultDispatchers.IO 的调度器共享同样的线程池, 如果你要从在这两种调度器之间互相切换的话, 你会发现他们有时候并不会切换(源码里面写的是一般不会切换emm)

用 Java 实现非阻塞挂起

具体原因可以看这里, 下面才是 delay 方法的正确替代, 而不是使用 Thread.sleep 这种方式, 在这种情况下, Executor 的性能高于 Kotlin 协程, 我个人猜测是 Kotlin 的 lambda 语法实际上创建了大量的对象导致的这种情况, 也可能有其他原因, 欢迎在下面评论区留言或发邮件, 感激不尽!

这里我写了 Kotlin 代码hhhh, Java 写起来挺费劲的, 你们就当 Java 看吧😏

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
val delayExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
val runnable= Runnable {  }
fun executorTest() {
    Executors.newCachedThreadPool().run {
        repeat(100_000) {
            delayExecutor.schedule(runnable, 666, TimeUnit.MILLISECONDS)
        }
        shutdown()
        awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS)
    }
    delayExecutor.shutdown()
    delayExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS)
}

fun ktLaunchTest() = runBlocking {
    repeat(100_000) {
        launch { delay(666) }
    }
}

fun main() {
    trackTime { executorTest() } // 746
    trackTime { ktLaunchTest() } // 1055
}

结尾

注意, 以下观点基于 JVM 平台

  1. Kotlin Couroutine 性能实际上如何?
    只要你 Executor 用的好, 合理切换线程, 那么性能与其相差不大, 甚至略优于 Kotlin Coroutine
  2. 既然 Kotlin CoroutineExecutor 差距不大, 那我们为什么要用它呢?
    这个问题就像是: 0 1 写代码效率老高了, 为什么还要这么多高级编程语言? Kotlin Coroutine 的真正意义, 在于它将异步操作的难度彻底抹平了, 用同步的方式写出异步的代码, 因此我们会更喜欢用效率更高的代码去写逻辑, 这样其实是提升了我们的能力边界

疑惑

上面写了很多, 但是我还是有疑惑的, 具体疑惑在这个视频中所说, 我执行了他实例中所说的, 但是得到与他所说的不一致的效果, 但是与我上面解释的是不冲突的, 但是视频作者是个大佬…

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var a = 0
val task = java.lang.Runnable {
    Thread.sleep(200)
    a++
}

fun executorTest() = Executors.newCachedThreadPool().apply {
    repeat(100_000) {
        execute(task)
    }
    shutdown()
    awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS)
}

fun ktLaunchTest() = runBlocking {
    repeat(100_000) {
        launch {
            delay(200)
            a++
        }
    }
}

fun main() {
    trackTime { executorTest() } // 10083
    trackTime { ktLaunchTest() } // 561
}

我觉得这段代码的正确比较对象应该是下面, 这样不管是用 Kotlin Coroutine 还是 Executor, 性能差别就真的不大了(事实上这样的示例代码的话 Executor 还会较优一点)

事实上这点差距就会变成类似于 O(3) 和 O(4) 这样的时间复杂度的比较, 都是常数级的, 在内部任务不是这么简单的时候, 性能差距很小, 但是 Kotlin 它足够易用, 这一点才是我们需要关注的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
val delayExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
fun executorTest() {
    Executors.newCachedThreadPool().run {
        repeat(100_000) {
            delayExecutor.schedule({
                a++
            }, 200, TimeUnit.MILLISECONDS)
        }
        shutdown()
        awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS)
    }
    delayExecutor.shutdown()
    delayExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS)
}

fun main() {
    trackTime { executorTest() }
    println(a)
}


不过相信看完这篇文章的你就会明白这些了:

  1. 协程为啥性能高
  2. 非阻塞式挂起是什么
  3. 我为什么要使用挂起函数
  4. 什么情况下我应该使用 Executor API 而不是 Kotlin Coroutine

ref:

  1. KEEP/coroutines.md at master · Kotlin/KEEP (github.com)
  2. 【码上开学】到底什么是「非阻塞式」挂起?协程真的比线程更轻量级吗?_哔哩哔哩 (゜-゜)つロ 干杯~-bilibili