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

简介: 都说 Kotlin协程比线程性能好很多,然而事情的真相真是如此么?

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

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

最近几年协程的概念越来越深入人心,主要还是因为Google的Go语言应用范围越来越广,考虑到目前并没有一个通用的协程的定义,所以本文中对协程的定义主要来自于Go。

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

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

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

 Kotlin官方网站中的例子:

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

二、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的关系。

三、Go语言中的协程做了什么

再回到协程,尤其是在Go语言出现以后,协程在很大程度上可以避免因为创建线程过多,最终导致CPU时间片都来做切线程的操作,从而留给线程自己的CPU时间过少的问题。

原因就在于Go语言中提供的协程在完成我们开发者需要的并发任务的时候, 它的并发之间的调度是由Go语言本身完成的,并没有交给操作系统级别的Thread切换来完成。也就说协程本质上不过是一个个并发的任务而已。

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

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

四、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(指数级的差距). 这个是所有实现协程机制的语言中共有的特性。

五、Kotlin有类似Go中的协程能力吗?

那同样的需求,用Kotlin-JVM可以来完成吗?答案是不可以。简单来说,如果Kotlin-JVM 能提供Go类似的协程能力,那应该能完成如下的需求(但实际上使用Kotlin语言是无法完成下面的需求的):

  1. N个并发任务分别打印不同的字符串。就跟上述Go和Java的例子一样。

  2. 在打印的时候需要打印出所属的线程id或者线程name,且这id和name要保证一样。因为只有一样 才可以证明是在一个线程上完成了并发任务,而不是靠JVM的Thread来完成并发任务。

六、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")
    }
}

然后我们反编译看看这个东西到底是啥。

七、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的编译方法。

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

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

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

九、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是并行请求的。

十、总结

最后对本文做一个总结:

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的关系。

  1. OpenJDK正在做JVM的协程实现,项目名称为loom,有兴趣的同学可以查看对应资料。

  2. Kotlin官网中那个创建10w个Kotlin协程没有oom的例子其实有误导性,本质上那10w个Kotlin协程就是10w个并发任务仅此而已,他下面运行的就是一个单线程的线程池。你往一个线程池里面丢多少个任务都不会OOM的(前提是你的线程池创建的时候设定了对应的拒绝策略,否则无界队列下,任务过多一定会OOM),因为在运行的始终是那几个线程。

  • 参考资料
  1. https://www.zhihu.com/question/23290260

  2. https://juejin.im/post/5b7678f451882533110e8948

  3. https://www.zhihu.com/question/263955521

  4. https://kaixue.io/kotlin-coroutines-1/
目录
相关文章
|
10天前
|
算法 Java 开发者
Java中的多线程编程:概念、实现与性能优化
【4月更文挑战第9天】在Java编程中,多线程是一种强大的工具,它允许开发者创建并发执行的程序,提高系统的响应性和吞吐量。本文将深入探讨Java多线程的核心概念,包括线程的生命周期、线程同步机制以及线程池的使用。接着,我们将展示如何通过继承Thread类和实现Runnable接口来创建线程,并讨论各自的优缺点。此外,文章还将介绍高级主题,如死锁的预防、避免和检测,以及如何使用并发集合和原子变量来提高多线程程序的性能和安全性。最后,我们将提供一些实用的性能优化技巧,帮助开发者编写出更高效、更稳定的多线程应用程序。
|
8天前
|
安全 算法 Java
深入理解Java并发编程:线程安全与性能优化
【4月更文挑战第11天】 在Java中,高效的并发编程是提升应用性能和响应能力的关键。本文将探讨Java并发的核心概念,包括线程安全、锁机制、线程池以及并发集合等,同时提供实用的编程技巧和最佳实践,帮助开发者在保证线程安全的前提下,优化程序性能。我们将通过分析常见的并发问题,如竞态条件、死锁,以及如何利用现代Java并发工具来避免这些问题,从而构建更加健壮和高效的多线程应用程序。
|
1天前
|
安全 Java
java多线程(一)(火车售票)
java多线程(一)(火车售票)
|
2天前
|
安全 Java 调度
Java并发编程:深入理解线程与锁
【4月更文挑战第18天】本文探讨了Java中的线程和锁机制,包括线程的创建(通过Thread类、Runnable接口或Callable/Future)及其生命周期。Java提供多种锁机制,如`synchronized`关键字、ReentrantLock和ReadWriteLock,以确保并发访问共享资源的安全。此外,文章还介绍了高级并发工具,如Semaphore(控制并发线程数)、CountDownLatch(线程间等待)和CyclicBarrier(同步多个线程)。掌握这些知识对于编写高效、正确的并发程序至关重要。
|
2天前
|
安全 Java 程序员
Java中的多线程并发编程实践
【4月更文挑战第18天】在现代软件开发中,为了提高程序性能和响应速度,经常需要利用多线程技术来实现并发执行。本文将深入探讨Java语言中的多线程机制,包括线程的创建、启动、同步以及线程池的使用等关键技术点。我们将通过具体代码实例,分析多线程编程的优势与挑战,并提出一系列优化策略来确保多线程环境下的程序稳定性和性能。
|
2天前
|
调度 Python
Python多线程、多进程与协程面试题解析
【4月更文挑战第14天】Python并发编程涉及多线程、多进程和协程。面试中,对这些概念的理解和应用是评估候选人的重要标准。本文介绍了它们的基础知识、常见问题和应对策略。多线程在同一进程中并发执行,多进程通过进程间通信实现并发,协程则使用`asyncio`进行轻量级线程控制。面试常遇到的问题包括并发并行混淆、GIL影响多线程性能、进程间通信不当和协程异步IO理解不清。要掌握并发模型,需明确其适用场景,理解GIL、进程间通信和协程调度机制。
18 0
|
2天前
|
缓存 分布式计算 监控
Java并发编程:深入理解线程池
【4月更文挑战第17天】在Java并发编程中,线程池是一种非常重要的技术,它可以有效地管理和控制线程的执行,提高系统的性能和稳定性。本文将深入探讨Java线程池的工作原理,使用方法以及在实际开发中的应用场景,帮助读者更好地理解和使用Java线程池。
|
3天前
|
存储 安全 Java
Java中的容器,线程安全和线程不安全
Java中的容器,线程安全和线程不安全
10 1
|
3天前
|
Java 开发者
Java中多线程并发控制的实现与优化
【4月更文挑战第17天】 在现代软件开发中,多线程编程已成为提升应用性能和响应能力的关键手段。特别是在Java语言中,由于其平台无关性和强大的运行时环境,多线程技术的应用尤为广泛。本文将深入探讨Java多线程的并发控制机制,包括基本的同步方法、死锁问题以及高级并发工具如java.util.concurrent包的使用。通过分析多线程环境下的竞态条件、资源争夺和线程协调问题,我们提出了一系列实现和优化策略,旨在帮助开发者构建更加健壮、高效的多线程应用。
3 0
|
4天前
|
缓存 监控 Java
Java并发编程:线程池与任务调度
【4月更文挑战第16天】Java并发编程中,线程池和任务调度是核心概念,能提升系统性能和响应速度。线程池通过重用线程减少创建销毁开销,如`ThreadPoolExecutor`和`ScheduledThreadPoolExecutor`。任务调度允许立即或延迟执行任务,具有灵活性。最佳实践包括合理配置线程池大小、避免过度使用线程、及时关闭线程池和处理异常。掌握这些能有效管理并发任务,避免性能瓶颈。