コルーチンの概要

レスポンシブ UI は、優れたアプリに不可欠な要素です。これまでに作成したアプリでは当たり前のことだったかもしれませんが、ネットワーキングやデータベース機能といった高度な機能を追加し始めると、機能性とパフォーマンスの両方を備えたコードを記述することがますます難しくなります。次の例は、インターネットから画像をダウンロードするような長時間実行タスクが正しく処理されなかった場合にどうなるかを示しています。画像機能は動作していますが、スクロールが飛び飛びになるため、UI が応答しないように見えます(ふさわしくありません)。

9f8c54ba29f548cd.gif

上述のアプリの問題を回避するには、スレッドという処理について少し学ぶ必要があります。スレッドは少し抽象的な概念ですが、アプリのコードに対する単一の実行パスと考えることができます。記述するコードの各行は、同じスレッドで順番に実行される命令です。

すでに Android でスレッドを扱っています。すべての Android アプリには、デフォルトの「メイン」スレッドがあります。これは(通常は)UI スレッドです。これまでに記述したコードはすべて、メインスレッド上にあります。各命令(コード行)は、前の命令が終了するまで待ってから、次の行を実行します。

ただし実行中のアプリには、メインスレッドに加えてさらに多くのスレッドがあります。背後で、プロセッサは実際に別々のスレッドで動作するのではなく、異なる命令の間を行き来してマルチタスクのように見せかけます。スレッドは抽象化であり、コードを記述するとき、各命令を実行する実行パスを決定するために使用できます。メインスレッド以外のスレッドを操作すると、アプリのユーザー インターフェースが応答したまま、バックグラウンドで画像のダウンロードなどの複雑なタスクを実行できます。これを「同時実行コード」、または単に「同時実行」といいます。

この Codelab では、スレッドの詳細について学びます。また、コルーチンと呼ばれる Kotlin 機能を使用して明確なノンブロッキング同時実行コードを記述する方法について学びます。

前提条件

学習内容

  • 同時実行の概要とその重要性
  • コルーチンとスレッドを使用してノンブロッキング同時実行コードを記述する方法
  • バックグラウンドでタスクを実行するとき UI の更新を安全に行うためにメインスレッドにアクセスする方法
  • 異なる同時実行パターン(Scope / Dispatcher / Deferred)を使用する方法とタイミング
  • ネットワーク リソースとやり取りするコードを記述する方法

作成するアプリの概要

  • この Codelab では、Kotlin でスレッドやコルーチンを扱うための小さなプログラムをいくつか作成します

必要なもの

  • 最新のウェブブラウザ(Chrome の最新バージョンなど)を搭載したパソコン
  • パソコンでのインターネット アクセス

マルチスレッドと同時実行

これまで、単一の実行パスを持つプログラムとして Android アプリを扱ってきました。単一の実行パスで多くのことができますが、アプリの規模が大きくなるにつれ、同時実行について考える必要が出てきます。

同時実行を使用すると、複数のコード単位を順不同で実行したり、並列かのように実行したりできるため、リソースをより効率的に使用できます。オペレーティング システムは、システム、プログラミング言語、同時実行単位の特性を使用して、マルチタスクを管理できます。

fe71122b40bdb5e3.png

同時実行を使用する必要があるのはなぜでしょうか?アプリの複雑性が増すと、コードがノンブロッキングであることが重要になります。つまり、ネットワーク リクエストなどの長時間実行タスクを実行しても、アプリ内の他のタスクが実行されなくなることはありません。同時実行が適切に実装されていないと、アプリが応答していないように見えることがあります。

Kotlin での同時実行プログラミングを紹介する例をいくつか見てみましょう。サンプルはすべて、Kotlin プレイグラウンドで実行できます。

https://developer.android.com/training/kotlinplayground

スレッドは、プログラム内でスケジュール設定して実行できるコードの最小単位です。同時実行コードを実行できる簡単な例を次に示します。

簡単なスレッドを作成するにはラムダを指定します。プレイグラウンドで以下を試してみましょう。

fun main() {
    val thread = Thread {
        println("${Thread.currentThread()} has run.")
    }
    thread.start()
}

スレッドは、関数が start() 関数呼び出しに達するまで実行されません。出力結果は次のようになります。

Thread[Thread-0,5,main] has run.

なお currentThread() は、スレッドの名前、優先度、スレッド グループを返す文字列表現に変換された Thread インスタンスを返します。この出力は多少異なる場合があります。

複数スレッドの作成と実行

単純な同時実行の例を示すため、スレッドをいくつか作成して実行してみましょう。このコードは、先ほどの例の情報行を出力する 3 つのスレッドを作成します。

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

プレイグラウンドでの出力:

Thread[Thread-2,5,main] has started Thread[Thread-2,5,main] - Starting Thread[Thread-0,5,main] - Doing Task 1 Thread[Thread-1,5,main] - Doing Task 1 Thread[Thread-2,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Doing Task 2 Thread[Thread-2,5,main] - Doing Task 2 Thread[Thread-0,5,main] - Ending Thread[Thread-2,5,main] - Ending Thread[Thread-1,5,main] - Ending Thread[Thread-0,5,main] has started
Thread[Thread-0,5,main] - Starting
Thread[Thread-1,5,main] has started
Thread[Thread-1,5,main] - Starting

AS(コンソール)での出力:

Thread[Thread-0,5,main] has started
Thread[Thread-1,5,main] has started
Thread[Thread-2,5,main] has started
Thread[Thread-1,5,main] - Starting
Thread[Thread-0,5,main] - Starting
Thread[Thread-2,5,main] - Starting
Thread[Thread-1,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 1
Thread[Thread-2,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 2
Thread[Thread-1,5,main] - Doing Task 2
Thread[Thread-2,5,main] - Doing Task 2
Thread[Thread-0,5,main] - Ending
Thread[Thread-2,5,main] - Ending
Thread[Thread-1,5,main] - Ending

コードを複数回実行します。さまざまな出力が表示されます。スレッドが順番に実行されているように見える場合もあれば、コンテンツが散在しているように見える場合もあります。

スレッドを使用することは、複数のタスクと同時実行を扱う簡単な方法ですが、問題がないわけではありません。コードで Thread を直接使用すると、さまざまな問題が発生する可能性があります。

スレッドは多くのリソースを必要とする

スレッドの作成、切り替え、管理は、システム リソースを消費し、同時に管理できるスレッド数の制限に時間がかかります。作成のコストが大幅に増えることがあります。

実行中のアプリには複数のスレッドがありますが、アプリごとに 1 つの専用スレッドがあり、具体的にはアプリの UI を担当します。このスレッドは通常、メインスレッドまたは UI スレッドと呼ばれます。

このスレッドはアプリの UI を実行するためのものであるため、アプリをスムーズに動作させるには、メインスレッドのパフォーマンスを高めることが重要です。長時間実行タスクは完了するまでブロックされ、アプリが応答しなくなります。

オペレーティング システムは、ユーザーに対する応答性を維持しようとして多くのことを行います。現在のスマートフォンは、毎秒 60 回から 120 回(最低でも 60 回)UI を更新しようとします。UI を準備して描画する時間は限られています(60 フレーム/秒で、1 回の画面更新にかかる時間が 16 ms 以下)。Android はフレームをドロップするか、追いつくために 1 つの更新サイクルを完了しようとして中止します。一部のフレームがドロップし変動することは正常ですが、多すぎるとアプリが応答しなくなります。

競合状態と予期しない動作

前述のとおり、スレッドは、プロセッサが複数のタスクを同時に処理するように見える方法の抽象化です。プロセッサが異なるスレッドで命令セットを切り替えるとき、スレッドが実行される正確な時間と、スレッドが一時停止するタイミングは制御不能です。スレッドを直接操作する場合、必ずしも予測可能な出力が得られるとは限りません。

たとえば次のコードは、簡単なループを使用して 1 から 50 までカウントしますが、この場合、カウントが増えるたびに新しいスレッドが作成されます。出力がどのようになるかを考えてから、コードを数回実行してみましょう。

fun main() {
   var count = 0
   for (i in 1..50) {
       Thread {
           count += 1
           println("Thread: $i count: $count")
       }.start()
   }
}

出力は予想したとおりだったでしょうか。毎回同じだったでしょうか。出力例を次に示します。

Thread: 50 count: 49 Thread: 43 count: 50 Thread: 1 count: 1
Thread: 2 count: 2
Thread: 3 count: 3
Thread: 4 count: 4
Thread: 5 count: 5
Thread: 6 count: 6
Thread: 7 count: 7
Thread: 8 count: 8
Thread: 9 count: 9
Thread: 10 count: 10
Thread: 11 count: 11
Thread: 12 count: 12
Thread: 13 count: 13
Thread: 14 count: 14
Thread: 15 count: 15
Thread: 16 count: 16
Thread: 17 count: 17
Thread: 18 count: 18
Thread: 19 count: 19
Thread: 20 count: 20
Thread: 21 count: 21
Thread: 23 count: 22
Thread: 22 count: 23
Thread: 24 count: 24
Thread: 25 count: 25
Thread: 26 count: 26
Thread: 27 count: 27
Thread: 30 count: 28
Thread: 28 count: 29
Thread: 29 count: 41
Thread: 40 count: 41
Thread: 39 count: 41
Thread: 41 count: 41
Thread: 38 count: 41
Thread: 37 count: 41
Thread: 35 count: 41
Thread: 33 count: 41
Thread: 36 count: 41
Thread: 34 count: 41
Thread: 31 count: 41
Thread: 32 count: 41
Thread: 44 count: 42
Thread: 46 count: 43
Thread: 45 count: 44
Thread: 47 count: 45
Thread: 48 count: 46
Thread: 42 count: 47
Thread: 49 count: 48

コードの内容とは異なり、最後のスレッドが最初に実行され、他のスレッドの一部が順序どおりに実行されていないように見えます。一部の反復処理の「count」を見ると、複数のスレッドの後に変更されていないことがわかります。さらに奇妙なことに、2 番目に実行するスレッドでしかないことが出力で示されているにもかかわらず、Thread 43 で count が 50 に達します。この出力だけでは、count の最終的な値を知ることはできません。

これは、スレッドが予期しない動作を引き起こす可能性がある方法のひとつにすぎません。複数のスレッドを操作するとき、競合状態という状態になることもあります。これは、複数のスレッドがメモリ内の同じ値に同時にアクセスしようとした場合に発生します。競合状態は、再現の難しいランダムに見えるバグを引き起こす可能性があり、アプリの頻繁かつ予期しないクラッシュの原因になることがあります。

パフォーマンスの問題、競合状態、バグの再現が難しいことなどから、スレッドを直接操作することはおすすめしません。代わりに、同時実行コードを記述するために役立つ、コルーチンという Kotlin の機能について学びます。

バックグラウンド タスク用のスレッドを直接作成して使用することは、Android では一定の役割を持っていますが、Kotlin には、同時実行をより柔軟かつ簡単に管理できるコルーチンもあります。

コルーチンによってマルチタスクが可能となりますが、単にスレッドを操作するだけではなく、別のレベルの抽象化が提供されます。コルーチンの主な特長のひとつは状態を保存できることです。そのため、停止と再開ができます。コルーチンは実行される場合と実行されない場合があります。

「継続」で表される状態によって、コードの一部は、制御を引き渡す必要がある場合、または別のコルーチンが処理を完了するまで待ってから再開する必要がある場合に通知できます。このフローを協調的マルチタスクといいます。Kotlin のコルーチン実装では、マルチタスクに役立つ多くの機能が追加されます。継続に加え、コルーチンの作成には CoroutineScope 内の Job(ライフサイクルを伴うキャンセル可能な作業単位)での作業も含まれます。CoroutineScope は、子とその子にキャンセルとその他のルールを再帰的に適用するコンテキストです。Dispatcher は、コルーチンが実行に使用するバッキング スレッドを管理します。これによりデベロッパーは、新しいスレッドをいつどこで使用するかを気にする必要がなくなります。

Job

キャンセル可能な作業単位(launch() 関数で作成されたものなど)。

CoroutineScope

launch()async() などの新しいコルーチンを作成するために使用される関数は、CoroutineScope を拡張します。

Dispatcher

コルーチンが使用するスレッドを決定します。Main ディスパッチャは常にメインスレッドでコルーチンを実行しますが、DefaultIOUnconfined などのディスパッチャは他のスレッドを使用します。

これらについては後で詳しく説明しますが、Dispatchers はコルーチンのパフォーマンスを向上させる方法のひとつです。新しいスレッドを初期化するパフォーマンス コストを回避できます。

これまでの例を、コルーチンを使用するように応用してみましょう。

import kotlinx.coroutines.*

fun main() {
    repeat(3) {
        GlobalScope.launch {
            println("Hi from ${Thread.currentThread()}")
        }
    }
}
Hi from Thread[DefaultDispatcher-worker-2@coroutine#2,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#1,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#3`,5,main]

上のスニペットでは、デフォルトの Dispatcher を使用して、Global Scope 内に 3 つのコルーチンを作成しています。GlobalScope は、アプリが動作している限り、その中に含まれる任意のコルーチンを実行できます。メインスレッドについて説明した理由により、これはサンプルコード以外ではおすすめしません。アプリでコルーチンを使用するときは、他のスコープを使用します。

launch() 関数は、キャンセル可能な Job オブジェクトにラップされたコードからコルーチンを作成します。launch() は、コルーチンの範囲外で戻り値が必要ない場合に使用します。

次に重要なコルーチンのコンセプトを理解するために、launch() のシグネチャ全体を見てみましょう。

fun CoroutineScope.launch {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
}

背後では、起動するために渡したコードブロックが suspend キーワードでマークされます。suspend は、コードまたは関数のブロックを一時停止または再開できることを伝えます。

runBlocking について

次の例では runBlocking() を使用します。これは名前のとおり、新しいコルーチンを開始し、完了するまで現在のスレッドをブロックします。これは主に、main 関数とテストで、ブロックするコードとブロックしないコードを橋渡しするために使用されます。一般的な Android コードではあまり使用しません。

import kotlinx.coroutines.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

val formatter = DateTimeFormatter.ISO_LOCAL_TIME
val time = { formatter.format(LocalDateTime.now()) }

suspend fun getValue(): Double {
    println("entering getValue() at ${time()}")
    delay(3000)
    println("leaving getValue() at ${time()}")
    return Math.random()
}

fun main() {
    runBlocking {
        val num1 = getValue()
        val num2 = getValue()
        println("result of num1 + num2 is ${num1 + num2}")
    }
}

getValue() は、設定された遅延時間の経過後に乱数を返します。DateTimeFormatter を使用します。該当する entry と exit の時間を示します。main 関数は getValue() を 2 回呼び出し、合計を返します。

entering getValue() at 17:44:52.311
leaving getValue() at 17:44:55.319
entering getValue() at 17:44:55.32
leaving getValue() at 17:44:58.32
result of num1 + num2 is 1.4320332550421415

この動作を確認するには、main() 関数を次のコードに置き換えます(他のコードはすべて維持します)。

fun main() {
    runBlocking {
        val num1 = async { getValue() }
        val num2 = async { getValue() }
        println("result of num1 + num2 is ${num1.await() + num2.await()}")
    }
}

getValue() の 2 つの呼び出しは独立しており、必ずしもコルーチンを中断する必要はありません。Kotlin には、launch に似た async 関数があります。async() 関数は次のように定義されます。

Fun CoroutineScope.async() {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
}: Deferred<T>

async() 関数は Deferred 型の値を返します。Deferred は、将来の値への参照を保持できるキャンセル可能な Job です。Deferred を使用すると、すぐに値を返すかのように関数を呼び出すこともできますが、非同期タスクがいつ返されるかは不明なため、Deferred は単にプレースホルダとして機能します。Deferred(他の言語では Promise または Future ともいいます)は、値が後でこのオブジェクトに返されることを保証します。一方、非同期タスクは、デフォルトでは実行をブロックせず、待機もしません。現在のコード行が Deferred の出力を待機する必要があることを伝えるには、await() を呼び出します。未加工の値が返されます。

entering getValue() at 22:52:25.025
entering getValue() at 22:52:25.03
leaving getValue() at 22:52:28.03
leaving getValue() at 22:52:28.032
result of num1 + num2 is 0.8416379026501276

関数を suspend としてマークする場合

前の例では、getValue() 関数も suspend キーワードで定義されています。これは、suspend 関数でもある delay() を呼び出すためです。ある関数が別の suspend 関数を呼び出す場合、その関数も suspend 関数である必要があります。

この場合、この例の main() 関数が suspend でマークされないのはなぜでしょうか。結局 getValue() を呼び出します。

必ずしもそうとは限りません。実際には、getValue()runBlocking() に渡された関数の中で呼び出されます。これは launch()async() に渡されたものと同様の suspend 関数です。ただし、getValue()main() 自体では呼び出されておらず、runBlocking()suspend 関数ではないため、main()suspend でマークされません。suspend 関数を呼び出さない関数の場合、それ自体が suspend 関数である必要はありません。

この Codelab の冒頭で、複数のスレッドを使用する次の例を示しました。コルーチンの知識を活かして、Thread の代わりにコルーチンを使用するようにコードを書き直してみましょう。

注: Thread ステートメントを参照していても、println() ステートメントを編集する必要はありません。

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}
import kotlinx.coroutines.*

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       GlobalScope.launch {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               delay(5000)
           }
       }
   }
}

学習した内容

  • 同時実行が必要な理由
  • スレッドの概要と、同時実行にとってスレッドが重要な理由
  • コルーチンを使用して Kotlin で同時実行コードを記述する方法
  • 関数を「suspend」としてマークするタイミングとマークしないタイミング
  • CoroutineScope、Job、Dispatcher の役割
  • Deferred と Await の違い