為什么我放棄使用 Kotlin 中的協(xié)程?
實(shí)不相瞞,我對(duì) Kotlin 這門編程語言非常喜歡,盡管它有一些缺點(diǎn)和奇怪的設(shè)計(jì)選擇。我曾經(jīng)參與過一個(gè)使用 Kotlin、Kotlin 協(xié)程(coroutine, 下同)和基于協(xié)程的服務(wù)器框架 KTOR 的中型項(xiàng)目。這個(gè)技術(shù)組合提供了很多優(yōu)點(diǎn),但是我也發(fā)現(xiàn),與常規(guī)的 Spring Boot 相比,它們很難使用。
聲明:我無意抨擊相關(guān)技術(shù),我的目的僅是分享我的使用體驗(yàn),并解釋為什么我以后不再考慮使用。
調(diào)試
請(qǐng)看下面一段代碼。
- suspend fun retrieveData(): SomeData {
- val request = createRequest()
- val response = remoteCall(request)
- return postProcess(response)
- }
- private suspend fun remoteCall(request: Request): Response {
- // do suspending REST call
- }
假設(shè)我們要調(diào)試 retrieveData 函數(shù),可以在第一行中放置一個(gè)斷點(diǎn)。然后啟動(dòng)調(diào)試器(我使用的是 IntelliJ),它在斷點(diǎn)處停止。現(xiàn)在我們執(zhí)行一個(gè) Step Over(跳過調(diào)用 createRequest),這也正常。但是如果再次 Step Over,程序就會(huì)直接運(yùn)行,調(diào)用 remoteCall() 之后不會(huì)停止。
為什么會(huì)這樣?JVM 調(diào)試器被綁定到一個(gè) Thread 對(duì)象上。當(dāng)然,這是一個(gè)非常合理的選擇。然而,當(dāng)引入?yún)f(xié)程之后,一個(gè)線程不再做一件事。仔細(xì)一看:remoteCall(request) 調(diào)用的是一個(gè) suspend 函數(shù),雖然我們?cè)谡{(diào)用它的時(shí)候并沒有在語法中看到它。那么會(huì)發(fā)生什么?我們執(zhí)行調(diào)試器 "step over ",調(diào)試器運(yùn)行 remoteCall 的代碼并等待。
這就是難點(diǎn)所在:當(dāng)前線程(我們的調(diào)試器被綁定到該線程)只是我們的coroutine 的執(zhí)行者。當(dāng)我們調(diào)用 suspend 函數(shù)時(shí),會(huì)發(fā)生的情況是,在某個(gè)時(shí)刻,suspend 函數(shù)會(huì) yield。這意味著另外一個(gè) Thread 將繼續(xù)執(zhí)行我們的方法。我們有效地欺騙了調(diào)試器。
我發(fā)現(xiàn)的唯一的解決方法是在我想執(zhí)行的行上放置一個(gè)斷點(diǎn),而不是使用Step Over。不用說,這是個(gè)大麻煩。而且很顯然,這不僅僅是我一個(gè)人的問題。
此外,在一般的調(diào)試中,很難確定一個(gè)單一的 coroutine 當(dāng)前在做什么,因?yàn)樗诰€程之間跳躍。當(dāng)然,coroutine 是有名字的,你可以在日志中不僅打印線程,還可以打印 coroutine 的名字,但根據(jù)我的經(jīng)驗(yàn),調(diào)試基于 coroutine 的代碼所需的心智負(fù)擔(dān),要比基于線程的代碼高很多。
REST 調(diào)用中綁定 context 數(shù)據(jù)
在微服務(wù)上開發(fā),一個(gè)常見的設(shè)計(jì)模式是,接收一個(gè)某種形式認(rèn)證的 REST 調(diào)用,并將相同的認(rèn)證傳遞給其他微服務(wù)的所有內(nèi)部調(diào)用。在最簡(jiǎn)單的情況下,我們至少要保留調(diào)用者的用戶名。
然而,如果這些對(duì)其他微服務(wù)的調(diào)用在我們調(diào)用棧中嵌套了 10 層深度怎么辦?我們當(dāng)然不希望在每個(gè)函數(shù)中都傳遞一個(gè)認(rèn)證對(duì)象作為參數(shù)。我們需要某種形式的 "context",這種 context 是隱性存在的。
在傳統(tǒng)的基于線程的框架中,如 Spring,解決這個(gè)問題的方法是使用 ThreadLocal 對(duì)象。這使得我們可以將任何一種數(shù)據(jù)綁定到當(dāng)前線程。只要一個(gè)線程對(duì)應(yīng)一個(gè) REST 調(diào)用(你應(yīng)該始終以這個(gè)為目標(biāo)),這正是我們需要的。這個(gè)模式的很好的例子是 Spring 的 SecurityContextHolder。
對(duì)于 coroutine,情況就不同了。一個(gè) ThreadLocal 不再對(duì)應(yīng)一個(gè)協(xié)程,因?yàn)槟愕墓ぷ髫?fù)載會(huì)從一個(gè)線程跳到另一個(gè)線程;不再是一個(gè)線程在其整個(gè)生命周期內(nèi)伴隨一個(gè)請(qǐng)求。在 Kotlin coroutine 中,有 CoroutineContext。本質(zhì)上,它不過是一個(gè) HashMap,與 coroutine 一起攜帶(無論它運(yùn)行在哪個(gè)線程上)。它有一個(gè)可怕的過度設(shè)計(jì)的 API,使用起來很麻煩,但這不是這里的主要問題。
真正的問題是,coroutine 不會(huì)自動(dòng)繼承上下文。
例如:
- suspend fun sum(): Int {
- val jobs = mutableListOf<Deferred<Int>>()
- for(child in children){
- jobs += async { // we lose our context here!
- child.evaluate()
- }
- }
- return jobs.awaitAll().sum()
- }
每當(dāng)你調(diào)用一個(gè) coroutine builder,如 async、runBlocking 或 launch,你將(默認(rèn)情況下)失去你當(dāng)前的 coroutine 上下文。你可以通過將上下文顯式地傳遞到 builder 方法中來避免這種情況,但是上帝保佑你不要忘記這樣做(編譯器不會(huì)管這些!)。
一個(gè)子 coroutine 可以從一個(gè)空的上下文開始,如果有一個(gè)上下文元素的請(qǐng)求進(jìn)來,但沒有找到任何東西,可以向父 coroutine 上下文請(qǐng)求該元素。然而,在 Kotlin 中不會(huì)發(fā)生這種情況,開發(fā)人員需要手動(dòng)完成,每一次都是如此。
如果你對(duì)這個(gè)問題的細(xì)節(jié)感興趣,我建議你看看這篇博文。
https://blog.tpersson.io/2018/04/22/emulating-request-scoped-objects-with-kotlin-coroutines/
synchronized 不再如你想的那樣工作
在 Java 中處理鎖或 synchronized 同步塊時(shí),我考慮的語義通常是 "當(dāng)我在這個(gè)塊中執(zhí)行時(shí),其他調(diào)用不能進(jìn)入"。當(dāng)然“其他調(diào)用”意味著存在某種身份,在這里就是線程,這應(yīng)該在你的腦海中升起一個(gè)大紅色的警告信號(hào)。
看看下面的例子。
- val lock = ReentrantLock()
- suspend fun doWithLock(){
- lock.withLock {
- callSuspendingFunction()
- }
- }
這個(gè)調(diào)用很危險(xiǎn),即使 callSuspendingFunction() 沒有做任何危險(xiǎn)的操作,代碼也不會(huì)像你想象的那樣工作。
- 進(jìn)入同步鎖
- 調(diào)用 suspend 功能
- 協(xié)程 yield,當(dāng)前線程仍然持有鎖。
- 另一個(gè)線程繼續(xù)我們的 coroutine
- 還是同一個(gè)協(xié)程,但我們不再是鎖的 owner 了!
潛在的沖突、死鎖或其他不安全的情況數(shù)量驚人。你可能會(huì)說,我們只是需要設(shè)計(jì)我們的代碼來處理這個(gè)問題。我同意,然而我們談?wù)摰氖?JVM。那里有一個(gè)龐大的 Java 庫(kù)生態(tài)。而它們并沒有做好處理這些情況的準(zhǔn)備。
這里的結(jié)果是:當(dāng)你開始使用 coroutine 的時(shí)候,你就放棄了使用很多 Java 庫(kù)的可能性,因?yàn)樗鼈兡壳爸荒芄ぷ髟诨诰€程的環(huán)境。
單機(jī)吞吐量與水平擴(kuò)展
對(duì)于服務(wù)器端來說,coroutine 的一大優(yōu)勢(shì)是,一個(gè)線程可以處理更多的請(qǐng)求;當(dāng)一個(gè)請(qǐng)求等待數(shù)據(jù)庫(kù)響應(yīng)時(shí),同一個(gè)線程可以愉快地服務(wù)另一個(gè)請(qǐng)求。特別是對(duì)于 I/O 密集型任務(wù),這可以提高吞吐量。
然而,正如這篇博文所希望向您展示的那樣,在許多層面上,使用 coroutine 都有一個(gè)非零成本的開銷。
由此產(chǎn)生的問題是:這個(gè)收益是否值得這個(gè)成本?而在我看來,答案是否定的。在云和微服務(wù)環(huán)境中,有一些現(xiàn)成的擴(kuò)展機(jī)制,無論是 Google AppEngine、AWS Beanstalk 還是某種形式的 Kubernetes。如果當(dāng)前負(fù)載增加,這些技術(shù)將簡(jiǎn)單地按需生成你的微服務(wù)的新實(shí)例。因此,考慮到引入 coroutine 帶來的額外成本,單一實(shí)例所能處理的吞吐量就不那么重要了。這就降低了我們使用 coroutine 所獲得的價(jià)值。
Coroutine 有其存在的價(jià)值
話說回來,Coroutine 還是有其使用場(chǎng)景。當(dāng)開發(fā)只有一個(gè) UI 線程的客戶端 UI 時(shí),coroutine 可以幫助改善你的代碼結(jié)構(gòu),同時(shí)符合 UI 框架的要求。聽說這個(gè)在安卓系統(tǒng)上很好用。Coroutine 是一個(gè)有趣的主題,然而對(duì)于服務(wù)器端開發(fā)來說,我覺得協(xié)程還差點(diǎn)意思。JVM 開發(fā)團(tuán)隊(duì)目前正在開發(fā) Fiber,本質(zhì)上也是 coroutine,但他們的目標(biāo)是與 JVM 基礎(chǔ)庫(kù)更好共存。這將是有趣的,看它將來如何發(fā)展,以及 Jetbrains 對(duì) Kotlin coroutine 對(duì)此會(huì)有什么反應(yīng)。在最好的情況下,Kotlin coroutine 將來只是簡(jiǎn)單映射到 Fiber 上,而調(diào)試器也能足夠聰明來正確處理它們。
英文原文:
https://dev.to/martinhaeusler/why-i-stopped-using-coroutines-in-kotlin-kg0
本文轉(zhuǎn)載自微信公眾號(hào)「高可用架構(gòu)」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系高可用架構(gòu)公眾號(hào)。