0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Kotlin】構造化並行戦略(Structured Concurrency Strategy)

Posted at

はじめに

並行処理を書くとき、次のような問題に悩んだことはありませんか?

  • 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 で親を明示
例外が握りつぶされる asyncawait 同スコープで 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 の合流戦略

原則

  1. async同一スコープ内 で作る
  2. await()同スコープ内で合流する
  3. 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
asyncawait せず放置 子タスクがゾンビ化 同スコープで必ず合流
NonCancellable の乱用 永遠に止まらない処理 必要最小限に
スコープのネスト乱立 複雑・デバッグ困難 明確な「境界粒度」で統一
coroutineScope を呼ばず launch 親が完了前に子が終わらない 必ず coroutineScope で包む

12. チェックリスト

確認項目
GlobalScope を使っていないか?
async の await が同スコープ内か?
coroutineScope/supervisorScope の使い分けができているか?
タイムアウト・キャンセル境界が定義されているか?
親スコープの寿命に束縛されているか?
例外がドメイン層でハンドリングされているか?

まとめ

概念 キーアイデア
構造化並行性 コルーチンを階層構造で管理し、寿命と例外を伝播させる
coroutineScope 子が1つでも失敗 → 全体キャンセル(トランザクション単位)
supervisorScope 子の失敗を分離(部分成功可能)
スコープ粒度 UI=粗粒度、UseCase=中粒度、I/O=細粒度
キャンセル境界 withTimeout・mapLatest・collectLatest で制御

構造化並行性とは、「スレッド管理」ではなく「寿命と責務の管理」。
コルーチンの力を最大化する鍵は、正しいスコープ設計にあります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?