Kotlin Coroutines CoroutineContext 的那一兩件事

Jast Lai
Jastzeonic
Published in
15 min readOct 2, 2020

前言

個人感覺,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 之中:

CombinedContext
Element

想知道這答案的方法也很單純,直接把變數代進去跑一次流程就知道了:

以這段為例: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 實作的疑惑,雖然我覺得在實務上應該還會碰到更多奇奇怪怪的問題,但這樣也是等碰到後再來尋找解答也不遲。

如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我邊緣人我寫文章最大的動力。

--

--

Jast Lai
Jastzeonic

A senior who happened to be an Android engineer.