Kotlin 协程真的比 Java 线程更高效吗?

本文首发于 vivo互联网技术 微信公众号 
连接: https://mp.weixin.qq.com/s/-OcCDI4L5GR8vVXSYhXJ7w
做者:吴越

网上几乎所有介绍Kotlin的文章都会说Kotlin的协程是多么的高效,比线程性能好不少,然而事情的真相真是如此么?html

协程的概念自己并不新鲜,使用C++加上内嵌汇编,一个基本的协程模型50行代码以内就能够彻底搞出来。早在2013年国内就有团队开源了号称支持千万并发的C++协程库 libco。java

最近几年协程的概念愈来愈深刻人心,主要仍是由于Google的Go语言应用范围愈来愈广,考虑到目前并无一个通用的协程的定义,因此本文中对协程的定义主要来自于Go。linux

1、Kotlin协程在互联网上的主流定义

问题的讨论起源于文章《Go语言出现后,Java仍是最佳选择吗?》,因为以前写过一段时间Go语言,对Go语言有必定的理解,因此当时我看完这篇文章的时候感到疑惑的是Kotlin到底有没有完整的实现相似于Go语言中的协程机制?若是有,那么显然没有必要费这么一大段功夫来魔改JVM的实现。若是没有,那么网上那一堆堆的博客难道说的都是错误的吗?例以下面百度搜索的结果:android

再好比某个Kotlin的视频教程(我仔细观看了其中关于协程部分的讲解,与网络上流传的诸如协程比线程高效是基本一致的)golang

 Kotlin官方网站中的例子:windows

这个例子说明用Java开10w个线程很大几率就会OOM了,可是Kotlin开10w个协程就不会OOM,给人一种Go语言中协程的感受。可是真的是这样么?带着这个问题,咱们进行了一番探索,但愿下面的内容能帮你解开疑惑。服务器

2、JVM中的Thread和OS的Thread的对应关系

要搞清楚协程,首先要搞清楚线程。咱们都知道CPU的每一个核心同一时刻只能执行一个线程。微信

所以会带来一个问题,当线程数量超过CPU的核心数量的时候怎么办?固然是有的线程先暂停一下,而后让其余的线程走走,每一个线程都有机会走一下,最终的目标就是让每一个线程都执行完毕。网络

对于大部分Java的开发者来讲,JVM都是Oracle提供的,而Android开发者面对的就是Art了。可是无论是Oracle的JVM仍是谷歌Android的Art,对于这种主流的JVM实现,他们的线程数量和操做系统中线程的数量基本都是保持在1:1的。并发

也就是说只要在Java语言里面每start Thread 一次,JVM中就会多一个Thread,最终就会多一个os级别的线程,在不考虑调整JVM参数的状况下,一个Thread所占用的内存大小是1mb。最终的JVM的Thread的调度仍是依赖底层的操做系统级别的Thread调度。只要是依赖了操做系统级别的Thread调度,那么就不可避免的存在Thread切换带来的开销。

每一次Thread的 上下文切换都会带来开销,最终结果就是若是线程过多,那么最终线程执行代码的时间就变少,由于大部分的CPU的时间都消耗在了切换线程上下文上。

这里简单证实一下,在Java中Thread和OS的Thread 是1:1的关系:

Start一个线程之后,这里最终是要调用一个jni方法

jdk 目录下 /src/share/native/java/lang/ 目录下查询Thread.c 文件

start0 方法最终调用的JVM_StartThread方法. 再看看这个方法。

在hotspot 实现下(注意不是jdk目录了):

/src/share/vm/prims/   下面的 jvm.cpp 文件

找到这个方法:

最终:

继续下去就跟平台有关了,考虑到Android底层就是Linux,且如今基本服务器都是部署在Linux环境下,能够直接在Linux目录下找对应的实现:也便是在hotspot 下 src/os/linux/vm/os_linux.cpp 中找到该入口。

熟悉Linux的人应该知道,pthread_create 函数就是Linux下建立线程的系统函数了。这就完整的证实了主流JVM中 Java代码里Thread和最终对应os中的Thread是1:1的关系。

3、Go语言中的协程作了什么

再回到协程,尤为是在Go语言出现之后,协程在很大程度上能够避免由于建立线程过多,最终致使CPU时间片都来作切线程的操做,从而留给线程本身的CPU时间过少的问题。

缘由就在于Go语言中提供的协程在完成咱们开发者须要的并发任务的时候, 它的并发之间的调度是由Go语言自己完成的,并无交给操做系统级别的Thread切换来完成。也就说协程本质上不过是一个个并发的任务而已。

在Go语言中,这些并发的任务之间相互的调度都是由Go语言完成,由极少数的线程来完成n个协程的并发任务,这其中的调度器并无交给操做系统而是交给了本身。

同时在Go中建立一个协程,也仅仅须要4kb的内存而已,这跟OS中建立一个线程所须要的1mb相差甚远。

4、Go和Java在实现并发任务上的不一样

咱们须要注意的是:对于开发者而言,并不关心实现并发任务的究竟是线程仍是进程仍是协程或者是什么其余。咱们只关心提交的并发任务是否能够完成。

来看一下这段极简的Java代码。

package com.wuyue;
  
public class JavaCode {
    public static void main(String[] args) {
  
        new Thread() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("iqoo" + "  " + Thread.currentThread().getName());
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
        new Thread() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("x27" + "  " + Thread.currentThread().getName());
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
  
    }
}

这个执行结果然的很简单, 交错打印的IQOO和x27 分别对应着2个独立的线程。因此Java 对外提供的并发能力就是依靠不一样的Thread来完成。

简单来讲有多少个并发任务,最终反应到JVM和OS中就是有多少个Thread来运行。而后咱们来看看Go语言中协程是如何完成相似的事情的。

package main
  
  
import (
    "fmt"
    "runtime"
    "strconv"
    "time"
  
    "golang.org/x/sys/windows"
)
  
func name(s string) {
    for {
        //为了演示起来方便 咱们每一个协程都是相隔一秒才打印,不然命令行中刷起来太快,很差看执行过程
        time.Sleep(time.Second)
        str := fmt.Sprint(windows.GetCurrentThreadId())
        var s = "iqoo" + s + " belong thread " + str
        fmt.Println(s)
  
    }
}
  
func main() {
    //逻辑cpu数量为4,表明我这个go程序 有4个p可使用。每一个p都会被分配一个系统线程。
    //这里由于我电脑的cpu是i5 4核心的,因此这里返回的是4. 若是你的机器是i7 四核心的,那这里返回值就是8了
    //由于intel的i7 cpu 有超线程技术,简单来讲就是一个cpu核心 能够同时运行2个线程。
    fmt.Println("逻辑cpu数量:" + strconv.Itoa(runtime.NumCPU()))
    str := fmt.Sprint(windows.GetCurrentThreadId())
    fmt.Println("主协程所属线程id =" + str)
    //既然在我机器上golang默认是4个逻辑线程,那我就将同步任务扩大到10个,看看执行结果
    for i := 1; i <= 10; i++ {
        go name(strconv.Itoa(i))
    }
    // 避免程序过快直接结束
    time.Sleep(100 * time.Second)
  
}

能够从下图中看出来,这种交错的并发任务在Go中是能够在一个线程中完成的,也就验证了协程的并发能力并非线程给的,而是交给Go语言自己本身来完成的。

这里要额外注意的是,Go中 有时候会出现协程迁移的状况(即某个协程可能一开始在线程id为5的线程跑,过一会又会去线程id为10的线程跑),这与Go的调度器机制有关,此处就不展开Go调度器这个话题。

只要知道 Go中的多个协程能够在同一个线程上执行并发任务便可。能够理解为Go的并发模型是M(协程数):N(线程数)。其中M远远大于N(指数级的差距). 这个是全部实现协程机制的语言中共有的特性。

5、Kotlin有相似Go中的协程能力吗?

那一样的需求,用Kotlin-JVM能够来完成吗?答案是不能够。简单来讲,若是Kotlin-JVM 能提供Go相似的协程能力,那应该能完成以下的需求(但实际上使用Kotlin语言是没法完成下面的需求的):

  1. N个并发任务分别打印不一样的字符串。就跟上述Go和Java的例子同样。
  2. 在打印的时候须要打印出所属的线程id或者线程name,且这id和name要保证同样。由于只有同样 才能够证实是在一个线程上完成了并发任务,而不是靠JVM的Thread来完成并发任务。

6、Kotlin语言中有“锁”吗?

咱们都知道任何一门现代语言都对外提供了必定的并发能力,且通常都在语言层面提供了“锁”的实现。好比开启10个线程 对一个int变量 进行++操做,要保证打印出来的顺序必定得是1,2,3,4...10. 这样的Java代码很好写,一个synchronized关键字就能够,咱们看看Go中的协程是否有相似的能力?

package main
  
  
import (
    "fmt"
    "strconv"
    "sync"
    "time"
  
    "golang.org/x/sys/windows"
)
  
var Mutex sync.Mutex
  
var i = 0
  
func name(s string) {
    Mutex.Lock()
    str := fmt.Sprint(windows.GetCurrentThreadId())
    fmt.Println("i==" + strconv.Itoa(i) + "  belong thread id " + str)
    i++
    defer Mutex.Unlock()
  
}
  
func main() {
    for i := 1; i <= 10; i++ {
        go name(strconv.Itoa(i))
    }
    // 避免程序过快直接结束
    time.Sleep(100 * time.Second)
  
}

执行结果很清楚的能够看到,Go中的协程也是有完整的锁实现的。那么Kotlin-JVM的协程有没有相似的锁的实现呢?通过一番搜索,咱们首先看看这个Kotlin官方论坛中的讨论https://discuss.kotlinlang.org/t/concurrency-in-kotlin/858

这里要提一下的是,不少人都觉得Kotlin是谷歌出的,是谷歌的亲儿子,实际上这是一种错误的想法。Kotlin是JB Team的产物,并非谷歌亲自操刀开发的,最多算是个谷歌的干儿子。这个JB Team 不少人应该知道,是IDEA的开发团队Android Studio也是脱胎自 IDEA。  

关于这个讨论,JB Team的意思是说 Kotlin 在本身的语言级别并无实现一种同步机制,仍是依靠的 Kotlin-JVM中的 Java关键字。尤为是synchronized。既然并发的机制都是依靠的JVM中的sync或者是lock来保证,为什么称之为本身是协程的?

咱们知道在主流JVM的实现中,是没有协程的,实际上JVM也不知道上层的JVM语言究竟是啥,反正JVM只认class文件,至于这个class文件是Java编译出来的,仍是Kotlin编译出来的,或是如groovy等其余语言,那都不重要,JVM不须要知道。

基于这个讨论 咱们能够肯定的是,Kotlin语言没有提供锁的关键字,全部的锁实现都交给了JVM本身处理。其实就是交给线程来处理了。也就是说,虽然 Kotlin-JVM 声称本身是协程,但实际上干活的仍是JVM中Thread那一套东西。

写一个简单的代码验证一下,简单写一个Kotlin的类,由于Kotlin自己没有提供同步的关键字,因此这里就用Kotlin官方提供的sync注解。

class PrintTest {
    @Synchronized fun print(){
        println("hello world")
    }
  
    @Synchronized fun print2(){
        println("hello world")
    }
}

而后咱们反编译看看这个东西究竟是啥。

7、Kotlin将来会支持真协程吗?

到了这里,是否说Kotlin 彻底是不支持协程的呢?我认为这种说法也是不许确的,只能说Kotlin-JVM 这个组合是不支持协程的。例如咱们在IDEA中新建Kotlin工程的时候。

能够看出来,这里是有选项的,上述的验证,咱们只验证了 Kotlin-JVM 是不支持协程的。那么有没有一种Kotlin-x 的东西是支持协程的呢?答案是还真可能有。具体参见官方文档中Kotlin-Native 平台对 并发能力的描述:

https://kotlinlang.org/docs/reference/native/concurrency.html(Kotlin-native平台就是直接将Kotlin-native编译成对应平台的可执行文件也就是机器码,并不须要相似于JVM这样的虚拟机了)。

我大概翻译一下其中的几个要点:Kotlin-Native的并发能力不鼓励使用带有互斥代码块和条件变量的经典的面向线程的并发模型,由于该模型容易出错且不可靠。开篇的这句话直接diss的就是JVM的并发模型。而后继续往下看还有惊喜:

注意看第一句话,意思就是Kotlin-native提供了一种worker的机制 来替代线程。目前来看能替代线程的东西也就只有协程了。也就是提及码在Kotlin-native这个平台上,Kotlin是真的想提供协程能力的。目前Kotlin-Native并无正式发布,咱们在idea上新建Kotlin工程的时候并无看到有Kotlin-Native这个选项。且Kotlin-Native目前仅支持linux和mac平台,不支持windows。有兴趣且有条件的同窗能够自行搜索Kotlin-Native的编译方法。

8、主流JVM有计划支持协程吗?

通过前文的分析,咱们知道至少目前来看主流的JVM实现中是没有协程的实现的。可是已经有很多团队在朝着这方面努力,好比说 quasar这个库,利用字节码注入的方法能够实现协程的效果。

在这个做者加入Oracle以前,OPENJDK也一直在往协程上努力,项目名loom,这个应该是开源社区中一直在作的标准协程实现了。此外在生产环境中已经协程上线的效果能够看文章《重塑云上的 Java 语言》

9、Kotlin中的协程究竟是啥?

那么既然证实了,Kotlin-JVM中的协程并非真协程,那么这个东西究竟是什么,应该怎么用?

我的理解Kotlin-JVM的线程应该就仅仅是针对Java中的Thread作了一次更友好的封装。让咱们更方便的使用Java中的线程才是Kotlin-JVM中的协程的真正目的。

本质上和Handler,AsyncTask,RxJava 基本是一致的。只不过Kotlin中的协程比他们更方便一些。这其中最核心的是suspend这个Kotlin协程中的关键字。

class MainActivity : AppCompatActivity() {
  
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        GlobalScope.launch(Dispatchers.Main) {
            getInfo()
            getInfoNoContext()
            Log.v("wuyue", "我又切回来了 in thread " + Thread.currentThread().name)
        }
    }
  
    /**
     * 挂起就是切换线程 没其余做用,最多就是切到其余线程之后还能够自动切回来,避免过多的callback
     * 全部被suspend标记的函数 要么在协程里被调用,要么在其余挂起函数里被调用,不然就没法实现
     * 切走之后又能够切回来的效果
     */
    suspend fun getInfo() {
        /**
         * withContext挂起函数 内部实现了挂起的流程,suspend其实并无这个功能
         * kotlin中有不少挂起函数,withContext 应该是最经常使用的
         */
        withContext(Dispatchers.IO) {
            Log.v("wuyue", "getInfo in thread " + Thread.currentThread().name)
        }
    }
  
    /**
     * 这个函数 虽然用suspend标记 可是并无 用withContext 指定挂起,
     * 因此是没办法实现切线程的做用的,天然而然也就没法实现 所谓的挂起了
     * 我的理解这个suspend关键字的做用就是提醒 调用者注意 你若是调用的是一个被suspend标记的函数
     * 那么必定要注意 这个函数多是一个后台任务,是一个耗时的操做,你须要在一个协程里使用他。
     * 若是不在协程里使用,那么kotlin的编译 就会直接报错了。
     *
     *
     * 这点其实对于android来说仍是颇有用的,你全部认为耗时的操做均可以用suspend来标记,而后在内部指定
     * 这个协程的thread 为 io thread, 若是调用者没有用launch来 call 这个方法,那么编译就报错。
     * 天然而然就避免了不少 主线程操做io的问题
     *
     */
    suspend fun getInfoNoContext() {
        Log.v("wuyue", "getInfoNoContext in thread " + Thread.currentThread().name)
    }
  
}

这段代码很简单,能够多看一下注释。不少人都会被所谓Kotlin协程的非阻塞式吓到,其实你就理解成Kotlin中所宣传的非阻塞式,无非是用阻塞的写法来完成非阻塞的任务而已。

试想一下,咱们上述Kotlin中的代码 若是用Thread来写,就会比较麻烦了,甚至还须要用到回调(若是你不用handler的话)。这一点上Kotlin 协程的做用和RxJava实际上是一致的,只不过Kotlin作的更完全,比RxJava更优雅更方便更简洁。

考虑一种稍微复杂的场景,某个页面须要2个接口都返回之后才能刷新展现,此种需求,若是用原生的Java concurrent并发包是能够作的,可是比较麻烦,要考虑各类异常带来的问题。

比较好的实现方式是用RxJava的zip操做符来作,在有了Kotlin之后,若是利用Kotlin,这段代码甚至会比zip操做符还要简单。例如:

class MainActivity : AppCompatActivity() {
  
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        GlobalScope.launch(Dispatchers.Main) {
            Log.v("wuyue", "time 1==" + System.currentTimeMillis())
            val sum = withContext(Dispatchers.IO) {
                val requestA = async { requestA() }
                val requestB = async { requestB() }
                requestA.await() +"_____" +requestB.await()
            }
            Log.v("wuyue", "time 2==" + System.currentTimeMillis() + " get sum=" + sum)
        }
    }
  
    /**
     * 3s之后 才拿到请求结果 IQOO
     */
    fun requestA(): String {
        sleep(3 * 1000)
        Log.v("wuyue", "requestA in " + Thread.currentThread().name)
        return "IQOO"
    }
  
    /**
     * 5秒之后拿到请求结果 B
     */
    fun requestB(): String {
        sleep(5 * 1000)
        Log.v("wuyue", "requestB in " + Thread.currentThread().name)
        return "X27"
    }
  
}

能够看出来,咱们的2个请求分别在不同的Thread中完成,而且回调到主线程的时机也差很少花了5s的时间,证实这2个request是并行请求的。

10、总结

最后对本文作一个总结:

  1.  Kotlin-JVM中所谓的协程是假协程,本质上仍是一套基于原生Java Thread API 的封装。和Go中的协程彻底不是一个东西,不要混淆,更谈不上什么性能更好。
  2.   Kotlin-JVM中所谓的协程挂起,就是开启了一个子线程去执行任务(不会阻塞原先Thread的执行,要理解对于CPU来讲,在宏观上每一个线程获得执行的几率都是相等的),仅此而已,没有什么其余高深的东西。
  3.   Kotlin-Native是有机会实现完整真协程方案的。虽然我我的不认为JB TEAM 在这方面能比Go作的更好,因此这个项目意义并非很大。
  4.   Kotlin-JVM中的协程最大的价值是写起来比RxJava的线程切换还要方便。几乎就是用阻塞的写法来完成非阻塞的任务。
  5.   对于Java来讲,无论你用什么方法,只要你没有魔改JVM,那么最终你代码里start几个线程,操做系统就会建立几个线程,是1比1的关系。
  6.   OpenJDK正在作JVM的协程实现,项目名称为loom,有兴趣的同窗能够查看对应资料。
  7.   Kotlin官网中那个建立10w个Kotlin协程没有oom的例子其实有误导性,本质上那10w个Kotlin协程就是10w个并发任务仅此而已,他下面运行的就是一个单线程的线程池。你往一个线程池里面丢多少个任务都不会OOM的(前提是你的线程池建立的时候设定了对应的拒绝策略,不然无界队列下,任务过多必定会OOM),由于在运行的始终是那几个线程。
  • 参考资料
  1. https://www.zhihu.com/question/23290260
  2. http://www.javashuo.com/article/p-smzycaos-en.html
  3. https://www.zhihu.com/question/263955521
  4. https://kaixue.io/kotlin-coroutines-1/

更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:labs2020 联系。

相关文章
相关标签/搜索