Kotlin 協程真的比 Java 線程更高效嗎?

本文首發於 vivo互聯網技術 微信公衆號
連接:mp.weixin.qq.com/s/-OcCDI4L5…
做者:吳越html

網上幾乎所有介紹Kotlin的文章都會說Kotlin的協程是多麼的高效,比線程性能好不少,然而事情的真相真是如此麼?java

協程的概念自己並不新鮮,使用C++加上內嵌彙編,一個基本的協程模型50行代碼以內就能夠徹底搞出來。早在2013年國內就有團隊開源了號稱支持千萬併發的C++協程庫 libco。linux

最近幾年協程的概念愈來愈深刻人心,主要仍是由於Google的Go語言應用範圍愈來愈廣,考慮到目前並無一個通用的協程的定義,因此本文中對協程的定義主要來自於Go。android

1、Kotlin協程在互聯網上的主流定義

問題的討論起源於文章《Go語言出現後,Java仍是最佳選擇嗎?》,因爲以前寫過一段時間Go語言,對Go語言有必定的理解,因此當時我看完這篇文章的時候感到疑惑的是Kotlin到底有沒有完整的實現相似於Go語言中的協程機制?若是有,那麼顯然沒有必要費這麼一大段功夫來魔改JVM的實現。若是沒有,那麼網上那一堆堆的博客難道說的都是錯誤的嗎?例以下面百度搜索的結果:golang

再好比某個Kotlin的視頻教程(我仔細觀看了其中關於協程部分的講解,與網絡上流傳的諸如協程比線程高效是基本一致的)windows

Kotlin官方網站中的例子:bash

這個例子說明用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官方論壇中的討論discuss.kotlinlang.org/t/concurren…

這裏要提一下的是,不少人都覺得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 平臺對 併發能力的描述:

kotlinlang.org/docs/refere…(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. www.zhihu.com/question/23…

  2. juejin.im/post/5b7678…

  3. www.zhihu.com/question/26…

  4. kaixue.io/kotlin-coro…

更多內容敬請關注 vivo 互聯網技術 微信公衆號

注:轉載文章請先與微信號:labs2020 聯繫。

相關文章
相關標籤/搜索