Kotlin Coroutines CoroutineContext 的那一兩件事
前言
個人感覺,CoroutineContext 應該是 Coroutines 第一眼看到最難以理解的東西。
Context 的問題是,這個詞彙指的可能是上下文,前後文,或者說是語境,以語文來說, out of context 可以翻譯成斷章取義。
以程式語言來說, Context 賦予了這項實體更具體的意義,以 Coroutines 來說也是如此。
這有點咬文嚼字了,其實 CoroutineContext 講白了,就是「執行 Coroutines 會使用到的元素」,所以如果進去看 CoroutineContext 的 Source Code ,可以看到一個常常被用到的 Interface 叫做 Element。
這也是看 Context 時容易產生的誤區,我們常常會去看 Context 本身是甚麼,但 Context 本身的概念就很抽象,所以我們更該關注的應該是該項作業使用到的 Context 是甚麼。以 CoroutineContext 來說重點是,甚麼東西被當作 CoroutineContext 放進去了?那被當作 CoroutineContext 的東西放進去又做了甚麼?
CoroutineContext 最讓人困惑的東西
使用 CoroutineContext 的時候,我們常常會看到一個寫法是:
第一眼理論上看到,通常反應會是:
然後看了一下那個 + 發現原來是個 open operator 的代號,有 Source Code 可以看:
看到這段 Code,感覺完整了…
看不懂的感覺更完整了。
這裡我們先不要看其他的,單看他回傳的東西,除非傳入的 context (也就是 CoroutineContext + CoroutineContext 的後者)是 EmptyCoroutineContext ,不然最終都會回傳一個 CombinedContext。
也就是說 CoroutineContext + CoroutineContext 最後會得到一個 CombinedContext。
那 CombinedContext 是甚麼玩意?直接來看看它的 source code 唄。
從 get 的部分來看,可以看到 CombinedContext 其實就是一個 "key map" 。說是 " key map " 是講抽象面的,看到 this (這邊用 element) 和 left 有學過資料結構的應該就會有點概念了。更具體的部分是,CoroutineContext 實際上是一個利用 key 去尋找對象的 Linked List 結構,而且該 Linked List 會是 key 唯一的,也就是說 add 進去的 key 如果是 Job,那裡面就只會有一個 Job,並不會有兩個 Job,所以我會說概念上更像是 key map。
以這段 Code 為例:
Dispatchers.IO + job1 + coroutineExceptionHandler
這段具體的意思是,首先將 Dispatchers.IO + job1 合併成
CombinedContext(left= job1, element = Dispatchers.IO)
然後再用 CombinedContext(left= job1, element = Dispatchers.IO) + coroutineExceptionHandler
合併成:
CombinedContext(left= CombinedContext(left= job1, element = Dispatchers.IO), element = coroutineExceptionHandler)
於是我們的 coroutineContext 就會是 CombinedContext(left= CombinedContext(left= job1, element = Dispatchers.IO), element = coroutineExceptionHandler)。
講 plus (其實就是概念上的 add)到這邊,我們轉頭看看 get。
那從 CombinContext 可以看到裝進去的 CoroutineContext 勢必是一個 Element,同樣的從 CoroutineContext 的 Key 去看也勢必會知道 Key 必須要是個 Element ,那樣我們可以看看 Element 裏頭的 get:
如果 key 不同,會得到 null。
那再回頭看看這張:
那從我們得到的 CombinContext 的 get 的部分可以看到該無限迴圈,如果 cur 的 element[key] 不是 null 則直接回傳,若是 null 則會把 next assign 成 left,那若是 left 是 CombinContext 則 cur 會被 assign 成 left,然後再進行一次迴圈,若不是 CombinContext 則會被直接回傳 next[key]
以上面組成的 :CombinedContext(left= CombinedContext(left= job1, element = Dispatchers.IO), element = coroutineExceptionHandler) 為例。
假如我要取得 key 是 job,那流程會是這樣:
首先先做 element[key],那當下的 element 會是 Dispatchers ,不是 Job ,所以回傳 null,若是 null 會往下走,那 left 是 CombinedContext ,所以把 cur 指成 left 再跑一次迴圈。
那再來的 element 的是 coroutineExceptionHandler ,也不是 job,所以一樣得到 null 一樣往下走,看 left,left 不是 CoroutineContext ,所以直接把 left[key] 回傳,那 left 此時是 Job ,則取出 Job 回傳回去。
關於 get 的部分,大概是這樣。
但是,問題來了,我們在使用 plus 的時候,會發現它可以寫成這樣:
這個案例其實寫成這樣會有 compiler error ,說明:
這個訊息大致上在說是 job + job 只是把左邊的 job 覆蓋成右邊的 job,所以這段這樣寫沒意義。
那其實前面加上 Dispatchers 也是一樣的意思,只是因為這行有兩種 key,所以 Compiler 也不敢說這樣寫完全沒意義。
只是,看到這裡不禁讓人好奇,那個 + 究竟是怎麼樣執行,才能讓該 Linked list 只有一種 Key 呢?
關鍵就在 minusKey 之中:
想知道這答案的方法也很單純,直接把變數代進去跑一次流程就知道了:
以這段為例:coroutineContext 會是 CombinedContext(left= CombinedContext(left= coroutineExceptionHandler, element = job1), element = Dispatchers.IO)
那我們在 CoroutineContext + job2 會先從 minusKey 開始:
job2 的 key 是 Job,而當前的 element 並不是 dispatcher ,所以會往 left 去找。
left 還是 CoroutineContext ,所以一樣比對 element,這裡 job1 的 key 為 Job ,所以直接取出 left :coroutineExceptionHandler,作為合併對象。
接著把 coroutineExceptionHandler 和 job2 合併成一個新的 CombinedContext ,然後放到 element 為 Dispatchers.IO 的 CombinedContext 的 left 去。
於是最後得到的會是 :CombinedContext(left= coroutineExceptionHandler, element = job2), element = Dispatchers.IO)
有自幹過 Linked List 的小夥伴應該會很有既視感,這是我們在 Linked List 中換 node 的常用的方法。
雖然這邊嘴很多,但是這是以一個欣賞 Code 的角度去看這邊的東西,從這角度來看常常會有每一個字都看得懂,但讀完還是不懂在說啥的狀況。
那這邊綜合一下線索,總結一下對於 CoroutineContext 需要了解的部分是 — CoroutineContext 勢必會是一個 Element ,而 CoroutineContext + CoroutineContext 會變成一個 Element.Key unique 的 Linked List ,那概念上我們把它當作一個 Map<Element.Key,Element> 會更容易理解,也就是說我們用一個 Element.Key 去取,可以拿出一個 Element。儘管它的結構有點小出入,但出入結果是相通的。
CoroutineContext 對 Coroutines 有什麼影響?
話說回來,究竟我們需要 Context 做什麼呢?
誠如前面所說的,比起知道 Context 是什麼玩意,我們更應該去了解使用 Context 來做什麼,只是如果是這樣說,我前面寫那一大段幹嘛?總之現在我們知道了,CoroutineContext 會透過 Element.Key 被取出來使用,那我們可以透過這個 Key 去查查該 Context 究竟是什麼時候被取出來然後怎麼被用的?
以這段 Code 為例:
這邊涉及到三個 CoroutineContext ,分別是 Dispatcher 、 Job 以及 CoroutineExceptionHandler
Dispatcher
先來聊聊 Dispatcher 。
這個大家都知道他是 Coroutines 的 scheduler ,具體怎麼跑的可以參考我之前寫的文章。
那我們這邊不看細節,我們看他怎麼被從 CoroutineContext 取出來的。
原則上我們 lauch 之後,會透過 CoroutinStart 的 Default 去啟動 Coroutines,順著 dependency 摸下去可以看到這個 Extension 接著 Extension 的 startCoroutineCancellable。
那 Dispatcher 在哪呢?我們這邊可以透過 Dispatcher 繼承的 Element.Key 知道,Dispatcher 是使用 CoroutineIntercepter 的 Key 去取用的,所以顯然這裡和 intercepted 有關。
追下去能發現,這裡透過 Context 藉由 CoroutineIntercepter 取出 Dispatcher 並執行了 interceptContinuation 。
那其實會用到的四個 Dispatcher 除了 Unconfirmed 以外,都會是 CoroutineDispatcher ,所以他會生成 DispatcherContinuation ,
到這一步也確定了 Dispatcher ,然後執行下去就會看到 launch 的 block 被 dispatch 進去了。
再來,就是 dispatcher 的故事了。
Job
Job 本身不難理解,如果像是上面那種寫法,會有兩個 Job 一個是 coroutines 生成後自帶的 Job,一個是一開始就有的 job instance ,然後更反閱讀的部分是, coroutines 的 Job 會是一開始 job 的 child 。
在 launch 的過程中,會生成一個叫 StandaloneCoroutine 的玩意。
這玩意本身就是一個 job,那開頭 init 的時候,會看到這行:
initParentJobInternal 本身是 Job 的 method ,追進去看可以看到這邊:
這裡就會發現,把我們加進去的 job 當作是 parent 再把 Coroutine 本身當作 Child 加進去了。
所以若是取消 job ,則會取消 Coroutine 的 Job ,但反之取消 Coroutine 的 Job 卻不會影響 job
那這邊有一個有趣的點是,Scope 本身是會自帶一個 Job 的,若是 CoroutineContext 沒有 plus 一個 Job 的話,他則會成為 Coroutine 生成的 Job 的 Parent ,那麼還記得 CoroutineContext 只會有一個 Element.Key 對一個 instance 的特性嗎?也就是說如果 plus 一個 Job 的話,會把 Scope 的 Job 取代掉過去。那麼以結果來說,取消 Scope 的 Job 會變成沒有辦法取消 plus Job 的 Coroutines ,等同於 Scope 失去對該 Coroutines 的控制。
這點我其實挺意外的,本來想說 Scope 若是被 cancel 的話,可能會在 Context 被 combin 之前被取代掉,但是結果並非這樣,也許設計上的職責是 plus 進來 Job 會取代掉 Scope 的職責,又或者是設計上的 bug 也說不定。
CoroutineExceptionHandler
CoroutineExceptionHandler 就是專門處理 Coroutines 噴的 Exception 。那這玩意何時會從 CoroutineContext 中取出來呢?
噴 Exception 的時候。
這個 method 會在 StandCoroutine 被生成時被實作。
這裡稍微提一下,如果有碰到 launch 裡面有 launch 的話,會有抓不到 Exception 的現象,這讓 ExceptionHandler 顯得有點複雜難用。但事實上 Coroutines 的 ExceptionHandler 沒那麼複雜(不去看 Source Code 的話)。
他就一個點,跟著 CoroutineContext。
例如這樣:
這樣是會抓到的。
但是如果是這樣:
這樣是抓不到的。
前者因為 Coroutines1 和 Coroutines2 用的 Context 是同一個,所以有 Exception Handler 抓得到。
但是 Coroutines1 和 Coroutines2 用的是不同的 Context ,所以 Coroutines2 噴的 Exception Coroutine1 的 Exception Handler 抓不到。
另外有一個例外是 async :
async 跟 launch 一樣呼叫後馬上就會執行,但唯一的差別最後的結果會透過 await 或其他方式取得。
只是理論上如果他馬上執行的話,應該會噴一個抓不到的錯誤,但實作顯示,他會是在 await 的時候才噴錯誤,所以以上面那張圖的情況是抓得到的,也就是說, Coroutines 會將噴 Exception 的狀態保留至 await 被 invoke 時才外拋,如果是這樣的話,可以合理的推論大部分的 Exception 都是有被保留好的。
結論
這篇算是我在寫 Job 時挖得坑,本來想說寫到尾巴可能會連帶地把 ExceptionHandler 得坑給挖出來,但看來 ExceptionHandler 比我想像中的簡單,所以暫時就沒挖出來了。
CoroutineContext 我覺得最難的部分還是那個 plus 的 operator ,因為那段短短的程式碼,涉及了各式各樣的判斷。我後來認為,要驗證他的流程,還是實際去產生一組 CombindContext 去跑這樣最簡單。
這篇寫完,大致上補完了我對 Kotlin Coroutines 實作的疑惑,雖然我覺得在實務上應該還會碰到更多奇奇怪怪的問題,但這樣也是等碰到後再來尋找解答也不遲。
如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我邊緣人我寫文章最大的動力。