Swift & Objective-C

[Swift応用 #1] async/awaitの仕組み:停止するのはスレッドではありません

awaitで停止するのはスレッドではなく関数です。関数が状態をヒープに保存してスレッドを協調型プールに返すsuspensionの正確な意味と、逐次awaitが並列実行ではない理由を整理します。

読了 7 分
[Swift応用 #1] async/awaitの仕組み:停止するのはスレッドではありませんのカバー画像

エラー処理編とクロージャ編で何度も「async/await以降は」という但し書きを入れました。今回はその本編です。

応用シリーズの幕を開けるSwift Concurrency連載の第1回です。async/awaitが何を解決し、どのように動くのかから始めます。

文法自体は1日で覚えられます。関数にasyncを付け、呼び出しにawaitを付けるだけです。

難しいのはその先です。「awaitでスレッドは停止するのか」「async関数はどのスレッドで動くのか」といった疑問です。

この問いに明確な答えが出なければ、並行処理コードは呪文の暗記のままです。

この記事の目標は、その2つの疑問に正確に答えることです。

コールバックの本当の問題――インデントではなく、コンパイラの盲目性

async/await以前の非同期処理は、クロージャ編で見たcompletionハンドラーでした。

問題としてよく「コールバック地獄」のインデントが挙げられますが、それは症状です。根本原因は、コンパイラが処理の流れを見られないことです。

func loadProfile(completion: @escaping (Result<Profile, Error>) -> Void) {
    fetchUser { result in
        switch result {
        case .success(let user):
            fetchAvatar(user.avatarURL) { avatarResult in
                // completion 呼び出しを忘れたら?コンパイラには分かりません
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

completionをある経路で呼び忘れたり、2回呼んだり、間違ったスレッドから呼んだりしても、コンパイラは何も言いません。

言語の基本概念であるreturnを、クロージャ呼び出しの慣習で置き換えた結果です。すべての経路が値を返す、エラーが伝播する、といった言語の保証が開発者の規律に委ねられました。

async/awaitは非同期処理を再び言語の管轄に戻します。

func loadProfile() async throws -> Profile {
    let user = try await fetchUser()
    let avatar = try await fetchAvatar(user.avatarURL)
    return Profile(user: user, avatar: avatar)
}

返り値はreturn、エラーはthrowsです。すべての経路が値を返すかthrowすることを、コンパイラが再び検査します。

エラー処理編で整理したdo-catchと伝播のルールも、非同期処理でそのまま機能します。単なる糖衣構文ではなく、失われたコンパイラの視力を取り戻す仕組みです。

suspensionの正確な意味――停止するのは関数であってスレッドではない

最初の疑問です。awaitでスレッドは停止するのでしょうか。

停止しません。停止するのは関数で、スレッドは解放されて別の仕事に向かいます。

この区別はSwift Concurrency全体で最も重要な一文です。

await地点では、関数が自分の進行状態(ローカル変数と実行位置)をスタックではなくヒープに保存し、占有していたスレッドを返します。

これをsuspension(中断)と呼びます。待っていた処理が終わると、保存した状態を引き継いで実行がresume(再開)されます。

再開時に同じスレッドが使われる保証はありません。同じ関数の前半と後半が別のスレッドで実行されることもあります。

この設計により、Swiftの並行処理ランタイムは協調型スレッドプールで動作します。CPUコア数程度のスレッドを用意し、関数がawaitごとにスレッドを譲り合います。

数百のスレッドを作って眠らせていた(blocking)GCD(Grand Central Dispatch)時代との違いです。スレッドが眠らないため、スレッドの急増もコンテキストスイッチの浪費も構造的に減ります。

ここから実務上のルールが1つ導けます。協調型プールのスレッドではブロックしてはいけません。

セマフォで待機したりsleepを呼んだりすると、譲り合いを前提に設計されたプールのスレッドが丸ごと眠ります。コアが4つならスレッドも4本前後なので、1本眠ればシステム全体の4分の1が止まる計算です。

「awaitは譲る、blockは禁止」が協調型プールの第一原則です。

await停留所で状態をバッグに詰め、スレッドを返す関数のイラスト
awaitでは関数が荷物をまとめ、スレッドは返却されて別の仕事をします

どのスレッドで動くのか――スレッドではなく隔離を問う

2つ目の疑問です。async関数はどのスレッドで動くのでしょうか。

Swift Concurrencyの答えは「その問いを捨てる」です。スレッドはランタイムが管理する資源で、開発者が指定するのは隔離(isolation)、つまり「このコードはどの直列実行コンテキストに属するか」です。

代表的な隔離がMainActorです。@MainActorが付いた関数や型は、メインスレッドで実行されることが保証されます。

UI更新コードにDispatchQueue.main.asyncを勘で散りばめていた時代とは違います。「メインのみ」という要件が型システムに宣言され、コンパイラが検査します。

UIViewControllerとSwiftUI Viewはすでに@MainActorで宣言されています。ビューコード内では自動的にメイン隔離を受けます。

一方、隔離の指定がないasync関数は特定のactorに属さず(nonisolated)、協調型プールのどこかで実行されます。

重い計算をメインから外すために「バックグラウンドスレッドへ送る」コードを書く必要はありません。その処理をMainActor隔離に属さないよう宣言するのがSwift流の考え方です。

必要ならTaskで新しい非同期コンテキストを開きます(Taskの構造化された使い方は本連載第4回で扱います)。

既存のコールバックAPIへの橋渡しも言語が提供します。withCheckedThrowingContinuationでコールバックAPIをasync関数で包めます。

continuationのresumeを正確に1回呼ぶことが契約で、「Checked」版は契約違反をランタイムで検出します。レガシーSDKと共存する実務で、最初に身につけたいブリッジングツールです。

逐次awaitの落とし穴――並行処理は無料ではない

上のloadProfileコードには、実は性能上の落とし穴が1つあります。fetchUserと無関係な別のリクエストならどうでしょう。

// 逐次:バナーが終わってからアバターが開始(合計 2秒)
let avatar = try await fetchAvatar()   // 1秒
let banner = try await fetchBanner()   // 1秒

awaitは「ここで待つ」という意味なので、この書き方では2つのリクエストが直列に並びます。互いに依存しない処理なら、async letで同時に開始するのが適切です。

// 同時:2つのリクエストが一緒に走る(合計 1秒)
async let avatar = fetchAvatar()
async let banner = fetchBanner()
let profile = try await Profile(avatar: avatar, banner: banner)

async/awaitが自動的に並列化してくれるわけではありません。逐次と同時のどちらを選ぶかは、依然として設計者の仕事です。

ただし、その選択がコールバックの組み合わせではなく、1行の文法差になったのは進歩です。async letとTaskGroupの体系的な使い方は、構造化並行処理編で詳しく扱います。

逐次awaitの2秒とasync letの1秒を対比したタイミング図
awaitは自動的な並列化ではありません。独立した処理はasync letで

まとめ

  • コールバックの本当の問題はインデントではなく、コンパイラの盲目性でした。async/awaitはreturnとエラー伝播を言語の管轄に戻し、コンパイラの検査を復元します。
  • awaitで停止するのは関数であってスレッドではありません。関数の状態はヒープに保存され、スレッドは返され、再開時に同じスレッドが使われる保証はありません。
  • ランタイムは協調型スレッドプールです。そのため、協調型プール内でのブロッキング(セマフォ、sleep)は禁止です。
  • 問うべきは「どのスレッドか」ではなく「どの隔離か」です。UIは@MainActor宣言で保証し、コールバックAPIはcontinuationで包みます。
  • awaitは自動的な並列化ではありません。独立した処理はasync letで同時に開始します。

次回はこの連載の核心、actorです。データ競合とは何か、actorがそれをコンパイル時の概念に引き上げる仕組み、そして悪名高いreentrancyの落とし穴まで扱います。

あわせて読みたい