たくさんの自由帳

あまいろ Kotlin Coroutines サスペンド関数編

投稿日 : | 0 日前

文字数(だいたい) : 43154

目次

どうもこんばんわ。
あまいろショコラータ1・2・3 コンプリートパックを買ってやっています。買ったのは3の発売のときだったので積んでたことになりますね、、
まずは、あまいろショコラータ攻略しました。

みくりちゃん!!!ところどころにあるやりとりがおもしろかった

Imgur

Imgur

↑じとめすち

むくれてるのかわい~

Imgur

Imgur

英語分からん分かる、英語が第一言語だとやっぱドキュメントもエラーメッセージもそのまま分かるのでしょうか。
直でドキュメント読めるのずるいとおもう(?)

Imgur

><
それはそうと服が似合ってていい

Imgur

あとあんまり関係ないけど無印版のエンディング曲がシリーズの中で一番好きかもしれません、

Imgur

あ!!!!!これ
このブログの土日祝日のアクセス数のこと言ってますか!??!?!?!

Imgur

技術(?)ブログ、休みの日はあんまりお客さん来てない。
CloudFrontの転送量をCloudWatchで見てみたけど、明らかに休みの日だけ折れ線グラフがガタ落ちしてる。面白い。

本題

Jetpack Composeがぶいぶい言わせている今日、Jetpack ComposeではありとあらゆるところでKotlin Coroutinesのサスペンド関数や、Flowが多用されています。
Androidはかなり)コールバック地獄だったので、他の言語にあるような同期スタイルで記述でき、とても嬉しいわけですが、、、、

その割にはAndroid本家のKotlin Coroutinesの紹介がおざなりというか、分かる人向けにしか書いていません!(別にAndroid側が紹介する義理なんて無いけど)
Jetpack Composeであれだけ多用しているのに!?!?(いや関係ないだろ)
https://developer.android.com/kotlin/coroutines

かくいう私もあんまり理解できてない、、、なんとなくで書いている。
数年前からふいんき(なぜか変換できない)で書いているので真面目にやります。
https://takusan.negitoro.dev/posts/android_sqlite_to_room/#なんでコルーチン

というわけで今回はKotlin Coroutinesのドキュメントを読んでみようの会です。ぜひ夏休みの読書感想文にどうぞ。(てか世間は夏休みなのか)
https://kotlinlang.org/docs/coroutines-guide.html

Kotlin Coroutinesの話をするのに、Androidである必要はないんですが、私がサンプルコード書くのが楽というだけです。
特別なことがなければAndroidじゃない環境、Kotlin/JVMとかでも転用できます。

いちおうドキュメントの流れに沿って読んでいこうかなと思うのですが、
スレッドと違い、なぜコルーチンは欲しいときに欲しいだけ作っていいのかとかの話をしないと興味がわかないかなと思いそこだけ先にします。

多分合ってると思う、間違ってたらごめん。

環境

まあ分かりやすいと思うのでAndroidで、あとはJetpack Composeを使います。

もしサンプルコードを動かすなら↓
サンプルとしてインターネット通信が挙げられると思うので、OkHttpというHTTP クライアントライブラリも入れます。
インターネット通信のサンプルがあるため、インターネット権限を付与したプロジェクト(アプリ)を作っておいてください。

はじめに

https://kotlinlang.org/docs/coroutines-guide.html

FutureとかPromiseとかasync / awaitを知っていればもっと分かりやすいかもしれませんが知らなくてもいいです。なんなら忘れても良いです。
というのもFuturePromiseには無い安全設計が存在したり、Kotlin Coroutines は他の言語にある async/awaitという回答は大体あっているくらいしかないです。
また、Kotlin Coroutinesではasync/awaitは並列実行のために使われており、他の言語にあるasync functionに当たるものはsuspend funになります。

あらずし

一応言っておくと別にスレッドとかFutureとかPromiseの悪口が言いたいわけじゃないです。

ライブラリの解説

これはコルーチンとは関係ないのですが、OkHttpというライブラリのコードでサンプルを書くので、
最低限の使い方をば。

これが非同期モード。現在のスレッドはブロックせず、代わりに通信結果はonResponse()受け取る。
ブロックしないためメインスレッド(UIスレッド)からも呼び出せます。

val request1 = Request.Builder().apply {
    url("https://example.com/")
    get()
}.build()
OkHttpClient().newCall(request1).enqueue(object : Callback {
    override fun onFailure(call: Call, e: IOException) {
        // エラー
    }
 
    override fun onResponse(call: Call, response: Response) {
        // 成功
    }
})

これが同期モード。execute()を呼び出すと、レスポンスが返ってくるまで現在のスレッドをブロックします。

val request = Request.Builder().apply {
    url("https://example.com")
    get()
}.build()
val response = OkHttpClient().newCall(request).execute()

コールバック

Androidでコルーチンが来る前、コールバックをよく使っていました、てかこれからも使うと思います。Javaで書く人たちはKotlin Coroutines使えないので。

人によってはRxなんとかを使ってたりしたそうですが、Androidライブラリのほとんどはコールバックだったと思います。
かくいうAndroidチームが作るライブラリAndroid Jetpack(androidx.hogehoge みたいなやつ)も、もっぱらそう。

コールバックは、せっかく覚えたプログラミングのいろはを全部台無しにしていきました。

というわけでまずはコールバックの振り返り。OkHttpってライブラリを入れてサンプルを書いていますが、Kotlin Coroutinesあんまり関係ないのでここは真似しなくてもいいと思います。

例えばプログラミングでは、上から処理が実行されるという話がされるはず、でも意地悪してこれだとどうなるかというと。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        println("1 番目")
 
        val request = Request.Builder().apply {
            url("https://example.com/")
            get()
        }.build()
        OkHttpClient().newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
 
            }
 
            override fun onResponse(call: Call, response: Response) {
                println("2 番目")
            }
        })
 
        println("3 番目")
 
    }
}

Logcatの表示だとこれ。全然上から順番に処理されてない。

1 番目
3 番目
2 番目

forも使える、、あれ?コールバックだと期待通りじゃない?

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        
        repeat(10) { index ->
            println("Request $index")
            val request = Request.Builder().apply {
                url("https://example.com/")
                get()
            }.build()
            OkHttpClient().newCall(request).enqueue(object : Callback {
                override fun onFailure(call: Call, e: IOException) {
 
                }
 
                override fun onResponse(call: Call, response: Response) {
                    println("onResponse $index")
                }
            })
        }
    }
}

Logcatが途中までは良かったのに、順番がぐちゃぐちゃになってしまった。

Request 0
Request 1
Request 2
Request 3
Request 4
Request 5
Request 6
Request 7
Request 8
Request 9
onResponse 1
onResponse 8
onResponse 7
onResponse 5
onResponse 3
onResponse 2
onResponse 0
onResponse 6
onResponse 9
onResponse 4

順番を守るためには、コールバックの中に処理を書かないといけないわけですが、見通しが悪すぎる。
俗に言うコールバック地獄Androidはこんなのばっか。Camera2 APIみてるか~?

val request1 = Request.Builder().apply {
    url("https://example.com/")
    get()
}.build()
OkHttpClient().newCall(request1).enqueue(object : Callback {
    override fun onFailure(call: Call, e: IOException) {
        // エラー1
    }
 
    override fun onResponse(call: Call, response: Response) {
        // 成功1
        
        val request2 = Request.Builder().apply {
            url("https://example.com/")
            get()
        }.build()
        OkHttpClient().newCall(request2).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
                // エラー2
            }
 
            override fun onResponse(call: Call, response: Response) {
                // 成功2
            }
        })
 
    }
})

例外処理のtry-catchを覚えたって?コールバックの前では役立たずです。
成功時に呼ばれる関数、失敗時に呼ばれる関数に分離。finallyが欲しい?関数作って両方で呼び出せばいいんじゃない?

val request = Request.Builder().apply {
    url("https://example.com/")
    get()
}.build()
OkHttpClient().newCall(request).enqueue(object : Callback {
    override fun onFailure(call: Call, e: IOException) {
        println("失敗時")
    }
    override fun onResponse(call: Call, response: Response) {
        println("成功時")
    }
})

それ以前に、コールバックがなければ直接スレッドを作って使うしか無いのですが、これは多分あんまりないと思います。
そもそもAndroidだとコールバックのAPIしか無いとかで。

なぜコールバック

それでもなおコールバックを使っていたのには、わけがちゃんとあります。
画面が固まってしまうんですよね。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        // UI スレッドから呼び出してみる
        val request = Request.Builder().apply {
            url("https://example.com")
            get()
        }.build()
        // インターネット通信は時間がかかる
        // 通信制限されていたらいつ処理が終わるか分からない。execute() がいつ返り値を返すか分からない。返すまでは画面が固まってしまう。
        val response = OkHttpClient().newCall(request).execute()
        // Toast を出す
        Toast.makeText(this, "こんにちは $response", Toast.LENGTH_SHORT).show()
    }
}

最初からあるスレッド、UIスレッドメインスレッドとも呼ばれていますが、これは特別でUIの更新や入力を受け付けるものになります。UIの処理はこれ以外の他のスレッドでは出来ません。
このスレッドで軽い処理なら問題ないでしょうが、インターネット通信(しかも通信制限のユーザーが居るかも知れない!)をやってしまったら、しばらく画面が固まってしまいます。押しても反応しないアプリが誕生します。

やがて行き着く先はこのダイアログ、「アプリ名」は応答していません。これはメインスレッドで行われている処理に時間かかった際に出ます。
Android 4.xあたりまではよく見た記憶がある。今でもメインスレッドを止めればいつでもこのダイアログに会えます。

Imgur

これを回避するためにスレッドを使ったり、コールバックを使い、時間がかかる処理をメインスレッドで行わないようにしていたわけです。
まあそれ以前に Android ではメインスレッドでインターネット通信できない(例外が投げられる)ので、そもそもやりたくても出来ません。

これで、アプリの安定性が上がった。 アプリがフリーズしないように対策出来た。代償としてコードが地獄になったわけ。つらい。

アプリの安定性からアプリがフリーズしないように対策とわざわざ言い直したわけですが、コールバックやスレッドだって使えば安定するかと言われると、そう簡単には安定しません。
ちゃんと使わないと別のエラーで落ちます。こっちは落ちます。フリーズじゃなくて。これが厄介!

Android 11で非推奨になったAsyncTaskで痛い目を見た。なんて懐かしいですね。なんなら全然うまく動かなくてトラウマになってる人もいそう。私ももう見たくない。
画面回転したら落ちるとか、アプリから離れると落ちるとか、、、

流石にそれはしんどいのでMVVM的な考え方にのっとり、UIを担当する処理とそれ以外が分離されました。
画面回転ごときで落ちるのは、UIの部分で通信処理を書いているのが原因。ViewModelというUIに提供するデータを用意する場所で書けばいい。こいつは画面回転を超えて生き残る。
コールバックが来ようと、同期的な処理になろうとずいぶんマシになったはず。

話がちょっとそれちゃったけど、これが今日のAndroidで、まあ後半はKotlin Coroutinesあんまり関係ないんですが、非同期とかコールバックがしんどいってのがわかれば。

コールバックの代替案 Future と Promise

Rxなんとか←これは使ったこと無いので触れないです。すいません。
Futureも名前知ってるレベルであんまり知らないです。Promiseがちょっとだけ分かります。

Promise(ゲッダンの方ではない)とか

FutureFeatureじゃなくFuture)というのがあります。
コールバックの代替案で、PromiseJavaScriptではasync/awaitとともに使われています。

Androidの話なので、JavaScriptの話をしてもあれですが、一応ね。こんな感じのJavaScriptです。
Promiseはコールバックのように非同期で処理されます。そのためどこかで待つ必要があります。then()catch()ですね。

コールバックはライブラリによってコールバック関数の名前が違ったりしますが(onSuccess / onResponse / onError / onFailure とか?)、
Promiseで書かれていればthen() / catch()と言った感じで一貫しています。
(ってPromise 本に書いてありました。→ https://azu.github.io/promises-book/#what-is-promise

then()では、配列操作のmap { }のように値を変換して返すことが出来ます。
ここにPromiseを返すことが出来て、このあとのthen()で受け取ることが出来ます。Promiseで出来ていれば一貫していることになるので、処理を繋げることが出来ます。
これをPromise チェーンとか言うそうです。

function main() {
    fetch("https://example.com") // Promise を返す HTTP クライアント。Response を返す Proimse です
        .then(res => res.text()) // Promise 経由で Response を受け取り、Response.text() を返す。String を返す Promise です
        .then(text => console.log(text)) // Response.text() の Promise 結果を受け取る 
        .catch(err => console.log(err)) // エラー
}

Promise チェーンが無いとコールバック地獄になってしまいますからね。
↓のコードは↑のコードと大体同じですが、明らかに↑の、メソッドチェーンで呼び出していくほうがまだマシでしょう。

function main2() {
    // コールバックが深くなっていく
    // マトリョーシカ
    fetch("https://example.com").then(res => {
        res.text().then(text => {
            console.log(text)
        })
    })
}

Kotlin Coroutinesの話をするのであんまり触れませんが、あとは複数のPromiseを待ち合わせたりも出来ます。
コールバックだと全部のコールバックが呼ばれたかの処理が冗長になりそうですからね。

function main3() {
    const multipleRequest = [
        fetch("https://example.com"),
        fetch("https://example.com"),
        fetch("https://example.com")
    ]
 
    // Promise 全部まつ
    Promise.all(multipleRequest).then(resList => {
        // resList は Response の配列
    })
}

ただ、よく見るとコールバックが少し減ったくらいしか差が無いと言うか(申し訳ない)、
Promiseを繋げたり、全部待つ必要がないならあんまり旨味がないのでは?と。then()で受け取るのとコールバックで受け取るのはあんまり差がない?。
then() / catch()のせいでtry-catchは結局使えないじゃんって?。

ついに来た async / await や Kotlin Coroutines

上記のPromiseではまだコールバック風な文化が残っていました。しかしついに今までの同期風に書けるような機能が来ました。async/awaitです。
async/awaitはそれぞれえいしんく/えいうぇいとと読むらしい、あしんく/あうぇいとってのは多分違う。

ついにtry-catchが使えるようになりました。
同期風にかけるようになったため、Promiseを繋げる→ああPromise チェーンかとか考えなくても、awaitを付けてあとは同期的に書けば良くなりました。

async function main4() {
    try {
        const response = await fetch("https://example.com") // Promise が終わるまで待つ
        const text = await response.text()
        console.log("成功")
    } catch (e) {
        console.log("失敗")
    }
}
 
function main5() {
    main4() // async function は Promise を返す
        .then(() => { /* do nothing */ })
        .catch(() => { /* do nothing */ })
}

ループだって怖くない。ただしJavaScriptforEach() / map()async function呼び出せないので、純粋なループにする必要あり。

async function main4() {
    try {
        for (let i = 0; i < 10; i++){
            const response = await fetch("https://example.com") // Promise が終わるまで待つ
            const text = await response.text()
            console.log(`成功 ${i}`)
        }
    } catch (e) {
        console.log("失敗")
    }
}
 
function main5() {
    main4() // async function は Promise を返す
      .then(() => { /* do nothing */ })
      .catch(() => { /* do nothing */ })
}
 
main5();

ちゃんとforの順番通りでました!もうコールバックやだ!

成功 0
成功 1
成功 2
成功 3
成功 4
成功 5
成功 6
成功 7
成功 8
成功 9

Kotlin CoroutinesはこれのKotlin版です。async functionsuspend funになります。
というわけで長い長いあらずしも終わり。いよいよ本題にいきましょう。

最初のコルーチン

https://kotlinlang.org/docs/coroutines-basics.html#your-first-coroutine

兎にも角にもなにか書いてみましょう。というわけでこちら。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        
        lifecycleScope.launch { // コルーチンを起動
            delay(1000L) // 1秒間コルーチンを一時停止
            println("World!") // 待った後に println
        }
        println("Hello") // 予想通り、スレッドのときと同じくまずはこれが出力されます。
    }
}

"Hello"を出力して、その1秒後"World"を出力するコルーチンです。
出力結果はこう。何の面白みもないですが。

Hello
World!

launch { }関数を使い新しいコルーチンを起動しています。delay()は指定時間コルーチンの処理を一時停止してくれます。

lifecycleScopeというのは、AndroidActivityと連携したコルーチンスコープです。
コルーチンスコープというのは後で説明しますが、とにかく新しくコルーチンを起動するときにはスコープが必要だということがわかれば。

ちなみに、ドキュメントではGlobalScopeや、runBlocking { }がコード例として出てきますが、Androidアプリ開発ではまず使いません。
Androidでコルーチンを使う場合は、lifecycleScopeとかrememberCoroutineScope()とかの用意されたコルーチンスコープを使い起動します。コルーチンスコープを自分で作ることも出来ますがあんまりないと思います。

GlobalScoperunBlocking { }はコルーチンのサンプルコードを書く場合には便利なのですが、実際のコードの場合は少なくともAndroidでは出番がありません。

delay()はサスペンド関数の1つで、fun start()suspend fun start()のように書き直せば、サスペンド関数を自分で作ることも出来ます。
これらはサスペンド関数の中で呼び出すか、launch { }async { }などの中じゃないと呼び出せません。

自分でsuspend funを作った場合、上記のコードはこんな感じになります。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        
        lifecycleScope.launch { // コルーチンを起動
            printWorld() // suspend fun を呼ぶ
        }
        println("Hello") // 予想通り、スレッドのときと同じくまずはこれが出力されます。
    }
 
    private suspend fun printWorld() {
        delay(1000L) // 1秒間コルーチンを一時停止
        println("World!") // 待った後に println
    }
}

ザックリ説明したところで、
コルーチンの話をしていく前に、先に言った通り、なぜスレッドと違って大量にコルーチンを作れるのかという話を。

大量にコルーチンを起動できる理由 その1

Kotlin Coroutinesは今あるスレッドを有効活用します。スレッドを退屈させない(遊ばせない)仕組みがあります。
例えば以下のコード。3秒後と5秒後にprintlnするわけですが、これを処理するのにスレッドが 2 個必要でしょうか?

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            delay(5_000)
            println("5 秒待った")
        }
        lifecycleScope.launch {
            delay(3_000)
            println("3 秒待った")
        }
        println("起動した")
    }
}

例えばその下の3秒後に printlnとか5秒間待ってる間に処理できそうじゃないですか?

lifecycleScope.launch {
    delay(5_000) // この 5 秒待ってる間に、その下の 3 秒後に println するコードが動かせるのでは、、、?
    println("5 秒待った")
}
lifecycleScope.launch {
    delay(3_000)
    println("3 秒待った")
}
println("起動した")

Kotlin Coroutinesはこんな感じに、待ち時間が発生すれば、他のコルーチンの処理をするためスレッドを譲るようにします。
これにより、launch { }した回数よりも遥かに少ないスレッドで処理できちゃうわけ。
一時停止と再開という単語がでてきますがおそらくこれです。

Imgur

付録 譲るところを見てみる

実際に譲っているか見てみましょう。printlnを追加して、どのスレッドで処理しているかを出力するように書き換えました。
Dispatchers.Defaultというまだ習ってないものを使ってますが、とりあえずは別のスレッドを使う場合はこれをつければいいんだって思ってくれれば。Dispatchersで詳しく話します。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch(Dispatchers.Default) {
            println("[launch 1] 起動 ${Thread.currentThread().name}")
            delay(5_000)
            println("[launch 1] 5 秒待った ${Thread.currentThread().name}")
        }
        lifecycleScope.launch(Dispatchers.Default) {
            println("[launch 2] 起動 ${Thread.currentThread().name}")
            delay(3_000)
            println("[launch 2] 3 秒待った ${Thread.currentThread().name}")
        }
        println("起動した")
    }
}

結果です。実行する度に若干変化するかと思いますが、私の手元ではこんな感じでした。
launch { }直後はそれぞれ別のスレッドが使われてますが、delay後は同じスレッドを使っている結果になりました。
ちゃんとdelayで待っている間、他のコルーチンにスレッドを譲っているのが確認できました。

[launch 1] 起動 DefaultDispatcher-worker-1
起動した
[launch 2] 起動 DefaultDispatcher-worker-2
[launch 2] 3 秒待った DefaultDispatcher-worker-1
[launch 1] 5 秒待った DefaultDispatcher-worker-1

Imgur

また、この結果を見るに、delay() する前と、した後では違うスレッドが使われる場合がある。 ということも分かりましたね。
メインスレッドの場合は 1 つしか無いので有りえませんが、このサンプルではDispatchers.Defaultを指定したためにこうなりました。
詳しくはDispatchersのところで話しますが、スレッドこそ違うスレッドが割り当てられますが、Dispatchers.Defaultは複数のスレッドを雇っているので、Defaultの中で手が空いているスレッドが代わりに対応しただけです。
(また、これはdelay()に限らないんですがまだ習ってないので・・・)

付録 Thread.sleep と delay

この2つ、どちらも処理を指定時間止めてくれるものですが、大きな違いがあります。

Thread.sleepはスレッド自体を止めてしまいます。コルーチンを実際に処理していくスレッド自体が止まってしまいます。
一方delayは指定時間コルーチンの処理が一時停止するだけで、スレッドは止まらない。止まらないので、待っている間、スレッドは他のコルーチンの利用に割り当てることができます。

delayはスレッドが止まるわけじゃないので、例えばメインスレッドで処理されるコルーチンを作ったところで、
ANRのダイアログは出ません。コルーチンが一時停止するだけで、メインスレッド自体は動き続けていますから。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch(Dispatchers.Main) { // Main でメインスレッドで処理されるコルーチンが作れます 
            delay(10_000) // Thread.sleep で 10 秒止めたら確実に ANR ですが、delay はコルーチンが一時停止するだけなので
            Toast.makeText(this@MainActivity, "ANR は出ません!", Toast.LENGTH_SHORT).show()
        }
    }
}

Imgur

たとえ無限にdelayしたとしても、上記の理由によりびくともしないと思います。

大量にコルーチンを起動できる理由 その2

ただ、上記の説明は、既存のスレッドを効率良く使う説明であって、
大量に作ってもいい理由にはあんまりなっていない気がします。

https://stackoverflow.com/questions/63719766/

Javaのスレッドは、OSのスレッドを使って作られています。JavaのスレッドとOSのスレッドは1:1の関係になりますね。
スレッドを新たに作るのはメモリを消費したりと、コストがかかる処理のようです。

また、搭載しているCPUのコア数以上にスレッドを生成されると処理しきれなくなるため、各スレッド均等に処理できるよう(よく知らない)
コンテキストスイッチと呼ばれる切り替えるための仕組みがあるのですが、これも結構重い処理らしい。
どうでも良いですが、コンテキストスイッチがあるので、1コア CPUだとしても複数のスレッドを動かすことが出来ます。同時に処理できるとは言ってませんが。(パラレルとコンカレントの話は後でします)

話を戻して、Kotlin Coroutineslaunch { }でコルーチンを作成しても、スレッドは作成されません。
もちろん、 コルーチンの中身を処理していくスレッドが必要なのですが、Kotlin Coroutines側ですでにスレッドを確保しているので(詳しくはDispatchersで)、それらが使われます。

そのためコルーチンと、確保しているスレッドの関係は多:多の関係になります。
どれかのスレッドで処理されるのは確かにそうですが、スレッドとコルーチンが1:1で紐付けられるわけではありません。大量にコルーチンを起動出来るもう一つの理由ですね。

コンテキストスイッチに関してもOSのスレッドだとOSがやるので重たい処理になる(らしい)のですが、
コルーチンだとKotlin側が持ってるスレッド上でコルーチンを切り替えるだけなので軽いらしい。

付録 本当にメモリ使用量が少ないのか

そうは言ってもよく分からないと思うので、thread { }が本当に重たいのか見ていきたいと思います。
それぞれ1000個(!?)作ってみます。Pixel 8 Pro / Android 15 Betaで試しました。デバッグビルドなのであんまりあてにならないかも。

開発中のアプリであれば、Android StudioProfilerでメモリ使用量を見ることが出来ます。
Imgur

スレッドでテスト

ボタンを押したらスレッドを作ってThread.sleep(60秒)するコードを書きました。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        setContent {
            KotlinCoroutiensPracticeTheme {
                MainScreen()
            }
        }
    }
}
 
@Composable
private fun MainScreen() {
    fun runMemoryTest() {
        repeat(1000) {
            thread {
                Thread.sleep(60_000)
            }
        }
    }
 
    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Button(onClick = { runMemoryTest() }) {
                Text(text = "確認")
            }
        }
    }
}

結果がコレです。
ボタンを押したら赤い丸ポチが付くわけですが、まあ確かに増えてますね。

Imgur

コルーチンでテスト

ボタンを押したら、コルーチンを起動(launch { })して、delay(60秒)するコードを書きました。みなさんも試してみてください。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        setContent {
            KotlinCoroutiensPracticeTheme {
                MainScreen()
            }
        }
    }
}
 
@Composable
private fun MainScreen() {
    val scope = rememberCoroutineScope()
 
    fun runMemoryTest() {
        repeat(1000) {
            scope.launch {
                delay(60_000)
            }
        }
    }
 
    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Button(onClick = { runMemoryTest() }) {
                Text(text = "確認")
            }
        }
    }
}

結果がコレで、赤い丸ポチが出ているときがボタンを押したときです。
最初ちょっと増えましたが、2回目以降は押しても得には、目に見えるレベルで増えたりはしてなさそう。

Imgur

実際にJavaのスレッドを作っているわけじゃないだけあって、いっぱい押しても特に起きない

Imgur

構造化された並行性

https://kotlinlang.org/docs/coroutines-basics.html#structured-concurrency

コルーチンのドキュメントをいい加減なぞっていこうかと思ったのですが、
もう一個、これはスレッドFuturePromiseから来た人たちが困惑しないように先に言及することにしました。これら3つにはない考え方です。

英語だとstructured concurrencyって言うそうです。かっこいい。

launch が使えない問題

もしこれを見る前にすでにKotlin Coroutinesを書いたことがある場合、まず壁にぶち当たるのがこれ。
launch { }を使いたくても、赤くエラーになるんだけど。って。しかも謎なことに、書く場所によってはエラーにならない。一体なぜ!?

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            downloadFile()
            
            launch {  } // ここはエラーじゃない(?)
        }
    }
 
    private suspend fun downloadFile() {
        launch { } // ここだとエラー(???)コルーチン起動したいよ!
    }
}

エラーになるので仕方なくGlobalScopeと呼ばれる、どこでも使えるコルーチンスコープを使って強行突破を試みますが、
そもそもGlobalScopeを使うことが滅多にないとLintに警告されます。なんでよ!?

private suspend fun downloadFile() {
    GlobalScope.launch { } // GlobalScope は滅多に使いません。
}

Imgur

反省してGlobalScopeのドキュメントを見に行きましょう。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-global-scope/

読んでみると、どうやらcoroutineScope { }を使えば、suspend funの中でもlaunch { }出来るっぽいですよ!?
試してみると、確かにcoroutineScope { }ではエラーが消えています。

private suspend fun downloadFile() {
    coroutineScope {
        launch { } // 祝!これで起動できた!!!
    }
}

これは何故かと言うと、launch { }coroutineScopeの拡張関数になっているからですね。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html

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

また、launch { }の中でlaunch { }出来たのは、launchの引数blockが、thisCoroutineScopeを提供していたからなんですね。
以下のコードが分かりやすいかな?

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            val scope: CoroutineScope = this // launch のブロック内はコルーチンスコープがある
            this.launch {  } // コルーチンスコープがあるので起動できる
            launch { } // これでいい
        }
    }
}

ちなみに、launchCoroutineScopeの拡張関数になっているという答えにたどり着けた場合、自分が作る関数もCoroutineScopeを取る拡張関数にすればいいのでは・・・!という答えになるかもしれません。
もしその発想にたどり着けた暁にはもうゴールは目前で、最後の一押しとしてLintcoroutineScope { }に置き換えるよう教えてくれます。かしこい!!!

// Lint で coroutineScope { } に置き換えるよう教えてくれる
private suspend fun CoroutineScope.downloadFile2() {
    launch { } // this が CoroutineScope なので問題は無い
    launch { }
}

Imgur

ただ、引数にコルーチンスコープを取る場合は教えてくれないので注意。

private suspend fun downloadFile3(scope: CoroutineScope) {
    scope.launch { 
        
    }
}

ところで、なんでコルーチンを起動するのにコルーチンスコープが必要なんでしょうか?
スレッドや Future 、Promise はどこでも作れるじゃないですか、なんでこんな仕様なの?めんどくせ~~

と思うかもしれませんが、これはスレッドFuturePromiseにはない安全設計のため、この様になっています。
この安全設計が構造化された並行性と呼ばれるものです。

親は子を見守る

Kotlin Coroutinesに構造化された並行性を導入した方の、こちらの記事もどうぞ
https://elizarov.medium.com/structured-concurrency-722d765aa952

↑の方の記事で引用されているこちらも。
コールバックはgoto文と何ら変わらないという話
https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/

Imgur
Imgur ちなD.C.5

いや、上記の画像はあんまり関係ないのですが、
二人三脚という競技は、例えば解けてしまった場合は結び直して再出発する必要があります。
いきなり相方がどっか走り出したらルール違反になります。

おおむね、プログラミングの世界でも、起動した並列処理がどこか行方不明にならないよう待ち合わせしたり、
時にはエラーになったら他の並列処理を終了に倒したいときがあります。分割ダウンロードを作るとか。

Imgur

JavaScriptPromiseでは、Promise.allを使うことで全てのPromiseが終わるまで待つ事ができます。
しかし、特に待ち合わせとか何もしない場合は独立してPromiseが動きます。これはスレッドFutureにも言えますが。

例えば以下のコード、main5関数の方は明示的に並列処理を待ち合わせしていますが、main6の方は非同期処理を開始するだけ開始してそのままにしています。
main6の呼び出し後は面倒を見ていません。別にPromiseに関係なく、コールバックだろうとそのまま待たずに呼び出し元へ戻ったら同じことが言えます。

async function main4() {
    // 待ち合わせする
    await main5()
    // これは待ち合わせしないのですぐ呼び出し元(ここ)に戻って来る
    main6()
}
 
// これは全ての Promise を待ち合わせする。待ち合わせが終わるまで呼び出し元へは戻らない
async function main5() {
    const multipleRequest = [
        fetch("https://example.com"),
        fetch("https://example.com"),
        fetch("https://example.com")
    ]
 
    return await Promise.all(multipleRequest)
}
 
// これは非同期処理を開始するだけ開始して、呼び出し元にすぐに戻る
function main6() {
    fetch("https://example.com")
    fetch("https://example.com")
    fetch("https://example.com")
}

構造化された並行性ではこれを問題視しています。
例えば、非同期処理を待たないので、コードの理解が困難になります。
冒頭で行った通り、非同期処理、コールバックたちはプログラミングのいろはを全て破壊していったので、
forは順番を守らない、try-catch-finallyだとcatchに来ない、finallyが非同期処理よりも先に呼ばれるしで、理解が困難になります。
finallyが先に呼ばれるせいでAutoCloseable#use { }、他の言語のusing { }が使えない等)

それではKotlin Coroutinesを見ていきましょう。賢い仕組みがあります。そして困惑するかもしれません。
以下のコードを見てください。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            runMultipleTask()
        }
    }
 
    private suspend fun runMultipleTask() {
        coroutineScope { // コルーチンスコープを作成
            println("coroutineScope 爆誕")
            listOf(3_000L, 5_000L, 10_000L).forEach { time -> // 3・5・10 秒待つコルーチンを並列で起動
                launch {
                    delay(time)
                    println("$time ミリ秒待ったよ!")
                }
            }
        }
        println("おしまい!")
    }
}

上でいった通り、suspend funの中ではlaunch { }出来ないため、coroutineScope { }を使いました。
これの実行結果ですが、予想できますか?

coroutineScope 爆誕
3000 ミリ秒待ったよ!
5000 ミリ秒待ったよ!
10000 ミリ秒待ったよ!
おしまい!

先に おしまい! が来るのかと思いきや、coroutineScope { }で起動した3・5・10 秒待つコルーチンを並列で起動を全て待ってからおしまいに進んでいます。
最後に10000 ミリ秒待ったよ!が出ると思ったそこのキミ。多分スレッドFuturePromiseから来ましたね?

これが構造化された並行性と呼ばれるもので、並列で起動した子コルーチンが全て終わるまで、親のコルーチンが終わらないという特徴があります。
明示的に待つ必要はなく、暗黙のうちに全ての子の終了を待つようになっています。(もちろん明示的に待つ事もできます。join()
この子が終わっているかの追跡に、コルーチンスコープを使っているんですね。新しいコルーチンの起動にコルーチンスコープが必要なのもなんとなく分かる気がする。

スレッドFuturePromiseの場合、この構造化された並行性が無いため、
非同期処理を開始するだけ開始して、成功したかまでは確認しない。すぐ戻って来るよろしく無い関数が作れてしまいます。
一方Kotlin Coroutinesでは基本的に並列処理が終わる前に戻ってくるような関数は作れません。
コルーチンスコープの使い方を間違えていなければ)

エラーが伝搬する

こっちのが分かりやすいかな。
Kotlin Coroutinesでは、並列実行した処理でどれか1つが失敗したら他も失敗するのがデフォルトです。生き残っている並列処理をそのまま続行したいことのほうが稀なはずなので、これは嬉しいはず。

どれか1つのPromise / Future / スレッドで失敗したら他も失敗にするという処理、なかなか面倒な気がします。
また、キャンセルさせたいと思っても全てのPromise / Future / スレッドに失敗を伝搬する何かを自前で実装する必要があります。

Kotlin Coroutinesのデフォルトでは、子が失敗した場合その他の子も全てを失敗にします。
どれか1つが失敗したら、親が検知して、子供に失敗、もといキャンセルを命令します。ちなみにキャンセルは例外の仕組み動いているのですが、後述します。

以下のコードで試してみましょう。
まだ習っていないCancellationExceptionとかが出てきますがすいません。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch(Dispatchers.Default) {
            try {
                splitTask()
            } catch (e: RuntimeException) {
                println("errorTask() が投げた RuntimeException をキャッチ!")
            }
        }
    }
 
    // delayTask を3つ、errorTask を1つ並列で実行する
    private suspend fun splitTask() = coroutineScope {
        launch { delayTask(1) }
        launch { delayTask(2) }
        launch { delayTask(3) }
        launch { errorTask() }
    }
 
    private suspend fun errorTask() {
        delay(3_000)
        throw RuntimeException()
    }
 
    private suspend fun delayTask(id: Int) {
        try {
            delay(10_000)
        } catch (e: CancellationException) {
            println("失敗 $id")
            throw e
        }
    }
}

logcatがこうです。

失敗 1
失敗 2
失敗 3
errorTask() が投げた RuntimeException をキャッチ!

delayTask10秒待っている間に、errorTask()が例外を投げました。
すると、エラーを伝搬するため、他のdelayTask()CancellationException例外が投げられます。
子を失敗させたら、最後に呼び出し元へエラーを伝搬させます。呼び出し元のcatchで例外をキャッチできるようになります。

CancellationExceptionの話はまだしてないのであれですが、キャンセルが要求されるとこの例外がスローされます。
ルールとしてCancellationExceptioncatchしたら再 throwする必要があるのですが、これも後述します。

呼び出し元はRuntimeExceptionをキャッチしておけば例外で落ちることはないですね。

もちろん、すべての子の待ち合わせしつつ、子へキャンセルを伝搬させない方法ももちろんあります。
並列で処理するけど、他の並列処理に依存していない場合に使えると思います。

ちなみに

コルーチンスコープを頼りにしているので、コルーチンスコープを適切に使わない場合は普通に破綻します。思わぬエラーです。
例えば以下はすべての子を待ち合わせしません。スコープが違うので他人の子です。我が子以外には興味がないサスペンド関数さん。

private suspend fun runMultipleTask() {
    coroutineScope {
        println("coroutineScope 爆誕")
        listOf(3_000L, 5_000L, 10_000L).forEach { time ->
            lifecycleScope.launch { // coroutineScope { } の scope 以外を使うと破綻する
                delay(time)
                println("$time ミリ秒待ったよ!")
            }
        }
    }
    println("おしまい!")
}
coroutineScope 爆誕
おしまい!
3000 ミリ秒待ったよ!
5000 ミリ秒待ったよ!
10000 ミリ秒待ったよ!

これだって、以下のように書くとエラーが伝搬しません。それから呼び出し元で例外をキャッチできないので絶対やめましょう。
我が子以外には説教をしないサスペンド関数です。

lifecycleScope.launch(Dispatchers.Default) {
    try {
        splitTask()
    } catch (e: RuntimeException) {
        // errorTask() が coroutineScope { } の scope じゃないのでここではキャッチできません。エラーが伝搬しないので。
        println("errorTask() が投げた RuntimeException をキャッチ!")
    }
}
 
// 省略...
 
private suspend fun splitTask() = coroutineScope {
    launch { delayTask(1) }
    launch { delayTask(2) }
    launch { delayTask(3) }
    lifecycleScope.launch { errorTask() } // coroutineScope { } の scope を使っていない。これだと伝搬しない。
}

キャッチしきれなくて普通にクラッシュします。

Process: io.github.takusan23.kotlincoroutienspractice, PID: 29855
java.lang.RuntimeException
	at io.github.takusan23.kotlincoroutienspractice.MainActivity.errorTask(MainActivity.kt:65)
	at io.github.takusan23.kotlincoroutienspractice.MainActivity.access$errorTask(MainActivity.kt:28)
	at io.github.takusan23.kotlincoroutienspractice.MainActivity$errorTask$1.invokeSuspend(Unknown Source:14)

以上!!!!
構造化された並行性!!多分クソわかりにくかったと思う。

キャンセル

コルーチンのドキュメント通りに歩くと、次はこれです。
Cancellation and timeouts

https://kotlinlang.org/docs/cancellation-and-timeouts.html

これも大事で、これを守らないとKotlin Coroutines売り文句とは裏腹に、思った通りには動かないコードが出来てしまいます。
というわけでキャンセル、読んでいきましょう。

コルーチンのキャンセル方法

いくつかありますがlaunch { }したときの返り値のJobを使う場合。Job#cancelが生えているので呼べばキャンセルできます。

例えば以下のJetpack Composeで出来たカウントアップするやつでは、
開始ボタンを押したらループがコルーチンで開始、終了ボタンを押したらコルーチンをキャンセルさせて、カウントアップを止めます。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        setContent { MainScreen() }
    }
}
 
@Composable
private fun MainScreen() {
    val scope = rememberCoroutineScope()
 
    var currentJob = remember<Job?> { null }
    val count = remember { mutableIntStateOf(0) }
 
    Scaffold { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
 
            Text(
                text = count.intValue.toString(),
                fontSize = 24.sp
            )
 
            Button(onClick = {
                // launch の返り値を持つ。キャンセルに使う
                currentJob = scope.launch {
                    while (isActive) { // コルーチンがキャンセルされると isActive が false になる
                        delay(1_000) // 1 秒一時停止する
                        count.intValue++
                    }
                }
            }) {
                Text(text = "カウントアップ開始")
            }
 
            Button(onClick = {
                // キャンセルする
                currentJob?.cancel()
            }) {
                Text(text = "カウントアップ停止")
            }
        }
    }
}

Imgur

コルーチンスコープを利用したキャンセル

他にもCoroutineScope#cancelCoroutineContext#cancelChildrenを使って、子のコルーチンを全てキャンセルさせる事もできます。
cancel()だとこれ以降コルーチンを作ることが出来ないので、それが困る場合はcancelChildren()を使うといいと思います。

Android のコルーチンスコープのキャンセル

ただ、Androidだとコルーチンスコープのキャンセルはあんまりしないと思います。
というのも、既にAndroidが用意しているコルーチンスコープ、lifecycleScope / viewModelScope / rememberCoroutineScope() / LaunchedEffectたちは、それぞれのライフサイクルに合わせて自動でキャンセルする機能を持っています。

lifecycleScopeではonDestroy(確か)、rememberCoroutineScope() / LaunchedEffectではコンポーズ関数が表示されている間。
なので、例えば以下のコードでは、条件分岐でコンポーズ関数が表示されなくなったらLaunchedEffectを勝手にキャンセルしてくれます。コンポーズ関数が画面から消えたのにカウントアップだけ残り続けるなんてことは起きません。

@Composable
private fun MainScreen() {
    val isEnable = remember { mutableStateOf(false) }
    if (isEnable.value) {
        // true の間のみ、false になった場合は LaunchedEffect が呼ばれなくなるのでキャンセルです
        LaunchedEffect(key1 = Unit) {
            while (isActive) {
                delay(1_000)
                println("launched effect loop ...")
            }
        }
    }
 
    Button(onClick = { isEnable.value = !isEnable.value }) {
        Text(text = "isEnable = ${isEnable.value}")
    }
}

自分でコルーチンスコープを作る場合、MainScope()CoroutineScope()を呼び出せば作れるのですが、それはコルーチンコンテキストとディスパッチャの章で!
#コルーチンスコープ

コルーチンが終わるまで待つ

cancel()したあとにjoin()することで終わったことを確認できます。別にキャンセルしなくても終わるまで待ちたければjoin()すれば良いです。
cancel()はあくまでもキャンセルを命令するだけで、キャンセルの完了を待つ場合はjoin()が必要です。
後述しますが、キャンセルをハンドリングして後始末をする事ができるため、その後始末を待つ場合はjoin()が役立つかも。
また、cancelAndJoin()とかいう、名前通り2つを合体させた関数があります。

キャンセルの仕組み

まずはキャンセルの仕組みをば。
キャンセルの仕組みですが、キャンセル用の例外CancellationExceptionをスローすることで実現されています。
キャンセルが要求された場合は上記の例外をスローするわけですが、誰がスローするのかと言うと、一時停止中のサスペンド関数ですね。以下の例だとdelay()さんです。

while (isActive) { // isActive はキャンセルしたら false になるよ
    delay(1_000) // キャンセルが要求されたらキャンセル例外を投げるよ!
    println("loop ...")
}

例えば先程のカウントアップのプログラムでは、delay()がキーパーソンになります。
delay()のドキュメントを確認しますが、指定時間待っている間にコルーチンがキャンセルされた場合は関数自身がキャンセル例外をスローすると書いています。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html

delay()はキャンセルに協力的に作られているので良いのですが、自分でサスペンド関数を書いた場合はちゃんとキャンセルに協力的になる必要があります。
この話を次でします。

try-catch / runCatching には注意

サスペンド関数を try-catch / runCatching で囲った場合、注意点があります
CancellationException例外をスローすることでキャンセルが実現しているわけですが、try-catchrunCatchingCancellationExceptionをキャッチした場合はどうなるでしょうか?

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        lifecycleScope.launch(Dispatchers.Default) {
            // 5秒になるまで1秒ごとに経過を logcat に出す
            val timer = launch { printTimer() }
 
            // キャンセルしてみる
            delay(2_000)
            timer.cancel()
            println("キャンセルしたよ")
            // コルーチンが終わるのを待つ
            timer.join()
            println("おわったよ")
        }
    }
 
    private suspend fun printTimer() {
        runCatching { delay(1_000) }
        println("1秒経過")
        runCatching { delay(1_000) }
        println("2秒経過")
        runCatching { delay(1_000) }
        println("3秒経過")
        runCatching { delay(1_000) }
        println("4秒経過")
        runCatching { delay(1_000) }
        println("5秒経過")
    }
 
}

結果はこれです。
キャンセル後もキャンセルされずに処理が続行されています。これはキャンセル例外をキャッチしてしまったのが理由です。

1秒経過
キャンセルしたよ
2秒経過
3秒経過
4秒経過
5秒経過
おわったよ

修正方法としては、

  • try-catchでは最低限の例外だけをキャッチする
  • CancellationExceptionをキャッチしたら再スローする
  • キャンセルされているか明示的に確認する

のどれかが必要です。まずはtry-catchから。

// 最低限だけキャッチする
private suspend fun printTimer() {
    try {
        delay(1_000)
    } catch (e: RuntimeException) {
        // 必要な例外だけキャッチする。ちなみに delay は RuntimeException スローしないと思いますが
    }
    println("1秒経過")
 
    try {
        delay(1_000)
    } catch (e: RuntimeException) {
        // ...
    }
    println("2秒経過")
 
    // 以下省略...
}

もしくは、Exception を網羅的にキャッチするが、CancellationException だけは再スローする。

private suspend fun printTimer() {
    try {
        delay(1_000)
    } catch (e: CancellationException) {
        // キャンセル例外だけはキャッチして再 throw
        throw e
    } catch (e: Exception) {
        // Exception を網羅的にキャッチ
    }
    println("1秒経過")
 
    try {
        delay(1_000)
    } catch (e: CancellationException) {
        // キャンセル例外だけはキャッチして再 throw
        throw e
    } catch (e: Exception) {
        // Exception を網羅的にキャッチ
    }
    println("2秒経過")
 
    // 以下省略...
}

同じことがrunCatching { }にも言えます。
これはKotlin CoroutinescencellableRunCatching { }とか、suspendRunCatching { }を作ってくれないのが悪い気もする。
議論されてるけど、、、まーだ時間かかりそうですかねー?:https://github.com/Kotlin/kotlinx.coroutines/issues/1814

対策としてはrunCatching { }のキャンセル対応版を作るか
参考にしました、ありがとうございます:https://nashcft.hatenablog.com/entry/2023/06/16/094916

// 対策版 runCatching に置き換える
private suspend fun printTimer() {
    suspendRunCatching { delay(1_000) }
    println("1秒経過")
    suspendRunCatching { delay(1_000) }
    println("2秒経過")
    suspendRunCatching { delay(1_000) }
    println("3秒経過")
    suspendRunCatching { delay(1_000) }
    println("4秒経過")
    suspendRunCatching { delay(1_000) }
    println("5秒経過")
}
 
/** コルーチンのキャンセル例外はキャッチしない[runCatching] */
inline fun <T, R> T.suspendRunCatching(block: T.() -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: CancellationException) {
        throw e
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

もしくは、ResultクラスにgetOrCancel()みたいな拡張機能を作って、Resultを返すけど、もしキャンセル例外で失敗していればスローするとか。

private suspend fun printTimer() {
    runCatching { delay(1_000) }.getOrCancel()
    println("1秒経過")
    runCatching { delay(1_000) }.getOrCancel()
    println("2秒経過")
    runCatching { delay(1_000) }.getOrCancel()
    println("3秒経過")
    runCatching { delay(1_000) }.getOrCancel()
    println("4秒経過")
    runCatching { delay(1_000) }.getOrCancel()
    println("5秒経過")
}
 
/** [Result]の失敗理由がキャンセル例外だった場合は、キャンセル例外をスローする拡張関数 */
fun <T> Result<T>.getOrCancel(): Result<T> = this.onFailure {
    if (it is CancellationException) {
        throw it
    }
}

try-catchrunCatching { }どっちも厳しい場合は
try-catchrunCatching { }の後にensureActive()を呼び出す手もあります。
ensureActive()CoroutineScopeの拡張関数なのでコルーチンスコープが必要で、coroutineScope { }で囲ったり、launch { }の中で使わないといけないのが玉に瑕。

private suspend fun printTimer() = coroutineScope {
    try {
        delay(1_000)
    } catch (e: Exception) {
        // ...
    }
    ensureActive() // この時点でキャンセルされている場合、キャンセル例外をスローする
    println("1秒経過")
 
    try {
        delay(1_000)
    } catch (e: Exception) {
        // ...
    }
    ensureActive() // この時点でキャンセルされている場合、キャンセル例外をスローする
    println("2秒経過")
    
    // 以下省略...
}
private suspend fun printTimer() = coroutineScope {
    runCatching { delay(1_000) }
    ensureActive() // runCatching { } の後にキャンセルチェック
    println("1秒経過")
    runCatching { delay(1_000) }
    ensureActive() // runCatching { } の後にキャンセルチェック
    println("2秒経過")
    runCatching { delay(1_000) }
    ensureActive() // runCatching { } の後にキャンセルチェック
    println("3秒経過")
    runCatching { delay(1_000) }
    ensureActive() // runCatching { } の後にキャンセルチェック
    println("4秒経過")
    runCatching { delay(1_000) }
    ensureActive() // runCatching { } の後にキャンセルチェック
    println("5秒経過")
}

出力結果はこうです。
キャンセル後は余計な処理がされていないことを確認できました!

1秒経過
キャンセルしたよ
おわったよ

キャンセルは協力的

さて、ここからが大事です。(ここまでも大事ですが)
さっき書いたカウントアップするだけのコードはキャンセル出来ましたが、これはdelay()isActiveがキャンセルに協力しているからキャンセルできただけです。
自分でサスペンド関数を書く場合は、キャンセルに協力的になる必要があります。

例えば以下の、コルーチンの中で、OkHttpGETリクエストを同期的に呼び出すコードを動かしてみます。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        // 別スレッドを指定したコルーチン
        val internetJob = lifecycleScope.launch(Dispatchers.IO) {
            repeatGetRequest()
        }
 
        lifecycleScope.launch {
            delay(2_000)
            println("キャンセルします")
            internetJob.cancelAndJoin()
            println("cancelAndJoin() 終わりました")
        }
    }
 
    // OkHttp の同期モードで何回か GET リクエストを投げます
    private suspend fun repeatGetRequest() {
        // 5 回くらい
        repeat(5) {
            val request = Request.Builder().apply {
                url("https://example.com")
                get()
            }.build()
            val response = OkHttpClient().newCall(request).execute()
            println("レスポンス ${response.code}")
        }
    }
}

読者さんのインターネットの速度によっては、以下のように再現できないかも。なのであれなのですが。(開発者向けオプションのネットワーク速度を変更する、使わなければ128kpovo 2.0を契約する等)

しかし、さっきのカウントアップのときとは違い、cancel()を呼んだのにもかかわらず、GETリクエストが続行されています。
これはキャンセルに協力的ではありませんね。 キャンセルしたらインターネット通信を始めないでほしいです。ギガが減るんでね。

レスポンス 200
レスポンス 200
キャンセルします
レスポンス 200
レスポンス 200
レスポンス 200
cancelAndJoin() 終わりました

ギガが減るのも良くないけど、キャンセルが適切に行われないとクラッシュを巻き起こす可能性もあります。
画面回転や、Fragment の破棄後に非同期処理が終わり、破棄されているのにUI更新しようとして落ちるパターン。ViewModelが来る前まではみんな引っかかってたはず。
getActivity() != nullとか、Fragment#isAdded() == trueとかで分岐してなんとかしのいでた。

例に漏れずコルーチンでも、UI破棄のタイミングでキャンセルを要求したのは良いものの、キャンセル対応のサスペンド関数を書いていないと、破棄後にUI更新する羽目になりやっぱり同じエラーに鳴ってしまいます。
まあUI関係ないならViewModelに書けよという話ではあるんですが。

キャンセル可能な処理の作り方

いくつか、キャンセルに関連する関数、フラグがあります。

try-catch / runCatchingのときにも触れましたが、isActiveensureActive()コルーチンスコープの拡張関数になっていて、
サスペンド関数の中をcoroutineScope { }で囲ってあげるか、launch { }の直下で使う必要があります。
まあyield()もよく見るとcoroutineScope { }を使っているので適当にcoroutineScope { }で囲っとけばいいんじゃない(適当)

今回なら、インターネット通信を始める前にキャンセルされているか確認すれば良さそうですね。
直前にensureActive()を呼ぶか、isActiveで確認を入れれば良さそうですね。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        // 別スレッドを指定したコルーチン
        val internetJob = lifecycleScope.launch(Dispatchers.IO) {
            repeatGetRequest()
        }
 
        lifecycleScope.launch {
            delay(2_000)
            println("キャンセルします")
            internetJob.cancelAndJoin()
            println("cancelAndJoin() 終わりました")
        }
    }
 
    // OkHttp の同期モードで何回か GET リクエストを投げます
    private suspend fun repeatGetRequest() = coroutineScope {
        // 5 回くらい
        repeat(5) {
            // キャンセル済みならリクエストしない
            ensureActive()
            val request = Request.Builder().apply {
                url("https://example.com")
                get()
            }.build()
            val response = OkHttpClient().newCall(request).execute()
            println("レスポンス ${response.code}")
        }
    }
}

こんな感じにキャンセル後もリクエストが継続されているような挙動じゃなくなりました。
自分で作ったサスペンド関数がキャンセル可能になりました!!!キャンセル後も、キャンセルする前のリクエストが残ってるせいで微妙に分かりにくい。

レスポンス 200
レスポンス 200
キャンセルします
レスポンス 200
cancelAndJoin() 終わりました

もしwhileループをしている場合も同様で、isActiveでループを抜けられるようにするか、ensureActive()で例外を投げる必要があります。

// OkHttp の同期モードで何回か GET リクエストを投げます
private suspend fun repeatGetRequest() = coroutineScope {
    // 5 回くらい
    var count = 0
    while (count < 5 && isActive) { // isActive も確認する
        val request = Request.Builder().apply {
            url("https://example.com")
            get()
        }.build()
        val response = OkHttpClient().newCall(request).execute()
        println("レスポンス ${response.code}")
        count++
    }
}

付録 どこに ensureActive() / isActive を入れるの

キャンセルされているか確認しろと言われても、1行毎に入れていったら洒落にならないでしょう。

これの答えは、インターネット通信とか、ファイル読み書きとか、CPUを大量に消費する処理(フィボナッチ数列を計算する)とかの、
重たい、時間がかかる処理を始める前に確認すればいいんじゃないかなと思います。
https://developer.android.com/kotlin/coroutines/coroutines-best-practices?hl=ja#coroutine-cancellable

それから、delay()やそのほかkotlinx.coroutinesパッケージ傘下にあるサスペンド関数(最初から用意されているサスペンド関数)は、基本的にキャンセルに対応しているため、ensureActive()とかで確認せずともキャンセルされたら例外を投げてくれるはずです。

逆を言えば最初から用意されていない、自分でサスペンド関数を書く場合はキャンセル出来るよう心がける必要があります。

付録 ensureActive() と isActive 2つもあって迷っちゃうな~

https://medium.com/androiddevelopers/cancellation-in-coroutines-aa6b90163629

確かに2パターンあります。
微々たる差ではあるのですが、isActiveの方は例外を投げないので、while { }の下でなにか処理をしたい場合にちょっと楽かもしれません。
ただ、ensureActive()でもtry-finallyであとしまつ出来るので、どっちでもいい気がします。

private suspend fun exampleEnsureActive() = coroutineScope {
    try {
        while (true) {
            ensureActive()
            // 重い処理
        }
    } finally {
        // あとしまつとか
    }
}
 
private suspend fun exampleIsActive() = coroutineScope {
    while (isActive) {
        // 重い処理
    }
    // あとしまつとか
}

そういえば、ensureActive()と違い、isActiveの方は例外を投げないから、isActiveでキャンセルを実装したらキャンセル後も処理が続行してしまうのでは、、、と思う方がいるかも知れません。例外の場合は投げれば後続の処理は実行されませんからね。

coroutineScope {
    ensureActive() // コルーチンキャンセル時は例外を投げる
    println("生きてる") // 例外投げたらここに進まない
}
coroutineScope {
    if (isActive) {
        println("生きてる") // コルーチン生きてる時
    }
    println("あれ?") // ここコルーチンキャンセル時も来ちゃうんじゃない?
}

たしかにそれはそうです。ただ、これはtry-finallyすればensureActive()でも出来るやつなのでそういうものだと思います。

じゃあisActiveを使った実装方法だと自作サスペンド関数がキャンセルに対応できないのかというと、そんなことはなくて、今回使ったcoroutineScope { }がキャンセルに対応しています。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/coroutine-scope.html

coroutineScope { }のブロック内(波括弧の中身)が終了して、呼び出し元へ戻る際にキャンセルチェックが入ります。
戻った際にキャンセルが要求されていることが分かったらcoroutineScope { }自身が例外を投げます。

よって、今回書いてきたコードではどちらを使ってもcoroutineScope { }ensureActive()が例外を投げてくれるので、キャンセルに対応することになります。

付録 yield() の説明もしろ

なんて読むのか調べたらいーるどって読むらしい。
記事書き終わった後に良い例を思い出したので書いてみる。

ドキュメントではスレッドを譲るって書いてあるけど、なんか難しくて避けてた。
これはスレッドを専有するような処理を書く時に使うと良いみたい。

まだ習ってない物を使いますが、limitedParallelism()を使い、1スレッドで処理されるコルーチンを2つ起動します。
1スレッドしかないため、どちらかのコルーチンがスレッドを専有、ずっと終わらない処理を書いた場合はもう片方のコルーチンは処理されないことになります。例を書きます。

class MainActivity : ComponentActivity() {
 
    /** とりあえずは 1スレッド で処理されるコルーチンを作るためのものだと思って */
    private val singleThreadDispatcher = Dispatchers.Default.limitedParallelism(1)
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        // シングルスレッドでコルーチンを2つ起動
        lifecycleScope.launch(singleThreadDispatcher) {
            while (isActive) {
                // スレッドを専有する。
                // ここでは Thread.sleep() を使いスレッドを譲らずに経過時間までブロックする
                // 本来は Thread.sleep() は使わない。スレッドをブロックする例のため意図的に使っている。
                // ブロックするならインターネット通信等の IO 処理でもいいよ
                Thread.sleep(3_000)
                println("[launch 1] Thread.sleep")
            }
        }
        lifecycleScope.launch(singleThreadDispatcher) {
            // 同じものを作る
            while (isActive) {
                Thread.sleep(3_000)
                println("[launch 2] Thread.sleep")
            }
        }
    }
}

これでlogcatを見てみると、[launch 1]しかログが出ていません。
なぜならシングルスレッドしか無い上にスレッドをブロックする無限ループを書いて専有してしまっているためです。

[launch 1] Thread.sleep
[launch 1] Thread.sleep
[launch 1] Thread.sleep
[launch 1] Thread.sleep
[launch 1] Thread.sleep

ここでyield()が役に立ちます。説明どおりならスレッドを譲ってくれるはずです。
ループ毎にyield()を呼び出してみましょう。

class MainActivity : ComponentActivity() {
 
    /** とりあえずは 1スレッド で処理されるコルーチンを作るためのものだと思って */
    private val singleThreadDispatcher = Dispatchers.Default.limitedParallelism(1)
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        // シングルスレッドでコルーチンを2つ起動
        lifecycleScope.launch(singleThreadDispatcher) {
            while (isActive) {
                yield() // 他にコルーチンあれば譲る
                // スレッドを専有する。
                // ここでは Thread.sleep() を使いスレッドを譲らずに経過時間までブロックする
                // 本来は Thread.sleep() は使わない。スレッドをブロックする例のため意図的に使っている。
                // ブロックするならインターネット通信等の IO 処理でもいいよ
                Thread.sleep(3_000)
                println("[launch 1] Thread.sleep")
            }
        }
        lifecycleScope.launch(singleThreadDispatcher) {
            // 同じものを作る
            while (isActive) {
                yield() // 他にコルーチンあれば譲る
                Thread.sleep(3_000)
                println("[launch 2] Thread.sleep")
            }
        }
    }
}

これで、logcatを見てみると、[launch 2]の出力がされるようになりました。
スレッドを専有するような処理ではyield()を入れておくと良いかもですね!

try-finally が動く

キャンセルできるサスペンド関数は、キャンセル時はキャンセル例外を投げるため、try-finallyが完全に動作します。
処理が完了するか、はたまたキャンセルされるかわかりませんが、finallyに書いておけばどちらにも対応できます。プログラミングのいろはがようやく戻ってきました。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            // 5秒で終わってみる
            val job1 = launch { tryFinally() }
            delay(5_000)
            job1.cancelAndJoin()
 
            println("---")
 
            // 10秒で終わってみる
            val job2 = launch { tryFinally() }
            delay(10_000)
            job2.cancelAndJoin()
 
        }
    }
 
    private suspend fun tryFinally() {
        try {
            delay(10_000)
            println("10秒待った")
        } finally {
            println("finally ですよ")
        }
    }
}

こんな感じにキャンセルされる、されないに関係なくfinallyが実行できています。やったやった!

finally ですよ
---
10秒待った
finally ですよ

finally でコルーチンが起動できない解決策

コルーチンがキャンセルした後、新しくコルーチンを起動することが出来ません。
あんまりないかもしれませんが、どうしてもfinallyでサスペンド関数を呼び出したいときの話です。

キャンセル済みの場合、finally { }の中ではコルーチンを起動しても、キャンセル済みなのでensureActive()を呼び出すと例外をスローするし、isActivefalseになります。
サスペンド関数は動かなくなります。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            val job = launch { tryFinally() }
            delay(5_000)
            println("キャンセル")
            job.cancelAndJoin()
        }
    }
 
    private suspend fun tryFinally() {
        try {
            delay(10_000)
            println("10秒待った")
        } finally {
            sendLog()
            println("ログ送信")
        }
    }
 
    private suspend fun sendLog() = coroutineScope {
        // キャンセルチェック
        ensureActive()
        // TODO ログ送信
    }
}

どうしてもfinally { }でサスペンド関数を呼び出したい場合は、withContext(NonCancellable) { }で処理をくくると、サスペンド関数も一応動くようになります。
withContextは後述します。が、NonCancellableを引数に渡すと、キャンセル不可の処理を実行できるようになります。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            val job = launch { tryFinally() }
            delay(5_000)
            println("キャンセル")
            job.cancelAndJoin()
        }
    }
 
    private suspend fun tryFinally() {
        try {
            delay(10_000)
            println("10秒待った")
        } finally {
            // キャンセル不可にする
            withContext(NonCancellable) {
                sendLog()
                println("ログ送信")
            }
        }
    }
 
    private suspend fun sendLog() = coroutineScope {
        // キャンセルチェック
        ensureActive()
        println("isActive = $isActive")
        // TODO ログ送信
    }
 
}

出力結果はこうです、ensureActive()がキャンセル例外をスローしなくなりました。
一方、isActiveとかも、NonCancellableが付いている場合はtrueになるので注意です。キャンセルされたかの判定が壊れます。
あくまでリソース開放とかの最小限にとどめましょうね。

キャンセル
isActive = true
ログ送信

ちなみに、NonCancellable、よく見るとlaunch { }にも渡せるんですが、withContext { }以外で使ってはいけません。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-non-cancellable/

タイムアウト

タイムアウトもできます。
withTimeout { }を使うと指定時間以内にコルーチンが終了しなかった場合に、
withTimeout { }の中の処理はキャンセルし、また関数自身もTimeoutCancellationExceptionをスローします。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            try {
                withTimeout(5_000) {
                    tryFinally()
                }
            } catch (e: TimeoutCancellationException) {
                println("たいむあうと!")
                throw e
            }
        }
    }
 
    private suspend fun tryFinally() {
        try {
            delay(10_000)
            println("10秒待った")
        } finally {
            println("finally")
        }
    }
}
finally
たいむあうと!

先述の通り、タイムアウトしたらwithTimeoutは例外を投げるので、投げられた場合後続する処理が動きません。

lifecycleScope.launch {
    withTimeout(5_000) {
        tryFinally()
    }
    println("タイムアウト例外が出たら動かない") // withTimeout が例外を投げるせいでここには来ない
}

それが困る場合は、代わりにnullを返すwithTimeoutOrNull { }を使うと良いです。

lifecycleScope.launch {
    val resultOrNull = withTimeoutOrNull(5_000) {
        tryFinally()
    }
    println("タイムアウト例外が出たら動かない")
}
finally
タイムアウト例外が出たら動かない

付録 質問:キャンセル例外を投げればキャンセルしたことになりますか

これは元ネタがあって、私はただパクっただけです、興味があれば先に元ネタを読んでください。
https://medium.com/better-programming/the-silent-killer-thats-crashing-your-coroutines-9171d1e8f79b

回答: なりません

ensureActive()の説明では以下のコードと大体同じことをやっていると書いています。
これだけ見ると、例外を投げればキャンセルできるのかと思ってしまいます。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/ensure-active.html

if (!isActive) {
    throw CancellationException()
}

それでは自分で投げてみましょう!キャンセル時の挙動は、先述の通り、

  • isActivefalseであるはずで、
  • ensureActive()も例外を投げるはずで、
  • withContext { }も使えないはず、

なので、それも出力して見てみることにします。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            try {
                throwCancel()
            } catch (e: CancellationException) {
                println("CancellationException !!!")
                println("isActive = $isActive")
 
                // ensureActive() でも例外を投げるか確認
                try {
                    ensureActive()
                    println("ensureActive() スローせず")
                } catch (e: CancellationException) {
                    println("ensureActive() キャッチ")
                    throw e
                }
                
                throw e
            }
        }
    }
 
    private suspend fun throwCancel() {
        delay(3_000)
        throw CancellationException()
    }
}

で、logcatに出てきたのがこれです。

CancellationException !!!
isActive = true
ensureActive() スローせず
withContext に入りました

どうやらダメみたいですね。
キャンセル例外を投げるだけではキャンセル扱いにはならないみたいです。ちゃんと正規ルートでキャンセルしましょう。

これはほとんど無いと思いたい。。。!
ただ、CancellationExceptionKotlin Coroutinesで追加された例外ではなく、Javaエイリアスになっているので,
Kotlin Coroutinesのことを一切考慮していないJava / Kotlinコードからその例外を投げられる可能性は、、、可能性だけならありますね。

サスペンド関数

https://kotlinlang.org/docs/composing-suspending-functions.html

つぎはこれ、サスペンド関数の話です。ついに並列処理の話ができます。

直列処理

今まで通り、そのまま書けば直列処理です。
普通に書くだけで順番通りに処理されるとか、コールバックのときにはあり得なかったことですね。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch(Dispatchers.Default) {
            request()
        }
    }
 
    private suspend fun request() {
        // 直列で実行する
        val time = measureTimeMillis {
            requestInternet()
            requestInternet()
        }
        println("$time ms で完了")
    }
 
    private suspend fun requestInternet() = coroutineScope {
        ensureActive()
        val request = Request.Builder().apply {
            url("https://example.com")
            get()
        }.build()
        OkHttpClient().newCall(request).execute()
    }
 
}

結果はこう。直列処理なので、1つ目が終わるまで2つ目は呼ばれません。

1474 ms で完了

並列処理

次に並列処理です。1番目の処理を待たずに並列で走らせることが出来ます。
async { }await()を使います。

async { }の使い方はlaunch { }のそれと同じなのですが、launch { }と違って返り値を返せます!
await()で返り値を取得できます。
それ以外は大体同じなのでコルーチンスコープが必要なのも同様です。

private suspend fun request() = coroutineScope { // scope を用意
    // 並列で実行する
    val time = measureTimeMillis {
        // async { } はすぐ実行されます
        val request1 = async { requestInternet() }
        val request2 = async { requestInternet() }
 
        // なんかやる...
 
        // 待ち合わせする
        val response1 = request1.await()
        val response2 = request2.await()
    }
    println("$time ms で完了")
}

直列のときよりも大体半分の時間で終わっています。

731 ms で完了

数が多い場合は配列に入れてawaitAll()すると良いかもしれません。
見た目が良くてすき

private suspend fun request1() = coroutineScope {
    // 並列で実行する
    val time = measureTimeMillis {
        // listOf でも
        val responseList = listOf(
            async { requestInternet() },
            async { requestInternet() }
        ).awaitAll()
 
        val (response1, response2) = responseList
    }
    println("$time ms で完了")
}
 
private suspend fun request2() = coroutineScope {
    // 並列で実行する
    val time = measureTimeMillis {
        // map で async を返して awaitAll() する
        val responseList = (0 until 2) // [0, 1]
            .map { async { requestInternet() } } // すべて並列で実行
            .awaitAll() // 全て待つ
 
        // 取り出す
        val (response1, response2) = responseList
    }
    println("$time ms で完了")
}

並列処理の開始を制御する

async { }は何もしない場合はすぐに実行されますが、明示的に開始するように修正することが出来ます。
async { }の引数にstart = CoroutineStart.LAZYをつけると、start()を呼び出すまで動かなくなります。

private suspend fun requestInternet() = coroutineScope {
    println("requestInternet()")
    ensureActive()
    val request = Request.Builder().apply {
        url("https://example.com")
        get()
    }.build()
    OkHttpClient().newCall(request).execute()
}
 
private suspend fun request() = coroutineScope { // scope を用意
    // 直列で実行する
    val time = measureTimeMillis {
        val request1 = async(start = CoroutineStart.LAZY) { requestInternet() }
        val request2 = async(start = CoroutineStart.LAZY) { requestInternet() }
 
        // なんかやる...
        println("起動前")
 
        // async 開始する
        request1.start()
        request2.start()
        println("開始した")
 
        // 待ち合わせする
        val response1 = request1.await()
        val response2 = request2.await()
    }
    println("$time ms で完了")
}

出力はこうです。
ちゃんとstart()した後にrequestInternet()が呼ばれてそうですね。

起動前
開始した
requestInternet()
requestInternet()
659 ms で完了

構造化された並行性

は最初の方で話したので、まずはこちらを読んでください。Promise / Futureとの違いの話です。
#構造化された並行性

で、上記では触れなかったasync { }の使い方をば。
他の言語から来た場合asyncキーワードは関数宣言時に使うので、このように書きたくなるかなと思います。

// ダメなパターン
@OptIn(DelicateCoroutinesApi::class)
private fun requestInternet1() = GlobalScope.async(Dispatchers.IO) { // 関数宣言時に async を使いたくなる
    ensureActive()
    val request = Request.Builder().apply {
        url("https://example.com")
        get()
    }.build()
    return@async OkHttpClient().newCall(request).execute()
}

しかし、構造化された並行性があるKotlin Coroutinesではasync { }は呼び出し側で使うのが良いです。
関数の返り値にasync { }を使う、ではなくサスペンド関数を作ってasync { }の中で呼び出すのが良いです。

というのも、これだと子コルーチンのどれかが失敗した際に、他の子コルーチンをキャンセルする動作が動かないんですよね。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch(Dispatchers.Default) {
            val job = launch {
                try {
                    task()
                } catch (e: RuntimeException) {
                    // ...
                }
            }
            println("launch した")
            delay(3_000)
            job.cancel()
            println("キャンセル")
            job.join()
            println("おわった")
        }
    }
 
    private suspend fun task() = coroutineScope {
        listOf(
            launch { suspendDelay(tag = "launch") },
            async { suspendDelay(tag = "async") },
            asyncDelay(),
            launch {
                // 子コルーチンの1つを失敗にする。全部にキャンセルが行くはず
                delay(3_000)
                println("例外を投げます")
                throw RuntimeException()
            }
        ).joinAll()
    }
 
    // ダメなパターン
    private fun asyncDelay() = GlobalScope.async {
        suspendDelay(tag = "asyncDelay")
    }
 
    /** 適当な時間 delay する */
    private suspend fun suspendDelay(tag: String) {
        try {
            delay(10_000)
            println("[$tag] 10秒たった")
        } catch (e: CancellationException) {
            println("[$tag] キャンセル!")
            throw e
        } finally {
            println("[$tag] おわり")
        }
    }
}

出力はこうです

launch した
例外を投げます
[launch] キャンセル!
[launch] おわり
[async] キャンセル!
[async] おわり
キャンセル
おわった
[asyncDelay] 10秒たった
[asyncDelay] おわり

構造化された並行性では、子のどれか1つが失敗したら他の子コルーチンにもキャンセルが伝搬するのですが、結果はこうです。
launch { suspendDelay(tag = "launch") }async { suspendDelay(tag = "async") }はちゃんとキャンセルされているのですが、asyncDelay()だけは生き残っています。

これはなぜかと言うと、asyncDelay()だけはコルーチンスコープが違うため、キャンセル命令が行き届いてないのです。
launch { }async { }suspendCoroutine { }のコルーチンスコープを使っていますが、asyncDelay()GlobalScopeのコルーチンスコープを使っています。

そのため、修正するとしたら、
コルーチンスコープをasyncDelay()の引数に渡すよりは、asyncDelay()関数をサスペンド関数にし、async { }を呼び出す責務を呼び出し側に移動させるのが正解です。
async { suspendDelay(tag = "async") }の使い方が正解ですね。

他の言語にあるPromiseとかは、返り値を返すこのスタイルが使われているので注意です。

付録 launch と async

async { }だと値が返せるんだし、全部async { }でいいのではと。
値を返さない場合はlaunch { }、値を返す必要があればasync { }でいいと思います。launch { }のほうが考えることが少なくてよいです。

というのも、詳しくは例外の章で話すのですが、例外を投げる部分が違ってきます。ほんと微々たる違いなのですが、例外なのでシビアにいきましょう。
launch { }は、親のコルーチンまで例外を伝達させます。親まで伝達するため他の子コルーチンもキャンセルになります。
ので、例外をキャッチするにはcoroutineScope { }(親のスコープ)でtry-catchする必要があります。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            try {
                coroutineScope {
                    listOf(
                        launch { delay(5_000) },
                        launch { delay(5_000) },
                        launch {
                            delay(3_000)
                            throw RuntimeException() // 3 秒後に失敗
                        }
                    )
                }
            } catch (e: RuntimeException) {
                println("投げた RuntimeException をキャッチ")
            }
        }
    }
}

async { }は例外を返せます。launch { }と違い、async { }await()を使い値を返せるといいましたが、await()で例外も受け取ることが出来ます。
ついでに親のコルーチンスコープまで例外を伝達させます。 親まで伝達するため他の子コルーチンもキャンセルになります。

なので、await()の部分ではなく、try-catchcoroutineScope { }や親のコルーチンでやる必要があります。ちなみにawait()try-catchしてもキャッチできます。ただ親にも伝達します。

逆に期待通り、await()で例外をキャッチできるようにする方法もあります。親に伝搬しない方法。
SupervisorJob()supervisorScope { }を使うことでawait()で例外をキャッチして、かつ親にも伝搬しないようになります。が、後述します。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            try {
                coroutineScope { // 親にも例外が行く
                    val asyncTasks = async {
                        delay(3_000)
                        throw RuntimeException() // 3 秒後に失敗
                    }
                    try {
                        asyncTasks.await()
                    } catch (e: RuntimeException) {
                        println("await() で RuntimeException をキャッチ")
                    }
                }
            } catch (e: RuntimeException) {
                println("coroutineScope() で RuntimeException をキャッチ")
            }
        }
    }
}

出力がこう

await() で RuntimeException をキャッチ
coroutineScope() で RuntimeException をキャッチ

コルーチンコンテキストとディスパッチャ

https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html

launch { }async { }には引数を渡すことが出来ます。この引数のことをCoroutineContext(コルーチンコンテキスト)といいます。
コルーチンコンテキストには後述するDispatchersなどを設定できるのですが、この章ではその話です。

スレッドとディスパッチャ

コルーチンは大量に作れますが、結局はどれかのスレッドで処理されるといいました。
launch { }async { }では、引数にDispatchersを指定することが出来ます。このDispatchersが実際に処理されるスレッドを指定するものです。

よく使うのが以下の3つです。

解説スレッド数
Dispatchers.Defaultメインスレッド以外のスレッドで、CPU を消費する処理向けです。2 個以上CPU のコア数以下
Dispatchers.IOメインスレッド以外のスレッドで、インターネット通信や、ファイル読み書き向けです。少なくとも 64 個、足りなければ増える。
Dispatchers.Mainメインスレッドです。1 個

DefaultIOは、Dispatchersが複数のスレッドを持っている(雇っている)形になります。
その時空いているスレッドが使われる感じです。

Dispatchers.Mainに関して、Kotlinのドキュメントには出てこないので不思議に思ったかもしれません。なぜかというとAndroid用(というかGUI向け)に拡張して作られたからです
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-main.html

実際に指定してみます。以下のコードを試してみましょう。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            printThread()
        }
    }
 
    private suspend fun printThread() = coroutineScope {
        launch {
            println("launch 無指定 ${Thread.currentThread().name}")
        }
        launch(Dispatchers.Unconfined) {
            println("launch Unconfined ${Thread.currentThread().name}")
        }
        launch(Dispatchers.Default) {
            println("launch Default ${Thread.currentThread().name}")
        }
        launch(Dispatchers.IO) {
            println("launch IO ${Thread.currentThread().name}")
        }
        launch(Dispatchers.Main) {
            println("launch Main ${Thread.currentThread().name}")
        }
    }
}

出力がこうです。
出力される順番が前後するかもしれませんが気にせず。

launch Default DefaultDispatcher-worker-2
launch IO DefaultDispatcher-worker-1
launch 無指定 main
launch Unconfined main
launch Main main

無指定の場合はmainになっていますが、これは呼び出し元、親のDispatchersを引き継ぐためです。
しかしprintThread()を呼び出しているlaunch { }でも無指定です。特に親のlaunchでも指定がない場合はコルーチンスコープに設定されているDispatchersが使われます。

ちなみに、Dispatchers.Main以外は、複数のスレッドが処理を担当するため、もしスレッドに依存した処理を書く場合は注意してください。
Android開発においてはメインスレッドとそれ以外のスレッドという認識(雑)なので特に問題はないはずです。問題がある場合はnewSingleThreadContextの説明も読んでください。

例えば以下のコード、どのスレッドで処理されるかはKotlin Coroutinesのみが知っています。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch(Dispatchers.Default) { println("Default ${Thread.currentThread().name}") }
        lifecycleScope.launch(Dispatchers.Default) { println("Default ${Thread.currentThread().name}") }
        lifecycleScope.launch(Dispatchers.Default) { println("Default ${Thread.currentThread().name}") }
        lifecycleScope.launch(Dispatchers.Default) { println("Default ${Thread.currentThread().name}") }
        lifecycleScope.launch(Dispatchers.Default) { println("Default ${Thread.currentThread().name}") }
    }
}

予測は出来ません。続きを読めば対策方法があります。

Default DefaultDispatcher-worker-1
Default DefaultDispatcher-worker-2
Default DefaultDispatcher-worker-1
Default DefaultDispatcher-worker-2
Default DefaultDispatcher-worker-1

Android が用意しているスコープはメインスレッド

AndroidlifecycleScopeJetpack ComposerememberCoroutineScope()LaunchedEffectはデフォルトでMainが指定されています。
が、心配になってきたので一応試しましょう。coroutineContext[CoroutineDispatcher]Dispatchersを取り出せるようです。

class MainActivity : ComponentActivity() {
 
    @OptIn(ExperimentalStdlibApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        println("lifecycleScope = ${lifecycleScope.coroutineContext[CoroutineDispatcher]}")
 
        setContent {
            val composeScope = rememberCoroutineScope()
            println("rememberCoroutineScope() = ${lifecycleScope.coroutineContext[CoroutineDispatcher]}")
 
            LaunchedEffect(key1 = Unit) {
                println("LaunchedEffect = ${lifecycleScope.coroutineContext[CoroutineDispatcher]}")
            }
        }
    }
}

結果は認識通り、Mainが使われていました。よかった~

lifecycleScope = Dispatchers.Main.immediate
rememberCoroutineScope() = Dispatchers.Main.immediate
LaunchedEffect = Dispatchers.Main.immediate

付録 Dispatchers.Main.immediate の immediate って何

lifecycleScoperememberCoroutineScopeLaunchedEffectコルーチンスコープDispatchers.Main.immediateだということが判明しました。
しかし、Dispatchers.MainDispatchers.Main.immediateの違いはなんなのでしょうか?

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-main-coroutine-dispatcher/immediate.html

・・・??
というわけでドキュメントを見てみましたが、いまいちよく分からなかったので、一緒に書いてあるサンプルコードのコメントを元に実際に動かしてみる。
サンプルコードのコメントを見るに、すでにメインスレッドで呼び出されている場合は、即時実行される。とのこと。試します。

まずはDispatchers.Mainで。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        println("0")
        lifecycleScope.launch(Dispatchers.Main) {
            println("1")
        }
        lifecycleScope.launch(Dispatchers.Main) {
            println("2")
        }
        lifecycleScope.launch(Dispatchers.Main) {
            println("3")
        }
        println("4")
    }
}

これは予想通り、launch { }で囲った1/2/3よりも先に4がでますね。

0
4
1
2
3

一方、Dispatchers.Main.immediateをつけると・・・?

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        println("0")
        lifecycleScope.launch(Dispatchers.Main.immediate) {
            println("1")
        }
        lifecycleScope.launch(Dispatchers.Main.immediate) {
            println("2")
        }
        lifecycleScope.launch(Dispatchers.Main.immediate) {
            println("3")
        }
        println("4")
    }
}

おお!すでにメインスレッドで呼ばれている場合はlaunch { }が後回しにされずに即時実行されていますね。

0
1
2
3
4

newSingleThreadContext

ごく、極稀に、自分で作ったスレッドでコルーチンを処理させたい時があります。
あんまり、というか本当に無いと思うのですが、唯一あったのがOpenGL ESですね。

話がそれてしまうので手短に話すと、OpenGL ESは自分を認識するのにスレッドを使っています。
メインスレッド以外でUIを操作できないのと同じように、OpenGL ESOpenGL ESのセットアップ時に使われたスレッドを自分と結びつけます。
そのため、OpenGL ESの描画を行う際は、スレッドを気にする必要があります。OpgnGL ESにはマルチスレッドは多分ありません。

話を戻して、どうしても自分で作ったスレッドでしか処理できない場合があります。
その場合はnewSingleThreadContext()を使うことで、新しく Java のスレッドを作り、その中で処理されるDispatchersを返してくれます。

はい。Java のスレッドを作ることになります。これを多用した場合はKotlin Coroutinesの売り文句の 1 つ、スレッドより軽量を失うことになります。
そのため、シングルトンにしてアプリケーション全体で使い回すか、使わなくなったら破棄する必要があります。
(親切なことにAutoCloseableインターフェースを実装しているので、use { }拡張関数が使えます!)

class MainActivity : ComponentActivity() {
 
    @OptIn(ExperimentalCoroutinesApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        // close() するか use { } を使うこと
        val newThreadDispatcher = newSingleThreadContext("new thread !!!!!")
 
        newThreadDispatcher.use {
            lifecycleScope.launch(newThreadDispatcher) {
                println(Thread.currentThread().name)
            }
        }
    }
}

出力がこうです。ちゃんと新しく作ったスレッドで処理されていますね。

new thread !!!!!

Unconfined

私もこの記事を書くために初めて使う、し、いまいちどこで使えばいいかよくわからないので多分使わない。

これは特別で、呼び出したサスペンド関数がスレッドを切り替えたら、サスペンド関数を抜けた後もそのスレッドを使うというやつです。
よく分からないと思うので例を書くと。

と、その前にMainの例。Mainで起動したコルーチンでDispatchersを切り替える。
withContextはまだ習っていないのですが、CoroutineContext(Dispatchers)を切り替える際に使います。後述します。

@OptIn(ExperimentalCoroutinesApi::class,DelicateCoroutinesApi::class)
class MainActivity : ComponentActivity() {
 
    /** 分かりやすいよう特別に Dispatchers を作る */
    private val specialDispatcher = newSingleThreadContext("special thread dispatcher !!!")
 
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
 
    lifecycleScope.launch(Dispatchers.Main) {
        println("1-1 = ${Thread.currentThread().name}")
        specialTask()
        println("1-2 = ${Thread.currentThread().name}")
    }
 
    lifecycleScope.launch(Dispatchers.Main) {
        println("2-1 = ${Thread.currentThread().name}")
        specialTask()
        println("2-2 = ${Thread.currentThread().name}")
    }
}
 
    override fun onDestroy() {
        super.onDestroy()
        specialDispatcher.close()
    }
 
    /** 1秒待つだけ。新しく作ったスレッドで */
    private suspend fun specialTask() = withContext(specialDispatcher) {
        delay(1_000)
    }
 
}

出力がこうです。
ちゃんとspecialTask()を呼び出し終わった後はメインスレッドで処理されていますね。

1-1 = main
2-1 = main
1-2 = main
2-2 = main

つぎにDefaultに書き換えてみます。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
 
    lifecycleScope.launch(Dispatchers.Default) {
        println("1-1 = ${Thread.currentThread().name}")
        specialTask()
        println("1-2 = ${Thread.currentThread().name}")
    }
 
    lifecycleScope.launch(Dispatchers.Default) {
        println("2-1 = ${Thread.currentThread().name}")
        specialTask()
        println("2-2 = ${Thread.currentThread().name}")
    }
}

実行するたびに若干異なる場合がありますが、 手元ではこうでした。
specialTask()が終わった後はDispatchers.Defaultのスレッドが使われてます。が、なぜか違うスレッドが使われてますね。これはDefaultMainと違って複数のスレッドでコルーチンを処理しているためです。

はじめの方で話した通り、サスペンド関数を抜けた後、(メインスレッドのようなスレッドが 1 つしかない場合を除いて)違うスレッドが使われる可能性があるといいました。その影響です。
DefaultDispatcherDispatchers.Defaultが持っているスレッドなので(要検証)、スレッドこそ違うものが割り当てられましたが、Dispatchersは元に戻ってきていますね。

1-1 = DefaultDispatcher-worker-2
2-1 = DefaultDispatcher-worker-1
2-2 = DefaultDispatcher-worker-2
1-2 = DefaultDispatcher-worker-3

最後にUnconfinedです。よく見ててください。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
 
    lifecycleScope.launch(Dispatchers.Unconfined) {
        println("1-1 = ${Thread.currentThread().name}")
        specialTask()
        println("1-2 = ${Thread.currentThread().name}")
    }
 
    lifecycleScope.launch(Dispatchers.Unconfined) {
        println("2-1 = ${Thread.currentThread().name}")
        specialTask()
        println("2-2 = ${Thread.currentThread().name}")
    }
}

出力がこうです。
specialTask()を抜けた後もspecialTask()が使っていたスレッド(Dispatchers)を使っています。が、あんまり使う機会はないと思います。
知っていることを自慢できるかもしれないけど、そんなもん自慢したら深堀りされて詰む。

1-1 = main
2-1 = main
1-2 = special thread dispatcher !!!
2-2 = special thread dispatcher !!!

コンテキストを切り替える withContext

withContext { }、これまでも習ってないのにちょくちょくでてきてましたが。ついに触れます。
これを使うと好きなところでスレッド、正しくはDispatchersを切り替えることが出来ます。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        // UI スレッドで処理させる
        lifecycleScope.launch(Dispatchers.Main) {
            changeThread()
        }
    }
 
    private suspend fun changeThread() = coroutineScope {
        withContext(Dispatchers.Default){
            println("Default スレッド = ${Thread.currentThread().name}")
        }
        println("元のスレッド = ${Thread.currentThread().name}")
        
        withContext(Dispatchers.Main){
            println("Main スレッド = ${Thread.currentThread().name}")
        }
        println("元のスレッド = ${Thread.currentThread().name}")
    }
}

出力がこうです。
こんな感じにスレッドを行ったり来たり出来ます。newSingleThreadContext()も渡せます。
ブロック内は指定したスレッドで処理されます。ブロックを抜けると元のスレッドに戻るため、スレッドを切り替えているのにコールバックのようにネストせずに書けるのが感動ポイント。

Default スレッド = DefaultDispatcher-worker-1
元のスレッド = main
Main スレッド = main
元のスレッド = main

delay()のそれと同じように、この手の関数は、戻ってきた際に同じスレッドが使われるとは限らないので注意です。
先述の通りスレッド、ではなくDispatchersが元に戻るだけ、Dispatchers.Defaultは複数のスレッドを雇っているのでその時空いているスレッドが割り当てられます。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch(Dispatchers.Default) {
            changeThread("[1]")
        }
        lifecycleScope.launch(Dispatchers.Default) {
            changeThread("[2]")
        }
    }
 
    private suspend fun changeThread(tag:String) = coroutineScope {
        println("$tag 1 = ${Thread.currentThread().name}")
        withContext(Dispatchers.Main){
            println("$tag 2 = ${Thread.currentThread().name}")
        }
        println("$tag 3 = ${Thread.currentThread().name}")
    }
}

これも実行するたびに変化するかもしれませんが、手元ではこんな感じに、Dispatchersこそ同じものの、スレッドは違うものが割り当てられてそうです。

[1] 1 = DefaultDispatcher-worker-1
[2] 1 = DefaultDispatcher-worker-2
[1] 2 = main
[2] 2 = main
[1] 3 = DefaultDispatcher-worker-3
[2] 3 = DefaultDispatcher-worker-2

また、キャンセル後にサスペンド関数が呼び出せないのと同じように、withContext { }も呼び出せません。
ただし、キャンセルの章で話した通り、NonCancellableをつければ一応は使えます。乱用しないように。

Job()

構造化された並行性の章にて、親コルーチンは子コルーチンを追跡して、全部終わったことを確認した後に親が終わることを話しました。
しかし、コルーチンスコープを使うことでこの親子関係を切ることが出来ることも話しました。

この他にコルーチンコンテキストJob()をオーバーライドすることでもこの関係を切ることが出来ます。
こんな感じに。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            val job = launch { otherJob() }
            println("親を起動")
            // 少し待った後、親をキャンセルする
            delay(3_000)
            println("キャンセル!")
            job.cancelAndJoin()
        }
    }
 
    private suspend fun otherJob() = coroutineScope {
        launch {
            println("Jobなし 開始")
            delay(5_000)
            println("Jobなし 5 秒まった")
        }
        launch(Job()) {
            println("Job指定 開始")
            delay(5_000)
            println("Job指定 5 秒まった")
        }
    }
}

親子関係が切れてしまったので、親がキャンセルされても生き残っていますね。使い道があるのかは知らない。

親を起動
Jobなし 開始
Job指定 開始
キャンセル!
Job指定 5 秒まった

デバッグ用に命名

本家の説明で十二分だとおもう
https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html#naming-coroutines-for-debugging

同時に Dispatchers NonCancellable Job を指定したい

コルーチンコンテキストは+ 演算子で繋げることが出来ます。

// UI スレッドで処理させる
lifecycleScope.launch {
    try {
        // キャンセルするかもしれない処理
    } finally {
        withContext(Dispatchers.Main + NonCancellable) {
            // UI スレッドかつ、キャンセル時も実行したい場合
        }
    }
}

コルーチンスコープ

もし、自分でコルーチンスコープを作って管理する場合は、ちゃんと使わなくなった際にコルーチンスコープを破棄するようにする必要があります。
例えばAndroidのサービスでコルーチンが使いたい場合、(ActivityFragmentJetpack Composeとは違い)Androidでは用意されていないため自分で用意する必要があります。
作る分にはCoroutineScope()MainScope()を呼べば作れますが、ちゃんと破棄のタイミングでキャンセルしてあげる必要があります。

という話がMainScope()のドキュメントに書いてあるので読んで。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-main-scope.html

class AndroidService : Service() {
 
    // Android の Service だと Kotlin Coroutines のためのコルーチンスコープがない。ので自分で用意する
    private val scope = MainScope() // もしくは CoroutineScope()
 
    override fun onBind(intent: Intent?): IBinder? {
        return null
    }
 
    // Service が使われなくなったとき
    override fun onDestroy() {
        super.onDestroy()
        // コルーチンスコープも破棄する
        scope.cancel()
    }
}

スレッドローカルデータ

すいませんこれは使ったこと無いので分かりません。;;
https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html#thread-local-data

付録 Android の Handler と Dispatchers

AndroidではHandlerを引数に渡す関数が結構あります。
この手の関数は引数にコールバックと、そのコールバックを呼び出すスレッドを指定するためのHandlerを取ります。
ちなみにnullのときはメインスレッド、コードだとHandler(Looper.getMainLooper())Android側で渡しているそうです。

まあこの手の関数はHandlernullに出来て、しかも大抵はnullで問題なかったりするのですが。(小声)

例えばCamera2 APIの写真撮影メソッド。nullでメインスレッド?

capture(frontCameraCaptureRequest, object : CameraCaptureSession.CaptureCallback {
    // ...以下省略
}, Handler(handlerThread.looper))

例えばMediaCodecsetCallback()nullでメインスレッド

setCallback(object : MediaCodec.Callback() {
    // ...以下省略
}, Handler(handlerThread.looper))

例えばMediaProjectionregisterCallback()nullでメインスレッド

registerCallback(object : MediaProjection.Callback() {
    // ...以下省略
}, Handler(handlerThread.looper))

ただ、コールバックを別スレッドで呼び出されるようHandler()HandlerThread()を作ることもあるでしょう。MediaCodecの非同期モードは多分そう。
概要としては、HandlerThread()と呼ばれるスレッドを作り、Handler#getLooper()Handler()へ渡して、出来たHandlerを引数に渡せば完成なのですが、もう一歩、このHandler()Kotlin CoroutinesDispatchersとしても使うことが出来ます。

(ということを最近知りました、ありがとうございます)
https://qiita.com/sdkei/items/b066817fb7f7c34d5760

こんな感じですね。
HandlerThread()Kotlin Coroutinesでも転用したい場合はどうぞ!

class MainActivity : ComponentActivity() {
 
    private val handlerThread = HandlerThread("handler_thread").apply { start() }
    private val handler = Handler(handlerThread.looper)
    private val handlerDispatcher = handler.asCoroutineDispatcher()
 
}

付録 Dispatchers.Default はなぜ最低 2 個で、Dispatchers.IO は最低 64 個もあるの

これの答えをKotlin Coroutinesの中で探したんですが、それっぽい記述を見つけることは出来ず。

というわけで手詰まりになりました。ここから先の話は憶測です。ドキュメントには書いていない話なので。

Kotlin Coroutines関係なく、CPUを駆使する処理(CPU バウンドなタスク)だと、起動するスレッド数は搭載されているCPUのコア数と同じか半分が良いという風の噂があるらしい。

あんまり理解できてないけど、なんとなくこういうこと?かなり理論上な気はする。

CPUを駆使する処理はCPUにしか依存していないので(遅くなる原因のファイル読み書き等の邪魔するものがない場合)1スレッドだけで使用率を100%に出来る?。
1スレッドだけで100%になるからコア数以上に増やしてもそれ以上、100%を超えられないので意味がないってことらしい?

一方ファイル読み書きやインターネット通信(IO バウンド)はCPUがどれだけ早くても読み書き、通信速度がボトルネックで100%にできない?
から並列化させてCPUを遊ばせない(暇させない)ほうがいいってことなのかな

Flow と Channel

https://kotlinlang.org/docs/flow.html

↓書きました↓

https://takusan.negitoro.dev/posts/amairo_kotlin_coroutines_flow/

省略します!!!

やる気があればFlowの話をします。
というのもFlowはサスペンド関数のそれ同じくらい内容があってそれだけで記事一本出来てしまうので。。。

いやだって目次が多すぎる。。。

例外

例外の話です。地味に新しい事実がある

違う親の子コルーチンの例外

違う親の子コルーチンというか、自分が親のコルーチンのときにも言える話ですね。lifecycleScope.launch { }のこと。
まあ落ちます。自分が親のコルーチンにも言えることでしょうが。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            try {
                // this の Scope を使わずに起動
                val parent = lifecycleScope.launch {
                    delay(3_000)
                    throw RuntimeException()
                }
                parent.join()
            } catch (e: RuntimeException) {
                println("RuntimeException をキャッチ") // 出来ません
            }
        }
    }
}
Process: io.github.takusan23.kotlincoroutienspractice, PID: 24548
java.lang.RuntimeException
	at io.github.takusan23.kotlincoroutienspractice.MainActivity$onCreate$1$parent$1.invokeSuspend(MainActivity.kt:26)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:235)

ちなみにasync { }は受け取れますが、そもそも違う親で起動しない気がして、だから何みたいな。。。。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            // this の Scope を使わずに起動
            val parent = lifecycleScope.async {
                delay(3_000)
                throw RuntimeException()
            }
            try {
                parent.await()
            } catch (e: RuntimeException) {
                println("RuntimeException をキャッチ") // できる。けどあんまりないハズ
            }
        }
    }
}

キャッチされなかった例外を観測

キャッチされなかった例外を観測することが出来ます。キャッチされなかったというわけで、もうアプリは回復できません(落ちてしまう)
スタックトレース収集とかに使えるかも?

試したことがあるかもしれませんが、launch { }try-catchで囲っても意味がありません。普通に落ちます。
軽量スレッドとはいえスレッドなんですから。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        try {
            lifecycleScope.launch {
                delay(3_000)
                throw RuntimeException()
            }
        } catch (e: Exception) {
            println("launch { } で例外をキャッチ") // 動かない
        }
    }
}

じゃあどうすればいいのかというと、CoroutineExceptionHandler { }を使うと出来ます。
先述の通りキャッチされなかった例外が来るので、回復目的には使えません。出来るとしたらスタックトレースと回収とかアプリ全体を再起動とかでしょうか。
ここでキャッチ出来るようになるので、アプリが落ちることはなくなります。

例えば以下のようにスタックトレースの回収が出来ます。

class MainActivity : ComponentActivity() {
 
    private val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        // https://stackoverflow.com/questions/9226794/
        val stringWriter = StringWriter()
        val printWriter = PrintWriter(stringWriter)
        throwable.printStackTrace(printWriter)
        stringWriter.flush()
 
        println("!!! CoroutineExceptionHandler !!!")
        println(stringWriter.toString())
    }
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch(coroutineExceptionHandler) {
            delay(3_000)
            throw RuntimeException()
        }
    }
}

logcatはこんな感じです。

System.out              io....an23.kotlincoroutienspractice  I  !!! CoroutineExceptionHandler !!!
System.out              io....an23.kotlincoroutienspractice  I  java.lang.RuntimeException
System.out              io....an23.kotlincoroutienspractice  I  	at io.github.takusan23.kotlincoroutienspractice.MainActivity$onCreate$1.invokeSuspend(MainActivity.kt:39)
System.out              io....an23.kotlincoroutienspractice  I  	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
System.out              io....an23.kotlincoroutienspractice  I  	at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:235)

ちなみに親以外に付けても動きませんので!

lifecycleScope.launch {
    launch(coroutineExceptionHandler) { // 無意味!
        delay(3_000)
        throw RuntimeException()
    }
}

キャンセルと大元の例外

子コルーチンの中で例外が投げられた場合、親を通じて他の子もキャンセルされるわけですが、子に来るのはキャンセル例外です。
キャンセルの原因となった子コルーチンの例外は親のコルーチンで受け取れます。

子コルーチンがRuntimeExceptionでコケたら他の子コルーチンにはRuntimeExceptionが来るわけではなく、キャンセルの例外が来るよという話です。親でRuntimeExceptionを受け取れます。

複数の例外の報告

はドキュメントにゆずります。得にはないかな、、、
https://kotlinlang.org/docs/exception-handling.html#exceptions-aggregation

スーパーパイザー

ドキュメントにあるサンプルコードパクってもAndroidだと動かないな、、、

ここまで出てきたコードでは子のコルーチンが例外投げられたら、親に伝搬し、他の子に対してはキャンセルを投げるというものでした。
しかし、他の子コルーチンは生かしておきたい場合があります。その場合、子コルーチン内の処理をtry-catchして例外を投げないようにする、でも良いですが、もう一つの方法があります。SupervisorJob()supervisorScope { }です。

試してみましょう。
指定時間後に成功するタスクsuccessTask()と、指定時間後に例外を投げて失敗するfailTask()をそれぞれ並列で呼び出します。

async { }await()で値もしくは例外を受け取ることが出来ます。って書いてあります。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-supervisor-job.html

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            supervisorTask()
            println("supervisorScope { } complete !!!")
        }
    }
 
    private suspend fun supervisorTask() = supervisorScope {
        // 失敗する子を含んで並列起動
        val async1 = async { successTask() }
        val async2 = async { successTask() }
        val async3 = async { failTask() }
 
        listOf(async1, async2, async3).forEachIndexed { index, deferred ->
            try {
                // await() で例外をキャッチできる
                println("[$index] await()")
                println(deferred.await())
            } catch (e: RuntimeException) {
                println("[$index] RuntimeException キャッチ")
            }
        }
    }
 
    private suspend fun successTask(): String {
        delay(3_000)
        return "hello world"
    }
 
    private suspend fun failTask() {
        delay(2_000)
        throw RuntimeException()
    }
}

結果はこんな感じで、supervisorScope { }は子と子が持っている子にのみキャンセルが伝搬します。親には伝搬しません。
ので、親に対して明示的にキャンセルしない場合は、子コルーチンはそのまま生き続けます。

もちろん子がすべて終わるまで親が終わらないルールはsupervisorScope { }でも引き継がれています。なので最後にcomplete !!!が出る形にあります。

[0] await()
hello world
[1] await()
hello world
[2] await()
[2] RuntimeException キャッチ
supervisorScope { } complete !!!

さっきはasync { }の例を出しましたが、launch { }でも動きます。async { }だとawait()でキャッチできますが、
launch { }の場合はというと、、、、代わりにコルーチンスコープに設定されたCoroutineExceptionHandlerで例外をキャッチできます。コルーチンスコープにそれがない場合は落ちます。

class MainActivity : ComponentActivity() {
 
    private val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        throwable.printStackTrace(System.out) // System.out に出す
    }
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch(coroutineExceptionHandler) { // CoroutineExceptionHandler をつける
            supervisorTask()
            println("supervisorScope { } complete !!!")
        }
    }
 
    private suspend fun supervisorTask() = supervisorScope {
        launch { println(successTask()) }
        launch { failTask() }
    }
 
    private suspend fun successTask(): String {
        delay(3_000)
        return "hello world"
    }
 
    private suspend fun failTask() {
        delay(2_000)
        throw RuntimeException()
    }
}

これでもsuccessTask()がちゃんと生き残っています。

java.lang.RuntimeException
	at io.github.takusan23.kotlincoroutienspractice.MainActivity.failTask(MainActivity.kt:47)
	at io.github.takusan23.kotlincoroutienspractice.MainActivity.access$failTask(MainActivity.kt:19)
	at io.github.takusan23.kotlincoroutienspractice.MainActivity$failTask$1.invokeSuspend(Unknown Source:14)
    ... 以下省略
hello world
supervisorScope { } complete !!!

並行処理と可変変数

見出しは適当につけました。
最後の章。かな。

https://kotlinlang.org/docs/shared-mutable-state-and-concurrency.html

マルチスレッド起因の問題はコルーチンでも起きる

マルチスレッドで変数を同時に書き換えると正しい値にならないというのは有名だと思います。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        var count = 0
        lifecycleScope.launch(Dispatchers.Default) {
            coroutineScope {
                repeat(10_000){
                    launch {
                        count++
                    }
                }
            }
            println("結果 = $count")
        }
    }
}

logcatはこうです。実行するタイミングによっては10000が表示されるかもしれませんが、それはたまたま動いただけです。
これはマルチスレッドで同時に変数にアクセスしているから(まあ目には見えない速さなんですが)、タイミング悪く増える前の変数に+1したとかでおかしくなってるんでしょう。

結果 = 9995

Dispatchersの章で話した通り、Dispatchers.MainやシングルスレッドのDispatchersを使えば、他スレッドから参照されたり変更されたりしないので、ちゃんと期待通りになります。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        // Main とか
        lifecycleScope.launch(Dispatchers.Main) {
            increment()
        }
 
        // newSingleThreadContext() とか
        lifecycleScope.launch {
            newSingleThreadContext("single_thread_dispatchers").use { dispatcher ->
                withContext(dispatcher) {
                    increment()
                }
            }
        }
    }
 
    private suspend fun increment(){
        var count = 0
        coroutineScope {
            repeat(10_000) { // 10_000 回ループする
                launch {
                    count++ // +1 する
                }
            }
        }
        println("結果 = $count")
    }
}

また、変数に@Volatileを付けても期待通りにならないそうです(これがなんなのかいまいちわからないのでスルーします)

ループと withContext

ちなみにrepeat { }の中でwithContext { }よりもwithContext { }の中でrepeat { }のほうが良いです。
いくら軽いとはいえ、繰り返し文のたびにwithContext { }を呼び出すのはコストがかかるそうです。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            var count = 0
            val time1 = measureTimeMillis {
                repeat(10_000) {
                    withContext(Dispatchers.Default) {
                        count++ // 特に意味のないことをしてます。withContext の呼び出しが地味に重いので、time2 のようにループ開始前に呼び出しておくこと
                    }
                }
            }
            val time2 = measureTimeMillis {
                withContext(Dispatchers.Default) {
                    repeat(10_000) {
                        count++
                    }
                }
            }
            println("ループの中で withContext = $time1")
            println("withContext の中でループ = $time2")
        }
    }
}
ループの中で withContext = 716
withContext の中でループ = 0

スレッドセーフ

いくつかあります。
まずはAtomicInteger、これはIntですが他にもBooleanとかもあるはず。何回実行しても10000になります。

val count = AtomicInteger()
lifecycleScope.launch(Dispatchers.Default) {
    coroutineScope {
        repeat(10_000){ // 10_000 回ループする
            launch {
                count.incrementAndGet() // +1 する
            }
        }
    }
    println("結果 = ${count.get()}")
}

あとはsynchronizedのコルーチン版Mutex()を使うことでも同時アクセスを防ぐためにロックできます。変数の操作はもちろん、処理自体を同時実行されないようにしたい場合にこちら。ブロック内はスレッドセーフになります。
なおsynchronizedKotlin Coroutinesでは使えません。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        val mutex = Mutex()
 
        lifecycleScope.launch(Dispatchers.Default) {
            var count = 0
            coroutineScope {
                repeat(10_000) { // 10_000 回ループする
                    launch {
                        mutex.withLock { // 書き換わらないようロック
                            count++ // +1 する
                        }
                    }
                }
            }
            println("結果 = $count")
        }
    }
}
結果 = 10000

以上!

FlowChannel以外は)一通り読み終えました。
長かった。。。。つかれた。

ファンディスク

ここからはドキュメントに書いてないけど、実戦投入する際によく使うやつを書いていきます。

コールバックの関数をサスペンド関数に変換する

ちなみに、これが使えるのは一度だけ値を返す場合のみです。複数回コールバック関数が呼ばれる場合はFlowの勉強が必要です。。。

コールバックの関数をサスペンド関数に変換できます。Kotlin Coroutines使ってやりたいことの1位か2位くらいに居座っていそう、コールバックの置き換え。達成感あるんだよなこれ。
suspendCoroutine { }suspendCancellableCoroutine { }の2つがあります。差はキャンセル不可能か可能かです。後者のキャンセル対応版を使うのが良いです。

suspendCoroutine { }の場合はこんな感じです。
suspendCoroutineContinuationがもらえるので、コールバックの中でresumeとかを呼び出せば良い。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            request()
        }
    }
 
    private suspend fun request() {
        val response = awaitOkHttpAsyncCallback()
        println(response)
    }
 
    private suspend fun awaitOkHttpAsyncCallback() = suspendCoroutine { continuation ->
        val request = Request.Builder().apply {
            url("https://example.com/")
            get()
        }.build()
        OkHttpClient().newCall(request).enqueue(object : Callback { // コールバックの関数
            override fun onFailure(call: Call, e: IOException) {
                continuation.resumeWithException(e) // 失敗時
            }
 
            override fun onResponse(call: Call, response: Response) {
                continuation.resume(response.body!!.string()) // 成功時
            }
        })
    }
}

キャンセル出来ないので、キャンセルを要求したとしても、suspendCoroutine自身はキャンセル例外を投げません。
後続でキャンセルチェックとかを入れるなどする必要があります。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
 
    lifecycleScope.launch {
        val job = launch { request() }
        // 500ms 後にキャンセル
        delay(500)
        job.cancel()
    }
}
 
private suspend fun request() = coroutineScope { // ensureActive() のため
    try {
        val response = awaitOkHttpAsyncCallback() // キャンセル不可なので、非同期処理中にキャンセルが要求されても自身は例外を投げない
        ensureActive() // キャンセルチェック。これをコメントアウトすると println() に進んでしまう...
        println(response)
    } catch (e: CancellationException) {
        println("CancellationException !!!")
        throw e
    }
}

ensureActive()がキャンセル判定をし、今回だとキャンセルしているので例外を投げます。logcatにはCancellationException !!!が出ます。
ensureActive()をコメントアウトすると、そのままprintln()へ進んでしまいます。これはおそらく意図していない操作だと思います。

suspendCancellableCoroutineだとこんな感じです。
こちらはキャンセル機能を持ちます。ContinuationCancellableContinuationに変化します。キャンセルが要求されたときに呼び出されるコールバックを提供します。
極力こちらのsuspendCancellableCoroutine { }でキャンセルに協力的なサスペンド関数を作っていくのがよろしいかと思います。

使い方はキャンセルに対応したコールバックが追加されたくらいで、ほぼ同じです。

class MainActivity : ComponentActivity() {
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        lifecycleScope.launch {
            request()
        }
    }
 
    private suspend fun request() {
        val response = awaitCancellableOkHttpAsyncCallback()
        println(response)
    }
 
    private suspend fun awaitCancellableOkHttpAsyncCallback() = suspendCancellableCoroutine { continuation ->
        val request = Request.Builder().apply {
            url("https://example.com/")
            get()
        }.build()
        val call = OkHttpClient().newCall(request)
        call.enqueue(object : Callback { // コールバックの関数
            override fun onFailure(call: Call, e: IOException) {
                continuation.resumeWithException(e) // 失敗時
            }
 
            override fun onResponse(call: Call, response: Response) {
                continuation.resume(response.body!!.string()) // 成功時
            }
        })
        // コルーチンがキャンセルされたらリクエストをキャンセルさせます
        continuation.invokeOnCancellation {
            call.cancel()
        }
    }
}

先述の通り、キャンセルに対応しているので、キャンセル要求がされた場合はsuspendCancellableCoroutine { }自身が例外を投げます。
そのためキャンセルチェックは不要です。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
 
    lifecycleScope.launch {
        val job = launch { request() }
        // 500ms 後にキャンセル
        delay(500)
        job.cancel()
    }
}
 
private suspend fun request() {
    try {
        val response = awaitCancellableOkHttpAsyncCallback() // キャンセル対応なので、キャンセルが要求されたら自身が例外を投げる
        println(response)
    } catch (e: CancellationException) {
        println("CancellationException !!!")
        throw e
    }
}

ちゃんとlogcatにはensureActive()無しでCancellationException !!!が出ています。

並列と並行(パラレルとコンカレント)

https://stackoverflow.com/questions/1050222/

同時に処理を実行する際に出てくるキーワードに、 並列(parallel / パラレル)並行(concurrent / コンカレント) という単語が出てきます。
機械翻訳にぶち込むと同じ単語が出てきて困惑するのですが、 少なくとも Kotlin Coroutinesでは別に扱ってそうです。

平行(コンカレント)

これは同時に起動は出来るというだけ。同時に処理はできていない。
CPU1コアしかなければ1つのことしか出来ない。。。。ですが、実はコンテキストスイッチと呼ばれるものがあって、
同時に起動している処理を細かく区切って、それぞれ均等にCPUに与えて処理させているので、同時に処理が出来ているように見えている。

1コアしか無いので、同じ時間には1つしか処理できないことになります。

ワンオペで店を回しているようなものでしょうか。

並列(パラレル)

これは同時に処理が出来ます。マルチコア CPUとかIntel のハイパースレッディングのそれを使うやつです。
同時、同じ時間に複数の処理が出来る違いがあります。もちろんコア数を超えるスレッドがあれば同じくコンテキストスイッチが頑張ります。

複数人雇っていることになりますね。これなら同じ時間に違う仕事をさせることが出来ます。

だから何?

並列の実行数を制限したいときに、どっちを制限したいのかによって使い分ける必要があります。

平行(同時に起動する)並列(同時に処理する。使うスレッド数を制限する)
1つだけMutex()limitedParallelism(1)
上限付きSemaphore(上限の数)limitedParallelism(上限の数)

Mutex

例えば以下の関数はwithLock { }の中からのみ呼び出されているため、successTask()を3つ並列にしても、1つずつ処理されることになります。
なので、同時にHello worldが出力されることはありません。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        val mutex = Mutex()
        lifecycleScope.launch {
            mutex.withLock { successTask() }
        }
        lifecycleScope.launch {
            mutex.withLock { successTask() }
        }
        lifecycleScope.launch {
            mutex.withLock { successTask() }
        }
    }
 
    private suspend fun successTask() {
        delay(3_000)
        println("Hello world")
    }
}

logcat

03:53:59.277 10572-10572 System.out              io....an23.kotlincoroutienspractice  I  Hello world
03:54:02.280 10572-10572 System.out              io....an23.kotlincoroutienspractice  I  Hello world
03:54:05.281 10572-10572 System.out              io....an23.kotlincoroutienspractice  I  Hello world

Semaphore

Mutex()は1つずつですが、2個までは許容して3個目以降は待ち状態にしたい場合があると思います。
例えばMinecraftのマインカートは一人乗りなのでMutex()で事足りますが、ボートは二人まで乗れますのでMutex()は使えないですね。

それがSemaphore()です。見ていきましょう。
引数に同時に利用できる上限数を入れます。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        val semaphore = Semaphore(2)
        lifecycleScope.launch {
            semaphore.withPermit { successTask() }
        }
        lifecycleScope.launch {
            semaphore.withPermit { successTask() }
        }
        lifecycleScope.launch {
            semaphore.withPermit { successTask() }
        }
        lifecycleScope.launch {
            semaphore.withPermit { successTask() }
        }
    }
 
    private suspend fun successTask() {
        delay(3_000)
        println("Hello world")
    }
}

実行結果ですが、2つずつ処理されるので、logcatの出力も2個ずつ出てくるんじゃないかなと思います。

04:02:47.409 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world
04:02:47.411 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world
04:02:50.412 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world
04:02:50.413 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world
04:02:53.416 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world
04:02:53.417 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world
04:02:56.421 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world
04:02:56.422 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world
04:02:59.425 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world
04:02:59.426 12167-12167 System.out              io....an23.kotlincoroutienspractice  I  Hello world

limitedParallelism

これは同時に利用するスレッド数を制限するものです。
Dispatchers.IODispatchers.Defaultは複数のスレッドを持っている(雇っている)ので、それらの制限をするのに使うそう。

ややこしいのが、これはスレッド数を制限するものであって、launch { }async { }の同時起動数を制限するものではないということです。
Kotlin Coroutinesはスレッドを有効活用するため、delay()等の待ち時間が出れば他のコルーチンの処理に当てるのでコルーチンの起動数制限には使えません。
もしlaunch { }async { }の同時起動数を制限したい場合はさっきのSemaphore()が解決策です。

例えばlimitedParallelism(1)にした場合はスレッドが1つだけになるので、このようにループでインクリメントさせても正しい値になります。
ちなみにシングルスレッドが約束されるだけであって同じスレッドが使われるとは言っていません。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
 
        val singleDefault = Dispatchers.Default.limitedParallelism(1)
        var count = 0
        lifecycleScope.launch {
            withContext(singleDefault){
                repeat(10_000){
                    launch {
                        count++
                    }
                }
            }
            println("シングルスレッドなので $count")
        }
    }
}

うーん、どこで使うのかな。多分賢い使い方があるんだと思うんだけど。

forEach / map でサスペンド関数呼べます

JavaScriptから来た方向け。KotlinforEachmap等でサスペンド関数を呼び出せるよという話です。JavaScriptではforEach内でawait出来ないのは有名な話ですが、Kotlinでは出来るよという話、それだけです。

Kotlinにはインライン関数と呼ばれる、関数呼び出しではなく、関数の中身のコードを呼び出し元に展開する機能があります。(他の言語にあるマクロみたいなやつ)
forEachmapはインライン関数なので、これらは純粋な繰り返し文に置き換わります。なのでサスペンド関数を呼び出すことが出来る感じです。

例えば以下のKotlinコードは

val list = listOf("Pixel 9", "Pixel 9 Pro", "Pixel 9 Pro XL", "Pixel 9 Pro Fold")
list.forEach { text ->
    println(text)
}

Javaではこのようになるそうです。
ちゃんと純粋なループに置き換わっていますね。

String[] var3 = new String[]{"Pixel 9", "Pixel 9 Pro", "Pixel 9 Pro XL", "Pixel 9 Pro Fold"};
List list = CollectionsKt.listOf(var3);
Iterable $this$forEach$iv = (Iterable)list;
int $i$f$forEach = false;
Iterator var5 = $this$forEach$iv.iterator();
 
while(var5.hasNext()) {
    Object element$iv = var5.next();
    String text = (String)element$iv;
    int var8 = false;
    System.out.println(text);
}

おわりに

いやーーーーーーKotlin Coroutines、頭が良い!よく考えられてるなと思いました。