はじめに
並行処理を書くとき、次のような問題に悩んだことはありませんか?
-
launchを乱発して、どこで終わるか分からない - 子タスクがキャンセルされずにリークする
-
GlobalScopeのせいでアプリ終了後も動き続ける -
asyncの例外が握りつぶされる
これらの原因はすべて、
「構造化並行性(Structured Concurrency)」を守っていないことにあります。
1. 構造化並行性とは
「すべてのコルーチンは、親スコープの中で始まり、終わり、キャンセルされる。」
Kotlin のコルーチンは、スレッドではなく「スコープ(Scope)と階層構造」で管理されます。
AppScope
└── ViewModelScope
└── CoroutineScope (UseCase)
├── async { loadUser() }
├── async { loadNews() }
└── async { loadRanking() }
親がキャンセルされれば、子も必ずキャンセルされる。
これが構造化並行性の基本ルールです。
2. なぜ構造化が必要なのか
| 問題 | 非構造的コード(Anti Pattern) | 解決策 |
|---|---|---|
| ライフサイクルリーク | GlobalScope.launch { ... } |
viewModelScope / coroutineScope
|
| タスク競合 | 複数 launch の状態が独立 |
coroutineScope で親を明示 |
| 例外が握りつぶされる |
async 未 await
|
同スコープで await 合流 |
| 一部キャンセルされない | スコープ外から Job を保持 |
親スコープに束縛 |
3. スコープの階層と寿命管理
| スコープ | 寿命 | 主な用途 |
|---|---|---|
GlobalScope |
アプリ全体(避ける) | 一時的なデーモン処理のみ |
viewModelScope |
ViewModelの寿命 | UI操作・イベント処理 |
lifecycleScope |
Activity/Fragmentの寿命 | UIアニメーションなど |
coroutineScope / supervisorScope
|
関数単位 | UseCaseや一連の操作単位 |
4. coroutineScope vs supervisorScope
4.1 coroutineScope(デフォルトの構造)
- 子の1つが失敗すると → 他の子もキャンセルされる
- トランザクション単位で使う
suspend fun loadAll(): Dashboard = coroutineScope {
val user = async { userRepo.load() } // 失敗で全体キャンセル
val news = async { newsRepo.load() }
Dashboard(user.await(), news.await())
}
→ 「一体で成功/失敗したい処理」に最適。
4.2 supervisorScope(失敗を分離)
- 子の失敗が他に伝播しない
- 部分成功を許容したい場合に使用
suspend fun loadPartial(): Dashboard = supervisorScope {
val user = async { runCatching { userRepo.load() }.getOrNull() }
val news = async { runCatching { newsRepo.load() }.getOrNull() }
Dashboard(user.await(), news.await())
}
→ 「取れるデータだけ取って画面を出す」パターンに最適。
5. async/await の合流戦略
原則
-
asyncは 同一スコープ内 で作る -
await()は 同スコープ内で合流する -
Deferredを上位スコープに渡さない(ゾンビ化の原因)
suspend fun loadHome(): Home = coroutineScope {
val a = async { apiA() }
val b = async { apiB() }
Home(a.await(), b.await()) // ここで完結
}
「
asyncは局所的に閉じる」=合流点を明示することが構造化の基本。
6. withTimeout / withTimeoutOrNull
構造化並行性では、キャンセルも階層的に伝播します。
そのため、タイムアウトもスコープ単位で適用するのが安全。
suspend fun loadWithTimeout(): Data = withTimeout(3_000) {
repository.load() // 3秒以内に完了しなければキャンセル
}
UseCase単位で
withTimeoutをかけると、
ユーザー操作ごとに明確なキャンセル境界を作れる。
7. 構造化と並列性を両立するパターン
パターン1:トランザクション単位の並列処理
suspend fun fetchAll(): Result = coroutineScope {
val a = async { repoA.load() }
val b = async { repoB.load() }
combine(a.await(), b.await())
}
パターン2:部分成功許容
suspend fun fetchPartial(): Result = supervisorScope {
val a = async { runCatching { repoA.load() }.getOrNull() }
val b = async { runCatching { repoB.load() }.getOrNull() }
Result(a.await(), b.await())
}
パターン3:競争(race pattern)
suspend fun fastest(): Data = coroutineScope {
select {
async { sourceA() }.onAwait { it }
async { sourceB() }.onAwait { it }
}
}
8. ViewModel × UseCase 構成例
class LoadDashboardUseCase(
private val userRepo: UserRepository,
private val newsRepo: NewsRepository
) {
suspend fun execute(): Dashboard = coroutineScope {
val user = async { userRepo.fetch() }
val news = async { newsRepo.fetch() }
Dashboard(user.await(), news.await())
}
}
class DashboardViewModel(
private val useCase: LoadDashboardUseCase
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state = _state.asStateFlow()
fun load() = viewModelScope.launch {
runCatching { useCase.execute() }
.onSuccess { _state.value = UiState.Success(it) }
.onFailure { _state.value = UiState.Error(it) }
}
}
viewModelScope→ UI寿命管理coroutineScope→ UseCase単位の構造化並行- 例外は
runCatchingでドメイン化
→ 完全に階層が整理された安全な並行構成
9. 例外・キャンセルの伝播ルールまとめ
| 状況 | coroutineScope |
supervisorScope |
|---|---|---|
| 子が例外発生 | 他の子もキャンセル | 他の子は継続 |
| 親がキャンセル | 全ての子がキャンセル | 全ての子がキャンセル |
子で try/catch
|
捕捉すれば親に伝播しない | 捕捉すれば親に伝播しない |
withTimeout |
タイムアウトで全体キャンセル | タイムアウトで全体キャンセル |
10. 実戦Tips(ベストプラクティス)
| 状況 | 推奨戦略 |
|---|---|
| 複数非依存I/O | coroutineScope + async/await |
| 一部失敗を許容 | supervisorScope |
| タイムアウト付き操作 |
withTimeout を UseCase内に |
| 定期タスク |
launch + isActive ループ |
| UI連動 |
viewModelScope に束縛 |
| フロー並列 | flatMapMerge(concurrency = N) |
| 終了処理 |
try/finally or invokeOnCompletion
|
11. よくあるアンチパターン
| アンチパターン | 問題点 | 対応策 |
|---|---|---|
GlobalScope.launch |
ライフサイクル外で動き続ける | スコープ束縛(viewModelScope) |
async を await せず放置 |
子タスクがゾンビ化 | 同スコープで必ず合流 |
NonCancellable の乱用 |
永遠に止まらない処理 | 必要最小限に |
| スコープのネスト乱立 | 複雑・デバッグ困難 | 明確な「境界粒度」で統一 |
coroutineScope を呼ばず launch 群 |
親が完了前に子が終わらない | 必ず coroutineScope で包む |
12. チェックリスト
| 確認項目 |
|---|
| GlobalScope を使っていないか? |
| async の await が同スコープ内か? |
| coroutineScope/supervisorScope の使い分けができているか? |
| タイムアウト・キャンセル境界が定義されているか? |
| 親スコープの寿命に束縛されているか? |
| 例外がドメイン層でハンドリングされているか? |
まとめ
| 概念 | キーアイデア |
|---|---|
| 構造化並行性 | コルーチンを階層構造で管理し、寿命と例外を伝播させる |
| coroutineScope | 子が1つでも失敗 → 全体キャンセル(トランザクション単位) |
| supervisorScope | 子の失敗を分離(部分成功可能) |
| スコープ粒度 | UI=粗粒度、UseCase=中粒度、I/O=細粒度 |
| キャンセル境界 | withTimeout・mapLatest・collectLatest で制御 |
構造化並行性とは、「スレッド管理」ではなく「寿命と責務の管理」。
コルーチンの力を最大化する鍵は、正しいスコープ設計にあります。