AlamofireはAF.request(...).responseDecodable { response in ... }のようにクロージャ(コールバック)ベースでレスポンスを受け取るのが基本設計です。
非同期処理そのものは正しいのですが、実務では「複数のAPI呼び出しがすべて完了してから次の処理に進みたい」といった、擬似的に同期処理として書きたい場面によく出会います。

本記事では、この目的に対して現実的に選択肢となるDispatchGroupとasync/awaitの2つを、同じシナリオで比較します。あわせて、同じ目的でよく使われるDispatchSemaphoreについては、避けるべきパターンとして解説します。

検証環境は Alamofire 5.12.x / Swift 6 系を前提とします。

共通のシナリオと前提

この記事全体を通して、次のシナリオを使います。

複数のユーザーIDを受け取り、それぞれのプロフィールをAPIから取得する。すべて取得できたら、まとめて次の処理に渡す。

まずはベースになる、素朴なクロージャベースの1件取得APIです。

struct User: Decodable, Sendable {
    let id: Int
    let name: String
}

func fetchUser(id: Int, completion: @escaping (Result) -> Void) {
    AF.request("https://api.example.com/users/\(id)")
        .validate()
        .responseDecodable(of: User.self) { response in
            completion(response.result)
        }
}

このfetchUserを複数のIDに対して呼び出し、「全部終わったら次に進む」処理を3通りの方法で書いていきます。

なお本記事では、あるスレッド上で処理が先に進めなくなる状態全般を一貫して「スレッドをブロックする」と表記します。
そのうち、ブロックされた側同士が互いの再開を待ち合ってしまい、条件が永久に満たされないケースだけを「デッドロック」と呼んで区別します。
つまりデッドロックは、スレッドをブロックすることで起こりうる特殊な(そして最も深刻な)症状の1つ、という位置づけです。

避けるべき書き方: DispatchSemaphoreで待つ

最初に多くの人が一度は書いてしまう失敗パターンを紹介します。

func fetchUsersBad(ids: [Int]) -> [User] {
    var users: [User] = []
    let semaphore = DispatchSemaphore(value: 0)

    for id in ids {
        fetchUser(id: id) { result in
            if case .success(let user) = result {
                users.append(user)
            }
            semaphore.signal()
        }
        semaphore.wait() // ここでスレッドをブロックする
    }

    return users
}

このコードをメインスレッドから呼ぶと、高確率でデッドロックします。理由はシンプルで、Alamofireのデフォルトのコールバックキューは.mainです。
semaphore.wait()でメインスレッドをブロックすると、結果を受け取ってsemaphore.signal()を呼ぶはずのコールバックも同じメインスレッド上でしか実行できず、いつまでも実行されません。

「待つ側」と「合図を送る側」が同じスレッドを取り合ってしまい、どちらも先に進めなくなる——これが本記事の定義でいうデッドロックです。

この点は、Apple Developer Technical Support(DTS)のQuinn氏がフォーラムで繰り返し解説しています。非同期APIをDispatchSemaphoreで同期的に見せかけるテクニックについて、次のように明確に警告しています。

If any code on your main thread has a dependency on this secondary thread, or a resource held by the secondary thread, you run the risk of deadlock.
(メインスレッド上のコードが、待機中の別スレッドや、そのスレッドが保持するリソースに依存している場合、デッドロックのリスクを負うことになります) —— Apple Developer Forums

さらに別のスレッドでは、スレッドをブロックする行為そのものがスレッドプールを枯渇させ、単なるパフォーマンス低下では済まずデッドロックに直結しうると説明されています。

Thread exhaustion may seem like just a performance problem, but that’s not the case. It’s possible for thread exhaustion to lead to a deadlock, which blocks all thread pool work in your process forever.
(スレッド枯渇は単なるパフォーマンス問題に見えるかもしれませんが、そうではありません。スレッド枯渇はデッドロックにつながることがあり、その場合プロセス内のスレッドプールの作業全体が永久に止まります) —— Apple Developer Forums

コールバックキューをバックグラウンドキューに変更すれば、今回の特定のデッドロックは技術的には回避できます。
ただしそれでも推奨しません。「非同期のコールバックをセマフォで待ち受ける」という実装は、以前からXcodeの静的解析ツールが次のような警告文を用意しているほど、問題のあるパターンとして知られているからです。

Waiting on a callback using a semaphore creates useless threads and is subject to priority inversion; consider using a synchronous API or changing the caller to be asynchronous
(コールバックをセマフォで待つことは無駄なスレッドを生み、優先度逆転の対象になります。同期APIを使うか、呼び出し元を非同期にすることを検討してください) —— Apple Developer Forums: Dispatch Semaphore Anti-Pattern

ここで問題視されているのは、「DispatchSemaphoreと非同期処理を組み合わせること」全般ではなく、「セマフォでコールバックの完了を待ち受ける」という使い方そのものです。
優先度の高いスレッド(メインスレッドなど)が、優先度の低いキューで実行中の処理の完了をセマフォで待つと、優先度逆転が起こります。
低電力モード時など、システムが低優先度のキューを処理しない状況では、これが実質的なデッドロックとしてUIを止めてしまうことがあります。

比較1: DispatchGroupで束ねる

同じシナリオを、DispatchGroupで書き直します。

func fetchUsers(ids: [Int], completion: @escaping ([User]) -> Void) {
    let group = DispatchGroup()
    let users = OSAllocatedUnfairLock(initialState: [User]())

    for id in ids {
        group.enter()
        fetchUser(id: id) { result in
            if case .success(let user) = result {
                users.withLock { $0.append(user) }
            }
            group.leave()
        }
    }

    group.notify(queue: .main) {
        completion(users.withLock { $0 })
    }
}

ポイントはgroup.wait()ではなくgroup.notify(queue:)を使っていることです。
wait()はセマフォと同様にスレッドをブロックしてしまいますが、notifyはクロージャによる通知なので呼び出し元のスレッドをブロックしません。
「複数の非同期処理がすべて終わったら、まとめて次に進む」という目的を、スレッドをブロックせずに実現できます。

複数のクロージャが同時にusersへ書き込もうとすると衝突する可能性があるため、排他制御は必要です。iOS 16 / macOS 13以降が対象なら、上記のようにOSAllocatedUnfairLockを使うのがおすすめです。
NSLockを自分でlock()/unlock()するより安全で、withLockのクロージャを抜けると自動的にアンロックされるため、アンロックし忘れる心配もありません(それより前のOSを対象にする場合はNSLockで同様の排他制御を書きます)。

比較2: async/awaitで書き直す(推奨)

Alamofireは5.5以降、DataTaskを介したSwift Concurrencyサポートを提供しています。serializingDecodable()を使うと、コールバックを介さずにawaitで結果を受け取れます。

func fetchUser(id: Int) async throws -> User {
    try await AF.request("https://api.example.com/users/\(id)")
        .validate()
        .serializingDecodable(User.self)
        .value
}

同じシナリオ(複数IDのプロフィールをすべて取得してから次に進む)は、withThrowingTaskGroupで書けます。DispatchGroupと対になる、Swift Concurrency側の仕組みです。

func fetchUsers(ids: [Int]) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await fetchUser(id: id)
            }
        }

        var users: [User] = []
        for try await user in group {
            users.append(user)
        }
        return users
    }
}

DispatchGroup版と比べると、for try await user in groupが1件ずつ順番に結果を受け取る仕組みになっているため、
複数のタスクから同時に書き込まれて衝突する、という状況自体が起こりません(OSAllocatedUnfairLockのような排他制御を別途用意する必要もありません)。
ただし、async/awaitを選ぶ本質的な理由はそこよりも次の2点にあります。

  1. Swift Concurrencyへの統合: Taskのキャンセルや優先度、構造化された親子関係といった仕組みにそのまま乗る形でAPI呼び出しを組み込めます。呼び出し元がすでにasync関数であれば、DispatchGroup版のようにコールバック引数を用意する必要もありません。
  2. 非同期処理の制御がAlamofire側で完結する: serializingDecodable().valueをawaitするだけで完結するため、DispatchGroupのように「呼び出し側で完了管理の仕組みを自作する」必要がなく、保守性が高くなります。

もちろんスレッドをブロックしない点も変わらぬ利点です。前述の通り、これはSwift Concurrencyが目指す「ブロックせず中断する」という方向性にも合致しています。

呼び出し件数があらかじめ3件などに固定されている場合は、async letを使うとさらにシンプルに書けます。

func fetchDashboard(userId: Int) async throws -> (User, [Post], [Notification]) {
    async let user = fetchUser(id: userId)
    async let posts = fetchPosts(userId: userId)
    async let notifications = fetchNotifications(userId: userId)
    return try await (user, posts, notifications)
}

DataTaskは.value以外に.response(DataResponse全体)や.result(Result型)でも受け取れるので、詳細なレスポンス情報が必要な場合はそちらを使います。またタスクのキャンセルを、呼び出し元のTaskのキャンセルに連動させたい場合は、次のようにautomaticallyCancellingを明示します(デフォルトでは連動しません)。

let response = await AF.request(url)
    .serializingDecodable(User.self, automaticallyCancelling: true)
    .response

Swift 6の厳格な並行性まわりの注意点

Swift 6でStrict Concurrencyを有効にすると、Alamofireのモデルや一部APIについてSendable関連の警告が出ることがあります(Alamofire/Alamofire#3887、#3904)。デコード対象のモデルには本記事のサンプルのようにSendableを明示的に付与しておくと、警告や意図しない挙動を避けやすくなります。

比較表

同じシナリオ(複数APIの完了待ち)を実現する2つの方法を比較します。DispatchSemaphoreは前述の通り比較対象外のため、参考として一番左に置いています。

観点 DispatchSemaphore(参考・非推奨) DispatchGroup async/await + TaskGroup
呼び出し元スレッドのブロック ある ない(notify使用時) ない(中断・再開)
デッドロックリスク 高い(優先度逆転経由を含む) 低い ほぼない
結果を集める処理の書きやすさ 1件ずつ処理する前提で複数件には不向き enter/leaveで書けるが自前実装 for try awaitで順次受信するだけで済む
共有状態への排他制御 (用途上ほぼ不要) OSAllocatedUnfairLock等が必要(書き方自体はシンプル) 実装上不要(衝突が起きる状況自体がない)
キャンセル制御 自前実装が必要 自前実装が必要 Task/automaticallyCancellingで統合可能
推奨度 非推奨(コールバック待ちの用途では使わない) 条件付きでOK(既存の完了ハンドラ設計を活かしたい場合) 推奨

まとめ

  • 「非同期のコールバックをセマフォで待ち受ける」実装は、Xcodeの静的解析ツールが警告文を用意しているほど以前から知られた問題のあるパターンです。優先度逆転を通じて実質的なデッドロックにつながることがあるため、UIに関わるコードでは避けてください。DispatchSemaphore自体が悪いのではなく、この使い方が問題です。
  • DispatchGroupはnotifyで使う限りスレッドをブロックせずに「複数の非同期処理をひとまとめにする」ことができます。結果を集める際の排他制御は必要ですが、OSAllocatedUnfairLockを使えばシンプルかつ安全に書けます。
  • 新規実装やリファクタリングの機会があるなら、withThrowingTaskGroupやasync letを使ったasync/awaitへの置き換えが最も見通しが良い選択肢です。理由はスレッドをブロックしないことだけでなく、Task起点のキャンセル・優先度管理といったSwift Concurrencyの仕組みにそのまま乗れること、そして非同期処理の制御がAlamofire側(serializingDecodable().value)で完結し、呼び出し側の保守性が上がることにあります。これはSwift自体が「スレッドをブロックせず中断する」方向に設計されていることとも一致します。Swift 6のStrict Concurrencyを使う場合は、デコード対象モデルへのSendable付与も忘れずに。

参考リンク