Swiftの並行性モデルは、Swift 5.5で根本的な変化を遂げ、従来のGrand Central Dispatchパターンに代わる構造化された並行性を導入しました。これにより、孤立したタスクやリソースリークが頻繁に発生する問題が解決されました。それ以前は、開発者はDispatchGroupインスタンスを手動で管理し、キャンセル時にレースコンディションを防ぐための明示的な同期が必要でした。TaskGroupの抽象化は、親子関係ツリーをネイティブにカプセル化するように設計されており、ランタイムがライフサイクルメタデータを維持し、開発者はその管理から解放されます。
コアの問題は、親タスクがすべての子孫にキャンセルを確実に信号を送れる決定論的な階層を維持することにあります。このためには、グローバルレジストリや手動の弱参照配列を経由する必要がありません。従来のOperationQueueを使用したアプローチでは、完了ハンドラの明示的な登録と登録解除が必要で、完了ハンドラが早期退出のためにスキップされると、壊れやすい状態管理が生じます。さらに、キャンセルの伝播には複雑なアトミックフラグのポーリングが必要であり、これが応答遅延や過剰なCPUオーバーヘッドを引き起こすことがよくあります。
Swiftは、各タスクのコンテキスト内にタスクレコードを埋め込み、その親を指し示すことで、この問題に対処しています。このことで、TaskGroupを根とする侵入型のリンクリストが形成されます。addTaskが呼び出されると、ランタイムはこのリストに子タスクレコードを挿入し、親のキャンセルハンドラーにアトミックに登録します。キャンセルメカニズムは状態遷移機械を利用します:cancelAll()が呼び出されると、ランタイムはこのリストを走査し、各子タスクのメタデータでisCancelledフラグを設定し、停止中の実行者を起こします。これにより、O(n)の伝播が保証され、nはツリーの深さを表し、グローバルロックを回避します。
import Foundation func downloadImages(urls: [URL]) async throws -> [Data] { try await withThrowingTaskGroup(of: Data.self) { group in for url in urls { group.addTask { // 子タスクは自動的に親のキャンセルをチェックする let (data, _) = try await URLSession.shared.data(from: url) return data } } // ユーザーキャンセルのシミュレーション group.cancelAll() var results: [Data] = [] for try await data in group { results.append(data) } return results } }
あるメディア処理アプリケーションは、ユーザーが途中でキャンセルできるように、10,000画像のサムネイルを生成する必要がありました。エンジニアリングチームは最初にDispatchGroupアプローチを使用し、キャンセルを可能にするためにスレッドセーフなNSHashTableでアクティブなURLSessionDataTaskオブジェクトを追跡していました。
最初の解決策は、並行性を制限するためにDispatchSemaphoreを使用したDispatchGroupでした。機能的ではありましたが、完了したタスクをキャンセルセットから削除するための複雑なロジックが必要でした。キャンセル信号とセット列挙の間にタスクが完了するというレース条件が発生し、アプリが解放されたオブジェクトを参照することになりました。このアプローチは、DispatchGroupからの通知がデリゲートを強く保持していたため、ビューコントローラが解放されるときにメモリリークも引き起こしました。
2番目のアプローチは、キャンセル用のPassthroughSubjectを使用したCombineのFlatMapを採用しました。これは、より良いコンポーザビリティを提供しましたが、パブリッシャーチェーンの割り当てからの重要なメモリオーバーヘッドを引き起こしました。キャンセルの伝播にはAnyCancellableトークンをコレクションに格納する必要があり、手動でクリーンアップが必要でした。宣言的抽象化は実際のタスク階層を隠し、キャンセル信号がオペレーターチェーンを通じて伝播しない場合のデバッグを難しくしました。
チームはSwiftのTaskGroupに移行しました。これにより、ランタイムが各サムネイル生成タスクをグループのキャンセルドメインに自動的にリンクするため、手動のNSHashTable管理が不要になりました。ユーザーがキャンセルをタップすると、ビューコントローラはgroup.cancelAll()を呼び出し、すべての実行中タスクに次のawait停止ポイントで停止するようにアトミックに信号を送りました。この解決策は、ビュー解放後に孤立したタスクが処理を続けないことを保証し、withThrowingTaskGroupの決定論的スコープが、自分がエラーを投げた場合でも自動的にクリーンアップされることを確保しました。
キャンセルの待機時間は、手動セット列挙を待機する平均500ミリ秒から、直接リンクリストの走査によって10ミリ秒未満に減少しました。メモリプロファイリングでは、キャンセル後にゼロのTaskオブジェクトのリークが示され、コードベースは40行の同期ボイラープレートを削減しました。
子タスクがキャンセルを無視し、無限に実行を続けるシナリオをTaskGroupはどのように処理するか?
候補者はしばしば、TaskGroupがタスクを強制的に終了させたり例外を注入したりすると考えます。しかし実際には、Swiftのキャンセルは協調的です:ランタイムはタスクのコンテキストにisCancelledフラグを設定しますが、タスクは停止ポイントに到達するか、明示的にTask.isCancelledをチェックするまで続行します。子タスクは定期的にTask.checkCancellation()をポーリングするか、キャンセル通知を意識したAPIに頼る必要があります。タスクが停止ポイントを持たずにCPUバウンドのループを実行すると、グループの完了を無限にブロックします。これを防ぐために、長時間実行される計算ではTask.yield()を使用するか、キャンセルフラグをチェックするチャンクに作業を分けるべきです。
cancelAll()呼び出した後にTaskGroupにタスクを追加すると、なぜその新しいタスクが即座にキャンセルされるのか?
多くの人はcancelAll()が既存の子に対してのみ送信される一度限りの信号であると仮定しています。しかし、Swiftの実装は、ステータス記録内でTaskGroup自体をキャンセルとしてマークします。その後でaddTaskが呼び出されると、ランタイムはタスク作成中にグループのキャンセル状態をアトミックにチェックし、キャンセルされている場合には、新しい子タスクはそのisCancelledフラグが事前に設定された状態で作成されます。これにより、遅れて追加されたタスクがキャンセルドメインを逃れることができず、キャンセルされたスコープが新しい有効な結果を生成できないという構造的保証が維持されます。これにより、キャンセルが終了する際に追加されたタスクがすり抜けるというレース条件が防止されます。
TaskGroupの構造化された並行性とTask.initを使用して作成したタスクの間の根本的な違いは、キャプチャされた変数のメモリ管理についてどのようなものか?
候補者はしばしば、TaskGroupの子タスクが親コンテキストのアクターアイソレーションと優先度を引き継ぐことを見落としますが、さらに重要なのは、それらがキャプチャされた変数のライフタイムをグループスコープの終了までに拡張することです。これに対して、Task { ... }で作成された非構造化のTaskオブジェクトは、生成スコープのライフタイムを超えて存続し、selfを無限にキャプチャする可能性があります。これは、TaskGroup内でaddTaskでselfをキャプチャした場合、タスクがwithThrowingTaskGroupブロックを超えて生存することができないため、[weak self]を必要としないことを意味します。しかし、開発者はしばしば非構造化タスクからの[weak self]パターンを誤って適用し、不必要にコードを複雑にし、完了のためにselfが存在することを必要とする場合にはnil参照バグを引き起こす可能性があります。