【ITニュース解説】AsyncStream vs Combine Publishers: The Hidden miss That Can Hang Your iOS App
2025年10月03日に「Dev.to」が公開したITニュース「AsyncStream vs Combine Publishers: The Hidden miss That Can Hang Your iOS App」について初心者にもわかりやすく解説しています。
ITニュース概要
iOS開発でCombineからAsyncStreamへ移行する際、AsyncStreamは値を出し終えても自動で完了しないため注意が必要だ。明示的に終了を指示しないとアプリがハングアップする可能性がある。常に`continuation.finish()`でストリームを閉じる必要がある。
ITニュース解説
iOSアプリ開発では、ネットワーク通信やセンサーからのデータ取得といった、時間と共に発生する非同期イベントを効率的に処理する必要がある。アプリがスムーズに動作し、ユーザーインターフェースが固まらないようにするためには、これらの非同期処理を適切に管理することが極めて重要だ。AppleはSwift言語の進化と共に、非同期処理を行うためのツールを提供しており、その代表的なものが「Combine Publishers」と、より新しい「AsyncStream」である。これらはどちらも時間と共に生成される一連の値を扱うための強力なツールだが、その使い方には決定的な違いが存在し、特にAsyncStreamにおいては注意しないとアプリが予期せず停止してしまう(ハングアップする)可能性がある。
多くのiOS開発者はCombineからAsyncStreamへと移行する際、これら二つの機能が同様に動作すると誤解しがちだ。しかし、両者には一つ非常に重要な違いが存在する。それは「ストリームの完了(終了)」に関する挙動だ。Combine Publishersは通常、すべての値を放出し終えると自動的に完了シグナルを送り、その後の処理を円滑に進める。一方でAsyncStreamは、開発者が明示的に終了を指示しない限り、値を永遠に待ち続ける特性がある。この違いを見落とすと、AsyncStreamを使っているタスクがいつまでも完了せず、結果としてアプリ全体が停止してしまう事態を引き起こす。
Combine Publishersは、時間の経過とともに一連の値を生成し、それを購読者(Subscriber)に届ける仕組みだ。例えば、要素が固定された配列からPublisherを作成した場合、そのPublisherは配列のすべての要素を順番に放出した後、「もうこれ以上値は出ません」という完了の通知を自動的に行う。この通知を受け取った購読者は、ストリームが正常に終了したことを認識し、関連するタスクも完了する。これにより、メモリの解放や次の処理への移行が自動的に行われるため、開発者はストリームの終了を細かく管理する必要が少ない。これは、データが有限で、いつ終了するかが明確なシナリオで非常に便利だ。
一方、AsyncStreamは、Swiftの新しい非同期処理機能であるasync/awaitと組み合わせて使う、時間と共に値を生成するもう一つの方法だ。AsyncStreamの内部では、「continuation」という特別なオブジェクトを使って値を「yield」(生成)する。しかし、Combine Publishersとは異なり、AsyncStreamは値をすべて放出した後も、自動的には完了しない。たとえ生成される値がなくなったとしても、AsyncStreamは「まだ値が来るかもしれない」とみなし、永遠に次の値を待ち続ける。このため、for await ループでAsyncStreamの値を処理しているタスクは、いつまでもループを抜けられず、結果としてタスクが完了せず、アプリがハングアップしてしまうのだ。
AsyncStreamがこのような設計になっているのには理由がある。AsyncStreamは元々、WebSocket接続からのメッセージ、GPSからの位置情報、センサーからのデータ、サーバーからのリアルタイムイベントなど、いつデータが途切れるか、いつ終了するかが予測できない、あるいは終了しない可能性のあるデータストリームを扱うために作られている。これらのケースでは、データが一時的に途切れても、後で再び値が送られてくることが期待されるため、自動的に完了してしまうと困る。つまり、AsyncStreamは本質的に「無限」または「不定」のデータ源を想定しているのだ。
AsyncStreamでアプリがハングアップする問題を避けるための解決策は単純明快だ。AsyncStreamの利用が終わった際には、必ず明示的に終了を指示する必要がある。具体的には、AsyncStreamを生成するブロック内で、すべての値をyieldし終えた後で、continuation.finish()を呼び出す。このfinish()メソッドを呼び出すことで、AsyncStreamは「これ以上値は生成されません、ストリームは完了です」と明確にシグナルを送る。これにより、for await ループはストリームの完了を検知し、ループを正常に抜け出し、タスクも完了する。
この問題は、特にタイマーのような周期的なイベントをAsyncStreamで扱う際に顕著に現れる。例えば、毎秒現在時刻をyieldするストリームをAsyncStreamで実装する場合、continuation.finish()を呼ばずにタイマーを動かしっぱなしにすると、タイマーは永遠に動作し続け、ストリームも永遠に完了しない。これはメモリリークを引き起こすだけでなく、関連するタスクを永久に待機させてしまう。このような問題を適切に防ぐためには、タイマーが特定の回数だけ実行されたら、タイマーを停止させ(timer.invalidate())、その後でcontinuation.finish()を呼び出してAsyncStreamを終了させるようにする。
さらに、AsyncStreamが何らかの理由でキャンセルされたり、使用されなくなったりした場合に、内部で動作しているタイマーなどのリソースを確実にクリーンアップすることが重要だ。これを実現するのがcontinuation.onTerminationという仕組みである。onTerminationブロック内でtimer.invalidate()のようなクリーンアップ処理を行うように設定することで、AsyncStreamが終了する際に自動的にタイマーが停止し、不要なリソースの保持やメモリリークを防ぐことができる。
AsyncStreamを安全かつ効率的に利用するためには、いくつかの重要な原則を覚えておく必要がある。まず、AsyncStreamは必ず終了戦略を持つべきだということだ。これは、値をすべて生成し終えたらcontinuation.finish()を明示的に呼び出すか、外部からのキャンセルを適切に処理するかのどちらかだ。次に、continuation.onTerminationを積極的に利用して、タイマー、オブザーバー、ネットワーク接続など、ストリームの内部で確保したすべてのリソースを確実に解放することだ。これにより、メモリリークやアプリの不要な動作を防ぐことができる。そして、Combine PublishersからAsyncStreamへの移行を検討している場合、以前のCombineの「自動完了」の挙動を安易に期待せず、すべてのAsyncStreamの実装を注意深く確認し、終了処理が適切に行われているかを監査する必要がある。
もしあなたのアプリでAsyncStreamを使っている箇所で、タスクがいつまでも完了しない、メモリ使用量が時間と共に増え続ける、タイマーやオブザーバーが期待通りに停止しない、あるいは非同期関数がいつまで経っても結果を返さないといった兆候が見られたら、それはAsyncStreamが正しく終了処理されていないためにハングアップしている可能性が高い。
結論として、AsyncStreamは明示的に終了を指示しない限り、永遠に処理を待機し続ける特性がある。ストリームを開いた開発者は、責任を持ってそれを閉じる必要があるという基本原則を理解し、実践することがiOSアプリの安定性とパフォーマンスを守る上で不可欠である。