【譯】Kotlin 協(xié)程,JVM 線程以及并發(fā)問(wèn)題
原文:Bridging the gap between coroutines, JVM threads, and concurrency problems
作者:Manuel Vivo
譯者:Flywith24
「協(xié)程是輕量級(jí)的線程」,是不是經(jīng)常聽到這樣的描述?這個(gè)描述對(duì)你理解協(xié)程有實(shí)質(zhì)性的幫助嗎?可能沒(méi)有。閱讀本文,您會(huì)對(duì) 「協(xié)程在 JVM 中實(shí)際的執(zhí)行方式」,協(xié)程與線程的關(guān)系以及使用 JVM 線程模型時(shí)不可避免的 「并發(fā)問(wèn)題」 有更多的了解。
協(xié)程與 JVM 線程
協(xié)程旨在簡(jiǎn)化執(zhí)行異步操作的代碼。基于 JVM 的協(xié)程的本質(zhì)是:「?jìng)鬟f給協(xié)程構(gòu)建器的 lambda 代碼塊最終會(huì)在特定的 JVM 線程上執(zhí)行」。如下面這個(gè)簡(jiǎn)單的 斐波那契數(shù)列(譯者注:鏈接已改為百度百科)的計(jì)算:
//?在后臺(tái)線程中計(jì)算第10個(gè)斐波那契數(shù)的協(xié)程
someScope.launch(Dispatchers.Default)?{
????val?fibonacci10?=?synchronousFibonacci(10)
????saveFibonacciInMemory(10,?fibonacci10)
}
private?fun?synchronousFibonacci(n:?Long):?Long?{?/*?...?*/?}
上面的 異步 協(xié)程代碼塊執(zhí)行了同步且阻塞的斐波那契計(jì)算并將結(jié)果保存至內(nèi)存。該代碼塊被 「協(xié)程庫(kù)管理的線程池(通過(guò) Dispatchers.Default 配置)分發(fā)調(diào)度」 并且在未來(lái)的某個(gè)時(shí)刻(取決于線程池的策略)在線程池中的線程執(zhí)行。
請(qǐng)注意,因?yàn)闆](méi)有掛起(suspend),所以上面的代碼會(huì)在一個(gè)線程中執(zhí)行。如果將執(zhí)行的邏輯轉(zhuǎn)移至不同的調(diào)度器(dispatcher),或者代碼塊可能在使用線程池的調(diào)度器中 yield / suspend,則協(xié)程可以在不同的線程中執(zhí)行。
同樣,如果沒(méi)有協(xié)程,也可以使用線程手動(dòng)執(zhí)行上述邏輯,如下所示:
//?創(chuàng)建一個(gè)四個(gè)線程的線程池
val?executorService?=?Executors.newFixedThreadPool(4)
//?在線程池中的線程上調(diào)度并執(zhí)行下面代碼
executorService.execute?{
????val?fibonacci10?=?synchronousFibonacci(10)
????saveFibonacciInMemory(10,?fibonacci10)
}
盡管手動(dòng)管理線程池是可行的,但考慮到協(xié)程內(nèi)置支持取消,更容易處理錯(cuò)誤,使用可以降低內(nèi)存泄露可能性的 結(jié)構(gòu)化并發(fā)(structured concurrency) 以及 Jetpack 庫(kù)的支持,「協(xié)程是 Android 中異步編程的推薦方案。」
背后的原理
開始創(chuàng)建協(xié)程到在線程中執(zhí)行,這過(guò)程發(fā)生了什么?當(dāng)使用標(biāo)準(zhǔn)的協(xié)程構(gòu)建器創(chuàng)建協(xié)程時(shí),您可以指定在特定的 CoroutineDispatcher 執(zhí)行代碼,默認(rèn)將使用 Dispatchers.Default。
「CoroutineDispatcher 負(fù)責(zé)將協(xié)程的執(zhí)行分發(fā)給 JVM 線程」。原理是:當(dāng)使用 CoroutineDispatcher 時(shí),它會(huì)使用 interceptContinuation 攔截協(xié)程,該方法 「將 Continuation 包裝在 DispatchedContinuation 中」。這是可行的,因?yàn)?CoroutineDispatcher 實(shí)現(xiàn)了 ContinuationInterceptor 接口。
如果您閱讀過(guò)我的 協(xié)程工作原理 的文章,您已經(jīng)知道編譯器創(chuàng)建一個(gè)狀態(tài)機(jī),狀態(tài)機(jī)的信息(如下一步需要執(zhí)行的內(nèi)容)保存在 ?Continuation 對(duì)象中。
如果需要在其它 Dispatcher 中執(zhí)行 Continuation,DispatchedContinuation 的 resumeWith 方法負(fù)責(zé)分配給適合的協(xié)程!
此外,「DispatchedContinuation」 是 DispatchedTask,在 JVM 中它是可在 JVM 線程上運(yùn)行的 Runnable 對(duì)象!這很酷不是嗎?當(dāng)指定 CoroutineDispatcher 時(shí),協(xié)程將轉(zhuǎn)換為 DispatchedTask,該DispatchedTask 會(huì)作為一個(gè) Runnable 在 JVM 線程上執(zhí)行!
在創(chuàng)建協(xié)程時(shí) dispatch 方法是如何調(diào)用的呢?使用標(biāo)準(zhǔn)的協(xié)程構(gòu)建器創(chuàng)建協(xié)程,可以指定協(xié)程以 CoroutineStart 類型的 start 參數(shù)。例如,您可以使用 CoroutineStart.LAZY 將其配置為僅在需要時(shí)啟動(dòng)。默認(rèn)情況下,使用 CoroutineStart.DEFAULT 來(lái)根據(jù)其 CoroutineDispatcher 調(diào)度協(xié)程執(zhí)行。

協(xié)程中的代碼塊最終如何在線程中執(zhí)行的圖示
調(diào)度器與線程池
您可以使用 Executor.asCoroutineDispatcher() 擴(kuò)展函數(shù)將協(xié)程轉(zhuǎn)換為 CoroutineDispatcher,從而在您的 app 線程池中執(zhí)行協(xié)程。您也可以使用協(xié)程庫(kù)中的默認(rèn) Dispatchers。
您可以在 createDefaultDispatcher 方法中看到如何初始化 Dispatchers.Default。默認(rèn)情況下使用 DefaultScheduler。如果您查看 Dispatchers.IO 的實(shí)現(xiàn),它還將使用 DefaultScheduler 并允許根據(jù)需要?jiǎng)?chuàng)建至少 64 個(gè)線程。Dispatchers.Default 和 Dispatchers.IO 隱式地連接在一起,因?yàn)樗鼈兪褂孟嗤木€程池。下面我們來(lái)看看使用不同的 Dispatcher 調(diào)用 withContext 的運(yùn)行時(shí)開銷是怎樣的?
線程與 withContext 性能
在 JVM 中,如果創(chuàng)建的線程多于可用的 CPU 核心數(shù),則在線程之間進(jìn)行切換會(huì)帶來(lái)一些運(yùn)行時(shí)開銷。上下文切換 的成本并不低!操作系統(tǒng)需要保存和恢復(fù)執(zhí)行上下文,CPU 需要花時(shí)間調(diào)度線程而不是運(yùn)行實(shí)際的 app 工作。除此之外,如果線程正在運(yùn)行的代碼阻塞了,也可能會(huì)發(fā)生上下文切換。如果線程是這種情況,將 withContext 與不同的 Dispatchers 配合使用是否會(huì)對(duì)性能造成損失?
幸運(yùn)的是,如您所料,線程池為我們管理了這些復(fù)雜的場(chǎng)景,并嘗試盡可能優(yōu)化被執(zhí)行的工作(這就是在線程池上執(zhí)行工作比手動(dòng)在線程中執(zhí)行工作更好的原因)。協(xié)程也從中受益(因?yàn)樗鼈兪窃诰€程池中調(diào)度的)!最重要的是,協(xié)程不阻塞線程,而是 suspend 工作!甚至更有效率!
默認(rèn)情況下,CoroutineScheduler 是 JVM 實(shí)現(xiàn)中使用的線程池,「它以最有效的方式將分派的協(xié)程分配給工作線程」。由于 Dispatchers.Default 和 Dispatchers.IO 使用相同的線程池,因此優(yōu)化了它們之間的切換,以盡可能避免線程切換。協(xié)程庫(kù)可以優(yōu)化這些調(diào)用,保留在相同的調(diào)度器(dispatcher)和線程上,并遵循一個(gè)快速路徑(fast-path)。
由于 Dispatchers.Main 通常是 UI app 中不同的線程,因此在協(xié)程中 Dispatchers.Default 和 Dispatchers.Main 之間切換不會(huì)帶來(lái)巨大的性能成本,因?yàn)閰f(xié)程只是掛起(即停止在一個(gè)線程中執(zhí)行),并被調(diào)度到在另一個(gè)線程中執(zhí)行。
協(xié)程中的并發(fā)問(wèn)題
由于不同線程上的調(diào)度工作非常簡(jiǎn)單,協(xié)程 「確實(shí)」 使異步編程更容易。另一方面,這種簡(jiǎn)單性可能是一把雙刃劍:「由于協(xié)程運(yùn)行在 JVM 線程模型上,它們不能簡(jiǎn)單地?cái)[脫線程模型帶來(lái)的并發(fā)問(wèn)題。」 因此,您必須注意避免并發(fā)問(wèn)題。
多年來(lái),不可變性(immutability)等良好實(shí)踐已經(jīng)緩解了您可能遇到的一些與線程有關(guān)的問(wèn)題。然而,有些場(chǎng)景下不適合不可變性。所有并發(fā)問(wèn)題的根源在于狀態(tài)管理!特別是在多線程環(huán)境中訪問(wèn) 「可變狀態(tài)」。
多線程應(yīng)用中的操作順序是不可預(yù)測(cè)的。除了編譯優(yōu)化會(huì)帶來(lái)有序性問(wèn)題,上下文切換還可能帶來(lái)原子性問(wèn)題(譯者注:并發(fā)問(wèn)題可參考 譯者的筆記)。如果在訪問(wèn)可變狀態(tài)時(shí)未采取必要的預(yù)防措施,則線程可能會(huì)看到過(guò)時(shí)的數(shù)據(jù),丟失更新或遭受 競(jìng)爭(zhēng)狀況 的困擾。
請(qǐng)注意,可變狀態(tài)和訪問(wèn)順序的問(wèn)題不是 JVM 特有的,這些問(wèn)題也會(huì)影響其它平臺(tái)的協(xié)程。
使用協(xié)程的 app 本質(zhì)上是一個(gè)多線程 app。「使用協(xié)程并且包含可變狀態(tài)的類必須采取預(yù)防措施以確保執(zhí)行結(jié)果符合預(yù)期」,即確保在協(xié)程中執(zhí)行的代碼能看到最新版本的數(shù)據(jù)。這樣,不同的線程不會(huì)互相干擾。并發(fā)問(wèn)題可能會(huì)導(dǎo)致非常小的錯(cuò)誤,難以調(diào)試,甚至是 heisenbug!
這類問(wèn)題并不罕見。例如可能一個(gè)類需要將已登錄用戶的信息保留在內(nèi)存中,或者在應(yīng)用運(yùn)行時(shí)緩存某些值。如不小心,并發(fā)問(wèn)題仍會(huì)在協(xié)程中發(fā)生!使用 withContext(defaultDispatcher) 的掛起函數(shù)不能總是在同一線程中執(zhí)行!
假設(shè)我們有一個(gè)類可以緩存用戶進(jìn)行的交易。如果無(wú)法正確訪問(wèn)緩存,如下示例,則可能會(huì)發(fā)生并發(fā)錯(cuò)誤:
class?TransactionsRepository(
??private?val?defaultDispatcher:?CoroutineDispatcher?=?Dispatchers.Default
)?{
??private?val?transactionsCache?=?mutableMapOf()
??private?suspend?fun?addTransaction(user:?User,?transaction:?Transaction)?=
????//?小心!訪問(wèn)緩存是不受保護(hù)的。
????//?并發(fā)錯(cuò)誤可能發(fā)生:線程可以看到過(guò)時(shí)的數(shù)據(jù),競(jìng)爭(zhēng)條件可能發(fā)生
????withContext(defaultDispatcher)?{
??????if?(transactionsCache.contains(user))?{
????????val?oldList?=?transactionsCache[user]
????????val?newList?=?oldList!!.toMutableList()
????????newList.add(transaction)
????????transactionsCache.put(user,?newList)
??????}?else?{
????????transactionsCache.put(user,?listOf(transaction))
??????}
????}
}
即使我們討論的是 Kotlin,《Java 并發(fā)編程實(shí)踐》(作者:Brian Goetz)一書也是了解更多這部分內(nèi)容和 JVM 系統(tǒng)并發(fā)問(wèn)題的絕佳資源。或者參考 Jetbrains 關(guān)于 共享可變狀態(tài)和并發(fā) 的文檔。
保護(hù)可變狀態(tài)
如何保護(hù)可變狀態(tài)或找到一個(gè)好的 同步 策略,完全取決于數(shù)據(jù)的性質(zhì)和所涉及的操作。本節(jié)旨在使您意識(shí)到可能會(huì)遇到的并發(fā)問(wèn)題,而不是列出保護(hù)可變狀態(tài)的所有不同方法和 API。盡管如此,您還是可以從這里獲得一些技巧和 API,以使得可變變量線程安全。
封裝
可變狀態(tài)應(yīng)由一個(gè) class 封裝并擁有。該類集中對(duì)狀態(tài)的訪問(wèn),并根據(jù)場(chǎng)景使用更適合的同步策略來(lái)保護(hù)讀寫操作。
線程約束
有一種解決方案是限制對(duì)一個(gè)線程的讀/寫訪問(wèn)。可以使用隊(duì)列以 生產(chǎn)者-消費(fèi)者 的方式完成對(duì)可變狀態(tài)的訪問(wèn)。JetBrains 對(duì)此有一個(gè)很好的文檔。
不要重復(fù)造輪子
在 JVM 中,您可以使用線程安全的數(shù)據(jù)結(jié)構(gòu)來(lái)保護(hù)可變變量。例如,對(duì)于簡(jiǎn)單計(jì)數(shù)器,可以使用 AtomicInteger。為了保護(hù)上面代碼的 Map,可以使用 ConcurrentHashMap。ConcurrentHashMap 是一個(gè)線程安全的同步集合,可優(yōu)化 Map 的讀寫吞吐量。
請(qǐng)注意,線程安全的數(shù)據(jù)結(jié)構(gòu)不能防止調(diào)用方排序問(wèn)題,它們只是確保內(nèi)存訪問(wèn)是原子性的。當(dāng)邏輯不太復(fù)雜時(shí),它們有助于避免使用鎖。例如,它們不能在上面顯示的 transactionCache 示例中使用,因?yàn)椴僮黜樞蚝退鼈冎g的邏輯需要線程和訪問(wèn)保護(hù)。
同樣,這些線程安全數(shù)據(jù)結(jié)構(gòu)中的數(shù)據(jù)必須是不可變的或受保護(hù)的,以防止在修改已存儲(chǔ)在其中的對(duì)象時(shí)出現(xiàn)競(jìng)爭(zhēng)條件。
自定義解決方案
如果您有需要同步的復(fù)合操作,則 @Volatile 變量或線程安全的數(shù)據(jù)結(jié)構(gòu)將無(wú)濟(jì)于事!內(nèi)置的 @Synchronized 注解可能不夠精細(xì),無(wú)法提高的效率。
在這種場(chǎng)景下,您可能需要使用并發(fā)工具(如 latch,信號(hào)量 或 屏障)創(chuàng)建自己的同步機(jī)制。其它場(chǎng)景,您可以使用鎖或互斥鎖保護(hù)代碼的多線程訪問(wèn)。
Kotlin 中的 Mutex 具有 lock 和 unlock 的掛起函數(shù)以用來(lái)手動(dòng)保護(hù)協(xié)程代碼。Mutex.withLock 擴(kuò)展函數(shù)使用很簡(jiǎn)單:
class?TransactionsRepository(
??private?val?defaultDispatcher:?CoroutineDispatcher?=?Dispatchers.Default
)?{
??//?Mutex?保護(hù)緩存可變狀態(tài)
??private?val?cacheMutex?=?Mutex()
??private?val?transactionsCache?=?mutableMapOf()
??private?suspend?fun?addTransaction(user:?User,?transaction:?Transaction)?=
????withContext(defaultDispatcher)?{
??????//?Mutex?使?讀&寫?緩存的操作?線程安全
??????cacheMutex.withLock?{
????????if?(transactionsCache.contains(user))?{
??????????val?oldList?=?transactionsCache[user]
??????????val?newList?=?oldList!!.toMutableList()
??????????newList.add(transaction)
??????????transactionsCache.put(user,?newList)
????????}?else?{
??????????transactionsCache.put(user,?listOf(transaction))
????????}
??????}
????}
}
由于使用 Mutex 的協(xié)程在可以繼續(xù)執(zhí)行前會(huì)暫停執(zhí)行,因此它比阻塞線程的 JVM 鎖要有效得多。在協(xié)程中使用 JVM 同步類時(shí)要小心,因?yàn)檫@可能會(huì)阻塞在其中執(zhí)行協(xié)程的線程并產(chǎn)生 liveness 問(wèn)題。
傳遞給協(xié)程構(gòu)建器的代碼塊最終在一個(gè)或多個(gè) JVM 線程上執(zhí)行。因此,協(xié)程運(yùn)行在 JVM 線程模型中并受其所有約束。使用協(xié)程,仍會(huì)寫出錯(cuò)誤的多線程代碼。因此,在代碼中訪問(wèn)共享的可變狀態(tài)要小心!
譯文完。
譯者總結(jié)
基于 JVM 的 Kotlin 協(xié)程本質(zhì)上是基于 JVM 線程池工作的 協(xié)程是 Android 中異步編程的推薦方案 協(xié)程也存在并發(fā)問(wèn)題,開發(fā)者需要注意并解決 并發(fā)問(wèn)題的根源在于狀態(tài)管理 保護(hù)可變狀態(tài)需要視具體情況而定,但有一些小技巧
推薦閱讀
并發(fā)問(wèn)題出現(xiàn)的源頭
線程池
碼上開學(xué):Kotlin 的協(xié)程用力瞥一眼 - 學(xué)不會(huì)協(xié)程?很可能因?yàn)槟憧催^(guò)的教程都是錯(cuò)的
碼上開學(xué):Kotlin 協(xié)程的掛起好神奇好難懂?今天我把它的皮給扒了
碼上開學(xué):到底什么是「非阻塞式」掛起?協(xié)程真的更輕量級(jí)嗎
關(guān)于我
人總是喜歡做能夠獲得正反饋(成就感)的事情,如果感覺本文內(nèi)容對(duì)你有幫助的話,麻煩點(diǎn)亮一下??,這對(duì)我很重要哦~
我是 Flywith24,「人只有通過(guò)和別人的討論,才能知道我們自己的經(jīng)驗(yàn)是否是真實(shí)的」,加我微信交流,讓我們共同進(jìn)步。
微信:Flywith24
