はじめに
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 では、viewModelScope や lifecycleScope を使って
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-debugの DebugProbes
→ 現在動いているコルーチンをトレース -
Jobの hashCode をログ出力して、スコープ間の親子関係を可視化 - 監視系メトリクス:
- 開いているFD数
- 未完了Job数
- Thread数 / Channelバッファ長
アンチパターン集と対策レシピ
| 悪い例 | 良い例 |
|---|---|
GlobalScope.launch |
明確なスコープ (viewModelScope, applicationScope) |
async 未 await
|
coroutineScope 内で await()
|
NonCancellable 乱用 |
掃除専用に限定 |
Flow Eagerly 起動 |
WhileSubscribed() で止める |
| Socket/Channel 未クローズ |
use{} or finally{ close() }
|
まとめ
Kotlin の並行処理リークは、APIのせいではなく設計の癖です。
構造化並行性+寿命束縛+I/O管理+Flow停止で、「漏れない並行処理」を日常にできる。