網上幾乎全部介紹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語言是無法完成下面的需求的):
- N個并發任務分别列印不同的字元串。就跟上述Go和Java的例子一樣。
- 在列印的時候需要列印出所屬的線程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的關系。
- OpenJDK正在做JVM的協程實作,項目名稱為loom,有興趣的同學可以檢視對應資料。
- Kotlin官網中那個建立10w個Kotlin協程沒有oom的例子其實有誤導性,本質上那10w個Kotlin協程就是10w個并發任務僅此而已,他下面運作的就是一個單線程的線程池。你往一個線程池裡面丢多少個任務都不會OOM的(前提是你的線程池建立的時候設定了對應的拒絕政策,否則無界隊列下,任務過多一定會OOM),因為在運作的始終是那幾個線程。
- 參考資料