はじめに
上記の記事でも書きましたが、今年実施したプロジェクトで Spring Boot(Kotlin)+ gRPC を利用した構成を採用し、アプリケーション全体として可能な限り Non-Blocking な設計を取りました。
チーム内での勉強会の資料も兼ねて、Kotlin で Non-Blocking な処理を書く際に前提となる Coroutine(コルーチン)について整理します。
Coroutine の概要から、Blocking / Non-Blocking の違い、実務で注意すべきポイントまでをまとめます。
Coroutine とは?
Coroutine は Kotlin における軽量な非同期処理の仕組みです。
スレッドを直接操作することなく、
- 非同期処理を
- 同期処理のような記述で
- 構造的に制御できる
という点が大きな特徴です。
Coroutine はスレッドそのものを置き換えるものではなく、
スレッド上で動作する処理の流れを効率よく制御するための抽象化
と考えると理解しやすいです。
Blocking と Non-Blocking の違い
Coroutine を理解する上で、Blocking と Non-Blocking の違いは非常に重要です。
| 観点 | Blocking | Non-Blocking |
|---|---|---|
| スレッドの扱い | 処理中はスレッドを占有 | I/O 待ち時はスレッドを解放 |
| 同時リクエスト数 | スレッド数に依存しやすい(例:200スレッド ⇒ 最大200リクエスト) | スレッド数に依存しにくい(例:200スレッド ⇒ 数千リクエスト) |
| CPU負荷 | コンテキストスイッチが増えがち | 少ないスレッドで回せる |
| メモリ消費 | スレッドスタックが重い | Coroutine は軽量 |
| 実装難易度 | 簡単 | 難しい(設計が重要) |
但し、Coroutine 自体が自動的に Non-Blocking になるわけではなく、
suspend 関数の中身や Dispatcher の使い方によってはスレッドをブロックしてしまうため注意が必要です。
Coroutine を使う利点
処理が複雑になりがちな Non-Blocking 処理を、比較的直感的に記述できます。
コードが読みやすい
val user = fetchUser() // User(id=1, name="Alice")
val items = fetchItems(user.id) // [Item(id=10), Item(id=11)]
非同期処理であっても、処理の流れを上から下へ自然に読むことができます。
軽量でスケーラブル
Coroutine はスレッドよりも非常に軽量であり、多数同時に起動できます。
特に I/O 待ちが多い処理では、スレッドを占有しない点が効果的です。
例外処理・キャンセル制御が一貫している
CoroutineScope を単位として、例外やキャンセルを構造的に管理できます。
これにより、処理の境界が明確になります。
CompletableFuture との比較
| 観点 | CompletableFuture | Coroutine |
|---|---|---|
| 記述 | thenCompose 等で複雑になりがち | 同期処理のように記述可能 |
| 例外処理 | 独自 API が必要 | try-catch で自然 |
| 戻り値 | Future 経由 | 通常の変数 |
| Kotlin との親和性 | 低め | 非常に高い |
CompletableFuture
.supplyAsync(() -> fetchUser())
.thenCompose(user -> fetchItems(user.getId()));
val user = fetchUser()
val items = fetchItems(user.id)
Java では CompletableFuture が有力な選択肢ですが、
Kotlin を利用する場合は Coroutine の方が可読性・保守性ともに高くなるケースが多いと感じます。
suspend 関数について
suspend の意味
suspend fun fetchUser(): User
suspend は、その関数が途中で一時停止・再開される可能性があることを示します。
I/O 待ちなどでスレッドをブロックせずに待機できる点が特徴です。
なお、suspend 関数は CoroutineScope 内、または他の suspend 関数からのみ呼び出せます。
アンチパターン(suspend を付けてもスレッドは止まる)
suspend fun bad(id: Int) {
println("start bad: $id")
Thread.sleep(1000) // Blocking
println("end bad: $id")
}
runBlocking {
repeat(4) {
launch {
bad(it)
}
}
}
出力例(Blocking)
start bad: 0
start bad: 1
(1秒間何も出力されない)
end bad: 0
end bad: 1
start bad: 2
start bad: 3
(さらに1秒)
end bad: 2
end bad: 3
suspend を付けていても、内部で Blocking API を呼び出すと
Coroutine が実行されている スレッドそのものが占有 されます。
Blocking な場合のイメージ
時間 →
Thread-1: ■■■■■■■■■■ (Thread.sleep)
Thread-2: ■■■■■■■■■■ (Thread.sleep)
Thread-3: ○○○○○○○○○ (スレッドはあるが処理能力を活かせない)
Thread-4: ○○○○○○○○○ (スレッドはあるが処理能力を活かせない)
■ : Blocking 処理で占有されている(使えない)
○ : 存在はするが有効活用できていない
Non-Blocking な場合
suspend fun good(id: Int) {
println("start good: $id")
delay(1000) // Non-Blocking
println("end good: $id")
}
runBlocking {
repeat(4) {
launch {
good(it)
}
}
}
出力例(Non-Blocking)
start good: 0
start good: 1
start good: 2
start good: 3
(1秒後)
end good: 0
end good: 1
end good: 2
end good: 3
delay は待機中にスレッドを占有しないため、
同じスレッドを使って他の Coroutine が次々と実行されます。
ベストプラクティス
- 時間がかかる処理や I/O は suspend 関数として定義する
- Blocking API を使う場合は Dispatcher を切り替えて隔離する
- 利用ライブラリ内でスレッドをブロックする処理がないかを意識する
Coroutine の基本的な使い方
起動方法
scope.launch {
doSomething()
}
val result = scope.async {
heavyTask()
}.await()
-
launch:戻り値が不要な処理 -
async:結果を返す処理
CoroutineScope
CoroutineScope(Dispatchers.IO)
Scope がキャンセルされると、その配下の Coroutine もすべてキャンセルされます。
GlobalScope の使用は基本的に推奨されません。
Dispatchers
| Dispatcher | 主な用途 |
|---|---|
| Dispatchers.IO | Blocking な DB / ファイル I/O |
| Dispatchers.Default | CPU負荷の高い処理 |
| Dispatchers.Main | UI スレッド(Android / Desktop) |
withContext(Dispatchers.IO) {
blockingRepository.findAll()
}
Dispatchers.IO は Blocking API を扱うための Dispatcher です。
但し、R2DBC のような Non-Blocking API を利用する場合は Dispatchers.IO への切り替えは基本的に不要で、むしろ非推奨です。
async / await による並列処理
suspend fun taskA(): Int {
delay(500)
println("taskA done")
return 1
}
suspend fun taskB(): Int {
delay(500)
println("taskB done")
return 2
}
val result = coroutineScope {
val a = async { taskA() }
val b = async { taskB() }
a.await() + b.await()
}
println("result = $result")
出力例
taskA done
taskB done
result = 3
タイムアウトとキャンセル
withTimeout(1000) {
longTask()
}
job.cancel()
キャンセルは協調的に行われるため、
suspend 関数側でキャンセルを考慮した実装が重要です。
supervisorScope
supervisorScope {
launch { taskA() }
launch { taskB() }
}
実装例(Spring Boot)
@RestController
class SampleController {
@GetMapping("/hello")
suspend fun hello(): String {
delay(100)
return "Hello Coroutine"
}
}
レスポンス例
GET /hello
HTTP/1.1 200 OK
Hello Coroutine
delay は Non-Blocking で待機します。
Spring WebFlux と組み合わせることで、I/O レベルから Non-Blocking な Controller を実装できます。
なお、Spring MVC でも Coroutine は利用できますが、
I/O モデル自体は Blocking である点には注意が必要です。
終わりに
Coroutine は Kotlin における非同期処理をシンプルに書くための強力な仕組みですが、
それだけで自動的に Non-Blocking になるわけではありません。
suspend 関数の中身や Dispatcher の使い分け、さらにフレームワークの I/O モデルまで含めて設計することで、初めて Non-Blocking 構成のメリットを活かすことができます。
本記事が、Coroutine を使った実装を考える際の整理や、Non-Blocking を意識した設計を見直すきっかけになれば幸いです。