Kotlin 1.3 が2018年10月30日に正式版として公開されました。
それに伴い、コルーチンが stable へと昇格しました!
非同期処理を楽に書けるコルーチンですが、コルーチンの書き方はわかったものの、実際何がどう動いて何と何がどう関係しているのかといったメンタルモデルが構築されるまでよくわからないまま使ってました。
自分の理解の整理も兼ねて、自分の中のメンタルモデルを図と文で書き出してみました。
間違っているところなどございましたら、コメントなどでご指摘いただけますと幸いです。
Kotlin のコルーチンについて、多少どういったものであるかを知っている人向けになっていますので、そもそもコルーチンって何?って方はまずはこちらの記事をご覧ください。
また、本記事では CoroutineContext のことを単に コンテキスト
、 CoroutineScope のことを スコープ
と表現することがあります。いずれもたくさんの意味を持つ言葉なのでここで明確化しておきます。
Job とは
Job とはなんらかの作業を表す単位で、開始や完了などの状態があります。
launch
や async
などのコルーチンビルダーで作成したコルーチンは Job です。
また Job は Job()
グローバル関数で作成することもできます。
Job の親子関係
Job には親子関係があり、1つ以上の子を持つことができます。
図の例では、 Job A は Job B と Job C を子に持ち、 Job C は Job D と Job E を子に持っています。
キャンセルの伝播
Job をキャンセルすると、キャンセルされた Job は子にキャンセルを伝播します。
キャンセルは再帰的に伝播していくため、図の Job A をキャンセルすると Job D, E もキャンセルされます。
CoroutineContext とは
CoroutineContext とは、コルーチンの文脈や環境の状態を表す値の集まりで、キーと値のペアとして情報を保持しています。(連想配列みたいな感じ)
よく使われるのは Dispatcher と Job という要素です。
Dispatcher はコルーチンをどのスレッドで実行するかといったことを決定します。それより詳細については難しいので、ここでは言及しません。
CoroutineScope とは
CoroutineScope とは、コルーチンが所属する仮想的な領域です。
コルーチンはいずれかのスコープに属します。
launch
や async
といったコルーチンビルダーは CoroutineScope の拡張関数として定義されているため、 CoroutineScope なしではコルーチンは起動できません。
CoroutineScope は CoroutineContext を持っている
CoroutineScope は CoroutineContext を持っています。
持っているというよりは、スコープにはコンテキストがあるという表現のほうが適切かもしれません。
コルーチンは新しくスコープを作る
コルーチンは新しくスコープを作ります。
図では起動されたコルーチンBはスコープBを、コルーチンCはスコープCを作っています。
コンテキストの受け継ぎ
コルーチンが新しく作るスコープは、そのコルーチンが起動されたスコープのコンテキストを受け継ぎます。
図では Dispatcher 要素が受け継がれていますが、他の要素もあればすべて受け継がれます。
ただし、 Job
の値は受け継がれず、自身のコルーチンになります。
また、起動されたコルーチンはコルーチンを起動したスコープの Job の子になります。
コルーチンを起動したスコープの Job
= コルーチンを起動したコルーチン
ですので、この例では起動されたコルーチンBとコルーチンCは起動したコルーチンAの子になります。
最初のスコープ
ここまでで、 CoroutineScope 内にコルーチンが作れるのはわかりました。
コルーチンがスコープを作ることもわかりました。
では最初の CoroutineScope はいつ、誰が作るのでしょうか?
最初のスコープは、自分で CoroutineScope を実装して作ります。
CoroutineScope は普通のインタフェースで、 coroutineContext
という、そのスコープの CoroutineContext を返すゲッターメソッドを実装するだけで OK です。
class MyActivity : Activity(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = /* このスコープのコンテキストを返す */
}
コンテキストの合成
実は、 Job
も Dispatcher
もいずれも CoroutineContext です。
コンテキストを構成する要素であると同時に、コンテキスト自身でもあるのです。
実際のコードを少し見てみます。(簡略化しています。)
// Job は CoroutineContext.Element
public interface Job : CoroutineContext.Element {
}
// Dispatchers.Default は ContinuationInterceptor
public actual object Dispatchers {
public actual val Default: CoroutineDispatcher = createDefaultDispatcher()
}
// CoroutineDispatcher は ContinuationInterceptor
public abstract class CoroutineDispatcher : ContinuationInterceptor {
}
// ContinuationInterceptor は CoroutineContext.Element
public interface ContinuationInterceptor : CoroutineContext.Element {
}
// CoroutineContext.Element は CoroutineContext
public interface CoroutineContext {
public interface Element : CoroutineContext {
}
}
Element とは単一要素のみを持つコンテキストだと思っても良いかもしれません。
少し詳細に入り込みすぎるので興味がなければ飛ばしてもらって構いませんが、実際に Element は自身の要素以外を取得しようとすると null を返すように実装されているため、自身の要素しか持ちえません。
public interface Element : CoroutineContext {
// このコードは少し簡略化していますが、取得しようとしたキーが
// 自身のキーでなければ null を返すようになっています。
public override operator fun <E : Element> get(key: Key<E>): E? =
if (this.key == key) this as E else null
}
CoroutineContext は合成できます。コンテキストを +
(plus
) すると、右辺と左辺のコンテキストに含まれる要素が足し合わされたコンテキストが作成されます。
右辺と左辺に同じキーを持つ要素がある場合は、右辺の要素の値が勝ちます。
GlobalScope
多少余談ですが、CoroutineScope は自分で実装したりコルーチンが作成したりする他に、 GlobalScope という特別なスコープもあります。
GlobalScope は独立したスコープで、現在のスコープの影響を受けてほしくないコルーチンを起動したりするときに使用します。
launch {
launch {
// 外側のスコープのコンテキストを受け継ぐ
}
GlobalScope.launch {
// 独立したスコープで動作するため、
// 外側のスコープの Job がキャンセルされても影響を受けない
}
}
具体的な例
これで、 Job, CoroutineContext, CoroutineScope といったKotlin でコルーチンを扱うためには理解の必須な要素がどのように関係しあっているのかをざっくりと理解できたはずです。
最後に Android の Activity を例に、
- Activity のライフサイクルに合わせたスコープを作る
- メインスレッドでコルーチンを実行する
- 他のスレッドでコルーチンを実行する
を行うサンプルコードを掲載しておきます。
class MyActivity : Activity(), CoroutineScope {
// この Activity のライフサイクルに合わせて状態を管理する Job。
// 大本となるスコープの Job をここで定義しておくことで、この Job さえ
// キャンセルすればこのスコープ内のすべてのコルーチンをキャンセルできる。
private val job = Job()
// このスコープのコンテキストを提供する。
// Dispatcher.Main を指定しているためこのスコープで起動するコルーチンはメインスレッドで動作する。
// Job として上で定義した job を渡しているので、すべてのコルーチンはこの job の子になる。
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_my)
val textView = findViewById(R.id.textView)
// なんらかのデータをネットワークから取得するクライアント
val apiClient = ApiClient()
launch {
// この中の `this` はこのコルーチンが作った CoroutineScope (スコープAと呼ぶ)
// スコープAは MyActivity に実装したスコープを受け継いでいるので、
// Dispatcher は Dispatchers.Main になる。
// そのためメインスレッドでしか行えない処理が行える。
textView.text = "changed"
// ネットワークアクセスをするために Dispatcher だけ変更してコルーチンを起動することもできる。
// 引数に渡したコンテキストが新しく作られるスコープのコンテキストの右辺に plus される。
val user = async(context = Dispatchers.IO) {
// この中の `this` は `スコープAのコンテキスト + Dispatchers.IO` の
// コンテキストを持ったスコープ (スコープBと呼ぶ)
// この中の Dispatcher は Dispatchers.IO なので、ネットワークアクセスをするような
// suspend function を実行してもメインスレッドはブロックされない。
apiClient.requestUser()
}.await()
// await で待っているので、ここは上の `requestUser()` が完了してから実行される。
// ここは Dispatchers.Main なのでメインスレッドでしか行えない処理が行える。
textView.text = user.name
}
}
override fun onDestroy() {
// Activity 破棄時に MyActivity の job をキャンセルすれば、 onCreate 内 launch で起動した
// コルーチンやそこからさらに async で起動したコルーチンまですべてキャンセルされる。
job.cancel()
super.onDestroy()
}
}
Dispatchers.Main を使用するためには kotlinx-coroutines-android
モジュールが必要です。