Kotlinのcoroutinesについて

採用はこちら

Kotlinのcoroutinesは、非同期処理や並行処理を、読みやすく安全に書くための仕組みです。

従来の非同期処理では、

  • コールバックが深くネストする
  • FutureCompletableFuture が増えて流れが追いにくい
  • キャンセルや例外処理が散らばる
  • スレッド管理が難しい

といった問題が起こりやすくありました。

coroutinesを使うと、コードの見た目は順番に書けるまま、途中で処理を止めたり再開したりできるため、非同期処理をかなり自然に表現できます。

ただし、coroutineは「魔法の並列化機能」ではなく、一時停止可能な処理を構造的に管理する仕組みとして理解するのが大切です。

目次

coroutineとthreadの違い

この2つは同じではありません。

thread

OSが管理する実行単位です。

実際にCPU上で動く土台であり、生成や切り替えにはそれなりのコストがあります。

coroutine

Kotlinが提供する軽量な処理単位です。

coroutine自体はthreadそのものではなく、threadの上で動く処理のまとまりです。

つまりイメージとしては、

  • thread = 実際に走る道路
  • coroutine = その道路を走る軽い仕事

に近いです。

coroutineはthreadを使わずに動くわけではありません。

最終的にはどこかのthread上で実行されます。

ただし、待機中にthreadを無駄に占有しにくいため、多くの非同期処理を効率よく扱いやすくなります。

coroutinesの本質

coroutinesの本質は、処理を一時停止できることです。

たとえばネットワーク通信やI/Oのように、結果が返るまで待つ処理では、普通の同期コードだとその間threadを止めてしまうことがあります。

しかしcoroutineでは、待っている間だけ処理を一時停止し、そのthreadを他の仕事に使いやすくすることができます。

この「途中で止まって、あとで続きから再開できる」という性質が、coroutinesの中心です。

最初に覚えるべき重要キーワード

coroutinesを理解するうえで、まず次の項目を押さえると全体が見えやすくなります。

  • suspend
  • CoroutineScope
  • launch
  • async
  • Job
  • Dispatcher
  • withContext

suspend とは何か

suspend は、その関数が途中で一時停止できることを表します。

suspend fun fetchData(): String {
    delay(1000)
    return "done"
}

この関数は、実行中に delay() などで一時停止できます。

ただし、ここで大事なのは、suspend が付いているからといって、自動で別threadで動くわけではないという点です。

suspend はあくまで、

  • 一時停止できる
  • 再開できる

という性質を表すだけです。

そのため、

  • 並列になるか
  • どのthreadで動くか
  • どのdispatcherで実行されるか

は、呼び出し側のcoroutine contextや書き方に依存します。

つまり、suspend非同期実行そのものの保証ではなく、一時停止可能性の宣言と考えるのが正確です。

CoroutineScope とは何か

CoroutineScope は、coroutineが属する範囲です。

もっと言うと、そのcoroutineをどの単位で管理するかを表します。

たとえば実務では、

  • 画面が表示されている間だけ動く処理
  • ViewModelが生きている間だけ動く処理
  • リクエスト処理の間だけ動く処理
  • 独自コンポーネントの寿命に紐づく処理

のように、「どこまで生かすか」を明確にする必要があります。

この管理の土台になるのが CoroutineScope です。

launchasync は、単なる関数ではなく、CoroutineScope の上で使うbuilderです。

そのため、coroutinesは「とりあえずどこからでも起動する」のではなく、必ず何らかのscopeの中で管理するのが基本になります。

launchasync の違い

この2つは非常によく使います。

launch

戻り値が不要な処理を開始するときに使います。

返るのは Job です。

scope.launch {
    saveLog()
}

これは「結果は特にいらないが、処理を実行したい」というケースに向いています。

async

後で結果を受け取りたい非同期処理に使います。

返るのは Deferred<T> で、await() すると結果を受け取れます。

val userDeferred = scope.async { fetchUser() }
val postsDeferred = scope.async { fetchPosts() }

val user = userDeferred.await()
val posts = postsDeferred.await()

これは、複数の独立した処理を同時に進め、あとで結果をまとめて受け取るような場面で便利です。

使い分けの考え方

  • 戻り値がいらないlaunch
  • 戻り値が必要async

この区別を基本にするとわかりやすいです。

async を何となく多用すると、Deferredawait() の管理が増え、逆に読みづらくなることがあります。

Job とは何か

Job は、coroutineそのものの状態やライフサイクルを表すハンドルです。

たとえば launch の返り値として受け取れる Job には、

  • 実行中か
  • 完了したか
  • キャンセルされたか

といった状態があります。

また、Job は親子関係を持てるのが大きな特徴です。

これによって、1つの処理の中で始めた複数のcoroutineを、まとまりとして扱えます。

structured concurrencyとは何か

coroutinesで非常に重要なのが、structured concurrency です。

これは簡単に言うと、起動した非同期処理を、ちゃんと親子関係の中で管理しようという考え方です。

たとえばある画面で複数の通信を開始したとき、その画面が閉じたら全部止まるべきです。

あるリクエスト処理の途中でさらに子coroutineを起動したなら、その親処理が終わるときには子も含めて管理されるべきです。

structured concurrencyがあることで、

  • 起動した処理がどこに属しているか分かる
  • キャンセル漏れが減る
  • 例外伝播が自然になる
  • 処理の寿命が追いやすい

という利点があります。

Kotlin coroutinesの強みは、単に非同期にできることではなく、非同期処理を構造化して管理できることにあります。

runBlocking とは何か

runBlocking は、通常の同期コードとcoroutineの世界をつなぐための入口です。

fun main() = runBlocking {
    launch {
        delay(1000)
        println("Hello from coroutine")
    }
    println("Hello from main")
}

この例では、runBlocking の中でcoroutineを動かしています。

ただし、runBlocking には重要な特徴があります。

それは、呼び出し元のthreadを実際にブロックすることです。

そのため、用途は基本的に次のような場面に限るのが自然です。

  • サンプルコード
  • main() 関数
  • 一部のCLI処理
  • テストでの限定利用

普段のアプリケーションコードで多用するものではありません。

通常のアプリロジックでは、suspend 関数や既存の CoroutineScope を使って組み立てるほうが自然です。

delay()Thread.sleep() の違い

これは非常に重要です。

delay()

  • suspend関数
  • coroutineを一時停止する
  • threadをブロックしない
  • キャンセルに反応しやすい

Thread.sleep()

  • 実際のthreadを停止する
  • その間、他の仕事に使えない
  • coroutineの利点を損ねやすい

たとえばcoroutine内で待ち時間を表現したいなら、基本は delay() を使います。

launch {
    delay(1000)
    println("1 second later")
}

この待機中、threadそのものを無駄に占有しにくいのが大きな利点です。

一方で Thread.sleep() は、thread自体を止めてしまうため、特にUI threadや限られたthread poolでは問題の原因になりやすいです。

キャンセルの考え方

coroutineのキャンセルは、協調的キャンセルです。

これは「どんな処理でも外から強制停止できる」という意味ではありません。

そうではなく、処理側がキャンセルを観測しながら止まる仕組みです。

val job = scope.launch {
    repeat(1000) { i ->
        delay(100)
        println(i)
    }
}

delay(300)
job.cancel()

この例では、delay() がキャンセル可能なので、cancel() されると途中で停止できます。

ただし、CPUを使い続けるループのように、suspendポイントがない処理はキャンセルに気づきにくいです。

そういう場合は、

  • isActive を確認する
  • yield() を使う
  • 適切に suspendポイントを入れる

といった工夫が必要です。

例外伝播の考え方

通常のcoroutineでは、子coroutineの失敗が親へ伝播しやすいです。

つまり、ある子が失敗すると、

  • 親がキャンセルされる
  • その結果、兄弟coroutineにも影響が及ぶ

という挙動になります。

これは一見厳しく見えますが、実際にはかなり大事です。

なぜなら、関連する処理の一部だけが失敗しているのに、全体が何事もなく進んでしまうと、不整合な状態になりやすいからです。

coroutineScopesupervisorScope

coroutineScope

中で起動した子coroutineをまとめて管理するスコープです。

すべての子が終わるまで待ち、どれか1つが失敗すると全体に影響します。

suspend fun loadAll() = coroutineScope {
    val a = async { fetchA() }
    val b = async { fetchB() }
    a.await() + b.await()
}

これは「この処理群は1セットで成功してほしい」という場合に向いています。

supervisorScope

こちらは、ある子の失敗を他の子へ広げにくくするためのスコープです。

suspend fun loadWidgets() = supervisorScope {
    launch { loadWeatherWidget() }
    launch { loadNewsWidget() }
}

このように、片方が失敗してももう片方は続けたいケースで使いやすいです。

どう使い分けるか

  • 全体が一体として成功すべきcoroutineScope
  • 部分的な失敗を分離したいsupervisorScope

この整理でかなり分かりやすくなります。

Dispatcherとは何か

dispatcherは、そのcoroutineをどの実行文脈で動かすかを決めるものです。

実際には、どのthreadまたはthread poolで動かすかに関わります。

代表的なものは次の通りです。

Dispatchers.Main

主にUI更新用です。

Androidなどでは、画面更新は基本的にMain threadで行う必要があります。

Dispatchers.Default

CPU計算向けです。

重い計算、変換処理、大量データ処理などに向いています。

Dispatchers.IO

これはblocking I/Oをオフロードしたいときによく使うdispatcherです。

たとえば、

  • ファイル読み書き
  • JDBCのようなblocking DBアクセス
  • blockingなネットワーク呼び出し

などです。

ここは少し丁寧に理解したほうがよくて、「ネットワークなら必ずIO」ではありません。

たとえば利用しているライブラリが、すでにnon-blockingなsuspend APIを提供しているなら、呼び出し側で毎回 Dispatchers.IO に切り替えるのが必須とは限りません。

そのため、厳密には、Dispatchers.IO は blocking I/O を逃がすためによく使うと理解するのが安全です。

Dispatchers.Unconfined

特殊なdispatcherです。

通常の業務コードでは、最初はあまり使わないほうが安全です。

実行・再開される文脈が直感的でないことがあるため、学習初期では優先度は低めです。

withContext とは何か

withContext は、contextを切り替えて、その結果を受け取るための関数です。

suspend fun readFileText(path: String): String =
    withContext(Dispatchers.IO) {
        java.io.File(path).readText()
    }

この例では、blockingなファイル読み込みを Dispatchers.IO に逃がしています。

withContext は、

  • 別のdispatcherで実行したい
  • 結果をそのまま返したい
  • 処理のまとまりとして切り替えたい

というときにとても便利です。

GlobalScope を安易に使わない理由

GlobalScope は、アプリ全体の寿命に近い、非常に広いscopeです。

どの親 Job にも明確に紐づかないため、structured concurrencyから外れやすくなります。

その結果、

  • いつ終わるか追いにくい
  • 不要になっても動き続けやすい
  • キャンセルしづらい
  • メモリリークや不要処理の原因になりやすい

という問題が起こります。

そのため実務では、基本的に

  • Androidなら viewModelScopelifecycleScope
  • サーバーならリクエスト単位のscope
  • 独自クラスなら明示的に持つ CoroutineScope

のように、寿命が明確なscope を使うのが基本です。

GlobalScope は「存在はするが、通常は慎重に扱うべきもの」と考えるのがよいです。

Androidでの代表的な使い方

Androidでは、coroutinesそのものの仕様理解に加えて、ライフサイクルに沿った使い方が重要です。

代表例としては、

  • viewModelScope
  • lifecycleScope

があります。

たとえば画面の状態を読み込みたい場合、ViewModelの中で viewModelScope.launch { ... } を使い、結果を StateFlow などへ反映する構成がよく使われます。

ここで大事なのは、これはKotlin coroutinesそのものの仕様というより、Androidアーキテクチャ上の代表的な運用方法だということです。

つまり、coroutinesの一般論とAndroidでの実践は分けて理解すると整理しやすくなります。

Flowとは何か

Flow は、非同期データストリームを表す仕組みです。

単発の1回きりの結果ではなく、時間とともに複数の値が流れてくるものを扱うのに向いています。

たとえば、

  • 検索入力の変化
  • DB更新通知
  • センサー値
  • 定期的に更新される状態
  • WebSocketから届くイベント

などです。

fun numbers(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(100)
        emit(i)
    }
}

受け取る側は次のように書けます。

scope.launch {
    numbers().collect { value ->
        println(value)
    }
}

Flowは、「順番に値が流れてくる非同期の列」 と考えると理解しやすいです。

Channelとは何か

Channel は、coroutine同士で値をやり取りするための仕組みです。

イメージとしては、非同期の送受信路キューに近いです。

  • 送る側が send
  • 受け取る側が receive

を使って通信します。

Flow と比べると、Channel はより低レベル寄りのprimitiveです。

内部的なパイプライン処理や、producer-consumer型の通信などで役立つことがあります。

FlowChannel の関係

ここは断定しすぎない理解が大切です。

  • Flow は非同期ストリーム表現
  • Channel は coroutine間通信のprimitive

という違いがあります。

実務では、状態公開や継続的なデータ表現には Flow 系がよく選ばれることが多いです。

一方で、Channel も依然として有効です。

つまり、

  • 公開APIや状態表現にはFlowが向くことが多い
  • 内部通信やキュー的用途ではChannelが向くことがある

という理解が現実的です。

actor について

以前の説明では actor も触れていましたが、ここは補足が必要です。

actor builder はAPIとして存在しますが、現在はobsolete扱いの文脈があるため、新規学習の中心に置くにはあまり向きません。

そのため、学習順としてはまず、

  • launch
  • async
  • withContext
  • Flow
  • Channel

あたりをしっかり理解するほうが優先です。

suspend を付けても勝手に速くはならない

これは非常によくある誤解です。

suspend を付けたからといって、

  • 自動で並列になる
  • 自動で高速になる
  • 自動で別threadに逃げる

わけではありません。

suspend は単に、一時停止できる設計になっているというだけです。

処理を並列に進めたいなら、

  • async を使う
  • 複数coroutineを起動する
  • 適切なscopeとcontextを使う

必要があります。

並列化の典型例

たとえば独立したAPIを同時に呼びたい場合は、こう書けます。

suspend fun loadPageData(): PageData = coroutineScope {
    val user = async { userApi.fetchUser() }
    val articles = async { articleApi.fetchArticles() }

    PageData(
        user = user.await(),
        articles = articles.await()
    )
}

このように、互いに依存しない処理なら並列に進められます。

ただし、依存関係がある処理を無理に async 化しても意味はありません。

「本当に独立しているか」を考えることが重要です。

テストでは runTest を覚える

coroutinesのテストでは、runBlocking より runTest を基準に考えるほうがよいです。

import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals

class SampleTest {
    @Test
    fun testFetch() = runTest {
        val result = fetchValue()
        assertEquals("ok", result)
    }
}

runTest は、coroutineテスト向けに設計されていて、時間制御やdelayの扱いなどで有利です。

テストコードでは、通常のアプリコード以上に「正しく制御できること」が重要になるため、こちらを使うのが自然です。

よくある誤解の整理

誤解1: coroutineはthreadを使わない

違います。

最終的にはthread上で実行されます。

誤解2: suspend は自動で非同期実行される

違います。

一時停止可能なだけで、並列実行やthread切り替えを自動で意味しません。

誤解3: Dispatchers.IO はネットワーク専用

厳密には違います。

blocking I/Oを逃がすためによく使われますが、使っているライブラリの性質によって最適解は変わります。

誤解4: GlobalScope は便利だからどこでも使えばよい

危険です。

ライフサイクル管理が難しくなります。

誤解5: asynclaunch より上位互換

違います。

戻り値が必要かどうかで使い分けるのが基本です。

誤解6: Thread.sleep() でも同じ

違います。

threadをブロックするため、coroutineの利点を損ねます。

学習のおすすめ順

coroutinesは一気に全部学ぶと混乱しやすいので、次の順で理解するとかなり入りやすいです。

  1. suspend
  2. launch
  3. asyncawait
  4. CoroutineScope
  5. Job とキャンセル
  6. Dispatcher
  7. withContext
  8. coroutineScopesupervisorScope
  9. Flow
  10. runTest

この順で進めると、
「単純な非同期処理」
→「ライフサイクル管理」
→「例外とキャンセル」
→「ストリーム処理」
へと自然に理解が広がります。

一言でまとめると

Kotlin coroutinesは、非同期処理を、threadを無駄にブロックしにくい形で、構造化して安全に書くための仕組みです。

特に大切なのは、次の理解です。

  • suspend は一時停止可能性を表す
  • launch は戻り値不要、async は戻り値あり
  • delay() はthreadをブロックしない
  • CoroutineScopeJob でライフサイクルを管理する
  • structured concurrency で親子関係を保つ
  • withContext で適切なcontextへ切り替える
  • Dispatchers.IO はblocking I/O向けに使うことが多い
  • Flow は非同期ストリーム
  • テストでは runTest が重要
  • GlobalScope は安易に使わない

この土台が理解できると、Androidでもサーバーサイドでも、coroutinesのコードがかなり読みやすくなります。

以上、Kotlinのcoroutinesについてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次