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