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】並行処理のリソースリーク防止

Posted at

はじめに

Kotlin の並行処理(Coroutines / Flow)は強力だが、設計を誤ると静かにリークする
スコープの寿命が管理されていなかったり、キャンセルが伝わらなかったり、I/O が閉じ忘れられたり。

結果として:

  • ViewModel が破棄されてもジョブが残る
  • Socket / Channel / DB Cursor が閉じられない
  • Flow が誰も購読していないのに動き続ける

この記事では、
リークの原因 → 構造的な対策 → コピペで使えるテンプレート
の順で、「止血から再発防止まで」 を解説する。


よくあるリークパターン

パターン 症状 原因
未合流の async await() されずに孤立 構造化並行性を無視
CoroutineScope(Job()) を放置 破棄されず生存 寿命束縛なし
GlobalScope.launch 永遠に動き続ける 片付け責任不明
I/Oリソース未クローズ ファイルFDやソケットが残る use{} / finally 不使用
Flow 常時稼働 購読がなくても動く SharingStarted設定不備
キャンセル不達 タスクが止まらない NonCancellableの誤用

原則 1:構造化並行性を守る

Kotlin の並行処理は「構造で安全を担保する」設計。
coroutineScope に包まれた子ジョブは、親が終わると全てキャンセルされる。

suspend fun fetchAll(): Result = coroutineScope {
    val a = async { fetchA() }
    val b = async { fetchB() }
    combine(a.await(), b.await())
} // ← ここを出ると同時に子が終わる or キャンセル

NG

fun fetchAll() {
    GlobalScope.launch { async { fetchA() } } // 誰も await しない
}

原則 2:寿命束縛(Lifecycle Awareness)

Android では、viewModelScopelifecycleScope を使って
UI の寿命にタスクを紐づける

class MyViewModel : ViewModel() {
    fun load() = viewModelScope.launch {
        repository.loadData()
    } // onCleared() で自動 cancel
}

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}

ポイント

  • repeatOnLifecycle は画面が非表示のとき自動キャンセル
  • viewModelScope は ViewModel 廃棄時に cancel

原則 3:I/O は開いたら閉じる

InputStream, Socket, ResponseBody
“開いたら必ず閉じる”
use{} / try-finally が標準装備。

suspend fun readText(file: Path): String = withContext(Dispatchers.IO) {
    Files.newBufferedReader(file).use { it.readText() }
}

あるいは:

val socket = Socket(host, port)
try {
    handle(socket)
} finally {
    socket.close()
}

原則 4:Flow の「止まる設計」

Flow は放っておくと“永遠に流れ続ける”。
購読がない時は止める必要がある。

val uiState = query
    .debounce(300)
    .distinctUntilChanged()
    .mapLatest { q -> searchUseCase(q) } // 新入力で旧処理をキャンセル
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Ui.Loading)

おすすめ設定

  • 状態用:stateIn(scope, WhileSubscribed(5_000), initial)
  • イベント用:shareIn(scope, WhileSubscribed(), replay = 0)
  • 購読解除後は 5 秒で停止(stopTimeoutMillis)

原則 5:NonCancellable は掃除専用

try {
    withContext(NonCancellable) { cleanup() } // ← OK(後片付け)
} catch (e: CancellationException) {
    // 無理に握りつぶさない
}

原則 6:テストでリークを炙り出す

@OptIn(ExperimentalCoroutinesApi::class)
class RepoTest {
    private val dispatcher = StandardTestDispatcher()
    private val scope = TestScope(dispatcher)

    @Test fun noLeaks() = scope.runTest {
        val job = launch { sut.run() }
        advanceUntilIdle()
        job.cancelAndJoin()  // すべて回収
    }
}

runTest はコルーチンの未終了を検出できる。
cancelAndJoin()未回収ジョブゼロを確認。


原則 7:運用での監視・デバッグ

  • kotlinx-coroutines-debugDebugProbes
    → 現在動いているコルーチンをトレース
  • Job の hashCode をログ出力して、スコープ間の親子関係を可視化
  • 監視系メトリクス:
    • 開いているFD数
    • 未完了Job数
    • Thread数 / Channelバッファ長

アンチパターン集と対策レシピ

悪い例 良い例
GlobalScope.launch 明確なスコープ (viewModelScope, applicationScope)
asyncawait coroutineScope 内で await()
NonCancellable 乱用 掃除専用に限定
Flow Eagerly 起動 WhileSubscribed() で止める
Socket/Channel 未クローズ use{} or finally{ close() }

まとめ

Kotlin の並行処理リークは、APIのせいではなく設計の癖です。
構造化並行性+寿命束縛+I/O管理+Flow停止で、「漏れない並行処理」を日常にできる。

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?