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