
Swift Concurrency で async 関数とキャンセル処理を実装した際の試行錯誤と結果
はじめに
プロジェクトで Swift Concurrency を使ってネットワーク処理を実装する機会がありました。その中で、キャンセル処理の実装に予想以上に苦戦したため、その経験と最終的に採用した実装パターンを共有します。
プロジェクトの内容や要件によって最適な方法は異なると思いますが、一つの参考例としてご覧いただければ幸いです。
本記事での気づきは:
- withCheckedThrowingContinuation は自動的にキャンセルを処理しない
- withTaskCancellationHandler との組み合わせが必要
- Swift 6 の並行性チェックには let による不変参照で対応
- 協調的キャンセルの理解が重要
※コード例は実際に使用したコードではありません。
プロジェクトの構成
今回のプロジェクトでは以下のような構成でした:
- ネットワークライブラリ: Alamofire
- アーキテクチャ: 擬似的クリーンアークテクチャ
- API処理の分離:
networkRequest()
: Alamofireを使ったネットワーク通信processResponse()
: レスポンスのデコードとエラーハンドリング
// 基本的な構造
class UserDataStoreImpl: UserDataStore {
func fetchUser(id: Int) async throws -> User {
let response = try await networkRequest(route: .getUser(id: id), parameters: nil)
return try processResponse<User>(from: response)
}
}
問題1: withCheckedThrowingContinuation だけではキャンセルできない
withCheckedThrowingContinuation の制約
Swift Concurrency の withCheckedThrowingContinuation
は、従来のコールバックベースの API を async/await に変換するための仕組みですが、Task のキャンセルを自動的には処理してくれません。
これは仕様上の制約であり、従来の API が持つ独自のキャンセル機構やリソース管理との整合性を保つためと考えられます。つまり、キャンセル処理は開発者が明示的に実装する必要があります。
Swift Concurrencyの継続関数の比較
withCheckedThrowingContinuation
のほかにも、従来のコールバック API を async/await に変換するための関数がいくつか用意されています。
関数名 | 継続の安全性 | エラー処理 | キャンセル処理 |
---|---|---|---|
withUnsafeContinuation() |
手動管理(高速) | なし | なし |
withUnsafeThrowingContinuation() |
手動管理(高速) | あり | なし |
withCheckedContinuation() |
自動検証(安全) | なし | なし |
withCheckedThrowingContinuation() |
自動検証(安全) | あり | なし |
withTaskCancellationHandler() |
- | - | あり |
Checked と Unsafe の違い
- Checked: 安全性を重視。継続が正確に1回だけ呼ばれることを実行時にチェックする。2回呼び出したり、呼び忘れたりするとクラッシュする。
- Unsafe: 性能を重視。継続の呼び出し回数をチェックしない。開発者が正確に呼び出すことを保証する必要があるが、わずかに高速。
最初の実装
private func networkRequest<P: Encodable>(
route: APIRouter,
parameters: P?
) async throws -> AFDataResponse<Data> {
return try await withCheckedThrowingContinuation { continuation in
let encoder = URLEncodedFormParameterEncoder(
encoder: URLEncodedFormEncoder(nilEncoding: .dropKey)
)
AF.request(
route,
method: route.method,
parameters: parameters,
encoder: encoder
)
.responseData { response in
continuation.resume(with: .success(response))
}
}
}
この実装の問題点:
- 実際に API を呼び出す箇所の Task がキャンセルされても、進行中の Alamofire リクエストは停止しない
- ユーザーが画面を離れても通信が継続してしまう
- 不要なネットワーク帯域の消費や予期しない UI 更新のリスクがある
解決策: withTaskCancellationHandler を追加
表で整理してみると、今回必要な機能(エラーハンドリング + キャンセル処理)を実現するには、withCheckedThrowingContinuation
と withTaskCancellationHandler
を組み合わせるのが適していることがわかります。この方針で実装を進めてみました。
でも、このコードではワーニングが発生しました。
private func networkRequest<P: Encodable>(
route: APIRouter,
parameters: P?
) async throws -> AFDataResponse<Data> {
var dataRequest: DataRequest?
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
// 省略:encoderの設定
// リクエスト参照を保持
dataRequest = AF.request(...)
dataRequest.responseData { response in
// 省略:キャンセルエラー処理
continuation.resume(returning: response)
}
}
} onCancel: {
// `Task.cancel()` が呼ばれた瞬間に実行される
dataRequest.cancel() // <- ワーニング: Reference to captured var 'dataRequest' in concurrently-executing code; this is an error in the Swift 6 language mode
}
}
実装ポイント:
onCancel
はTask.cancel()
が呼ばれた瞬間に実行される。- メイン処理の完了を待たない。
問題2: Swift 6 の並行性チェックで躓いた話
遭遇したワーニング
上記のコード例では、dataRequest.cancel()
の箇所で以下のワーニングが発生しました:
Reference to captured var 'dataRequest' in concurrently-executing code;
this is an error in the Swift 6 language mode
Swift 6 の並行性チェックとは
Swift 6 では、データ競合を防ぐために厳格な並行性チェックが導入されています。ここで問題となるのが変数キャプチャです。
問題の詳細:
// ワーニングが出たコード(Swift 6 でエラーになる)
private func networkRequest() async throws -> AFDataResponse<Data> {
var dataRequest: DataRequest? // ← 可変値変数(var)
return try await withTaskCancellationHandler {
// コンテキスト1: メイン処理
dataRequest = AF.request(...) // ← ここでキャプチャ
} onCancel: {
// コンテキスト2: キャンセル処理
dataRequest?.cancel() // ← 異なるコンテキストから同じ変数にアクセス、データ競合の可能性ある
}
}
なぜエラーになるのか
- 並行実行コンテキスト:
withTaskCancellationHandler
のメイン処理とキャンセル処理は異なる並行実行コンテキストで動作する。 - データ競合の可能性: 同じ可変変数(var)に対して同時にアクセスする可能性があるため、データ競合が発生するリスクがある。
解決方法
シンプルな方法で解決できました。dataRequest
を定数(let)として定義することで、複数の並行実行コンテキストでも安全にアクセルできるようになります。
// エラーが発生せず、正常に実行できるパターン
private func networkRequest<P: Encodable>(
route: APIRouter,
parameters: P?
) async throws -> AFDataResponse<Data> {
let encoder = URLEncodedFormParameterEncoder(
encoder: URLEncodedFormEncoder(nilEncoding: .dropKey)
)
let headers = HTTPHeaders([
HTTPHeader(name: "Content-Type", value: "multipart/form-data")
])
// 不変参照 = データ競合なし
let dataRequest = AF.request(
route,
method: route.method,
parameters: parameters,
encoder: encoder,
headers: headers
)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
dataRequest.responseData { response in
// 省略:キャンセルエラー処理
continuation.resume(returning: response)
}
}
} onCancel: {
dataRequest.cancel()
}
}
修正ポイント:
dataRequest
の生成処理はwithTaskCancellationHandler
のスコープ外で実行する。dataRequest
を定数として定義する。
変更不可能な定数は、複数のコンテキストで安全に共有できるため、エラーにはなりません。
問題3: 重複コードが気になって共通化した話
このままでも問題なく実行できますが、今後新しい API サービスが追加されるたびに、withTaskCancellationHandler
と withCheckedThrowingContinuation
を二重に使ったキャンセル処理を毎回コピペするのは面倒です。また、仕組みを理解するためのコメントがあちこちに散らばるのも避けたいと考え、共通化することにしました。
とはいえ、あまり複雑に共通化してしまうと柔軟性やメンテナンス性が損なわれるため、Alamofire の DataRequest
を拡張する形で共通化を図ることにしました:
extension DataRequest {
/// async/await対応メソッド(Taskのキャンセル対応)
func responseDataAsync() async throws -> AFDataResponse<Data> {
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
self.responseData { response in
// キャンセルエラーはシステム制御フローの一部なのでここで処理する
if let error = response.error,
error.isExplicitlyCancelledError {
continuation.resume(throwing: CancellationError())
return
}
// パースエラーなどのビジネスロジックエラーは呼び出し元で処理する
continuation.resume(returning: response)
}
}
} onCancel: {
self.cancel()
}
}
}
これで新しい API を実装する際は、以下のように記述できます:
private func networkRequest<P: Encodable>(
route: APIRouter,
parameters: P?
) async throws -> AFDataResponse<Data> {
let request = AF.request(...)
return try await request.responseDataAsync() // ← 共通化された処理
}
// 使用例
func fetchUser(id: Int) async throws -> User {
let response = try await apiRequest(
route: .getUser(id: id),
parameters: nil
)
return try processResponse<User>(from: response) // ← 既存のデコード処理
}
かなりスッキリしました。
ちなみに、Alamofire の upload
関数の返り値である UploadRequest
は DataRequest
のサブクラスなので、今回の拡張はそのまま適用できます。
キャンセルの使用例
これで、実際に Presenter では次のようにキャンセル処理を行うことができます:
class UserListPresenter {
private var loadingTask: Task<Void, Never>?
func loadUsers() {
// 前のタスクをキャンセル
loadingTask?.cancel()
loadingTask = Task {
do {
let users = try await userRepository.fetchUsers()
// キャンセルされてたら画面更新しない
// 明示的にチェックしなければキャンセルは無視される
guard !Task.isCancelled else { return }
await MainActor.run {
view.showUsers(users)
}
} catch {
// キャンセルエラーは特別扱い
guard !Task.isCancelled else { return }
await MainActor.run {
view.showError(error)
}
}
}
}
}
実装ポイント:
- Swift Concurrency のキャンセルは協調的キャンセル
- チェックしなければ無視される
task.cancel()
は「止まってください」のお願い- 実際に止めるには
Task.isCancelled
のチェックが必要 - 開発者が安全な場所で処理を中断する責任を持つ
まとめ
Swift Concurrency のキャンセル処理で覚えておくべきポイント:
withCheckedThrowingContinuation
は自動キャンセルしないwithTaskCancellationHandler
との組み合わせが必要- Swift 6 ではなるべく不変参照(let)を使う
- 協調的キャンセルの理解が重要
- UI レイヤーでの
Task.isCancelled
チェック
Swift Concurrencyは強力ですが、キャンセル処理を適切に実装するのは想像以上に難しいと感じました。特に、withCheckedThrowingContinuation
がキャンセルを自動で処理してくれない点は予想外でした。
今回紹介したパターンがすべてのプロジェクトに適用できるとは限りませんが、似たような問題に直面した方の参考になれば幸いです。
参考資料
公式資料
非公式の記事