📝

【翻訳】Understanding async/await in Swift

2023/07/14に公開

Swiftで並行処理に飛び込もうとする前に、async/awaitを理解する必要があります。
それを避ける方法はありません。async/awaitだけが並行処理のオプションではありませんが、
AppleのSDKはそれらを多用し始めています。
サードパーティのライブラリ・プロバイダがこれらを提供し始めることは間違いありません。

この記事では、async/awaitについて説明します。これらの概念を理解したら、
構造化された並行処理、非構造化された並行処理、SwiftUIなどをカバーする、より高度な記事に進みます。

コールバックベースのconcurrencyを書いているのであれば、async/awaitの実装は、
Appleのテクノロジーで以前に見たものとは全く異なることを覚えておいてください。
それは基本的に、並行プログラミングについてあなたが知っていることを窓から投げ捨てるようなものです。
この記事を読む際には、そのことを念頭に置いておくことが重要です。

この記事では、画像とそのメタデータを別のネットワーク呼び出しを使ってダウンロードする関数を書きます。
コールバック・ベースのconcurrencyでこれを行うと、すぐに管理するのが難しくなること、
そしてasync/awaitがこの問題を見事に解決してくれることを紹介します。

手続き型プログラミング

ネットワークやI/Oのような通常のプログラムを書く場合、
プログラムはコードが書かれた順番に実行され、必要に応じてプロシージャを呼び出し、
必要に応じて呼び出し元に内容を返します。

次のコードを考えてみよう:

func sayHi() {
    print("Hi")
}

func multiply(_ x: Int, _ y: Int) -> Int {
    x * y
}

func sayBye(result: Int) {
    print("Bye \(result)")
}

func performCoolStuff() {
    sayHi()
    let x = 10
    let y = 5
    let result = multiply(x, y)
    sayBye(result: result)
}

// Calling performCoolStuff()
performCoolStuff()

performCoolStuff()を呼び出すと、コードは以下のように実行されます。

  1. 最初に sayHi() を呼び出します。
  2. 2 つの変数、x および y を宣言します。
  3. x および y の値を渡して multiply を呼び出します。
  4. 乗算の結果でsayByeを呼び出す。

ここで迷うことはありません。あなたのコードは、与えられたのと同じ順序で呼び出されます。
他の関数を呼び出す関数は、呼び出し元と同じように呼び出しスタックに置かれ、呼び出し元へ値を返すたびに巻き戻されます。呼び出しが発生すると、関数はreturnを使用して呼び出し元に制御を戻します。
私たちがmultiplyを呼び出すとき、私たちはその関数に制御を割り当て、関数が私たちに結果を返すとき、
関数はreturnを通して私たちに制御を返します。

手続き型プログラミングについてあまり考えたことはないでしょう。
手続き型プログラミングは日常的に行われており、常に期待通りに動いています。

コールバック・ベースのconcurrencyのコード

他のコードと並行して実行される可能性のあるコードについては、少し複雑になります。
次の例では、ネットワークコールで画像をダウンロードし、
別のネットワークコールでメタデータをダウンロードします。
(このコードは、新しいプロジェクトのビューコントローラーにコピー&ペーストできます。)
ダウンロードは、メインスレッドの実行と同時に行われます:

struct ImageMetadata: Codable {
    let name: String
    let firstAppearance: String
    let year: Int
}

struct DetailedImage {
    let image: UIImage
    let metadata: ImageMetadata
}

enum ImageDownloadError: Error {
    case badImage
    case invalidMetadata
}

func sayHi() {
    print("Hi")
}

func multiply(_ x: Int, _ y: Int) -> Int {
    x * y
}

func sayBye(result: Int) {
    print("Bye \(result)")
}

func downloadImageAndMetadata(
    imageNumber: Int,
    completionHandler: @escaping (_ image: DetailedImage?, _ error: Error?) -> Void
) {
    let imageUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(imageNumber).png")!
    let imageTask = URLSession.shared.dataTask(with: imageUrl) { data, response, error in
        guard let data = data, let image = UIImage(data: data), (response as? HTTPURLResponse)?.statusCode == 200 else {
            completionHandler(nil, ImageDownloadError.badImage)
            return
        }
        let metadataUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(imageNumber).json")!
        let metadataTask = URLSession.shared.dataTask(with: metadataUrl) { data, response, error in
            guard let data = data, let metadata = try? JSONDecoder().decode(ImageMetadata.self, from: data),  (response as? HTTPURLResponse)?.statusCode == 200 else {
                completionHandler(nil, ImageDownloadError.invalidMetadata)
                return
            }
            let detailedImage = DetailedImage(image: image, metadata: metadata)
            completionHandler(detailedImage, nil)
        }
        metadataTask.resume()
    }
    imageTask.resume()
}

func performMessyStuff() {
    sayHi()
    let x = 10
    downloadImageAndMetadata(imageNumber: 1) { image, error in
        DispatchQueue.main.async {
            print("We got results")
        }
    }
    let y = 5
    let result = multiply(x, y)
    sayBye(result: result)
}

performMessyStuff()

注:AppleはWWDC2021のMeet async/await in Swiftセッションで同様の例を使いました。
この例はそれに基づいていますが、私はあなたが使えるコンパイル可能なバージョンを作成しました。

こうなります。

  1. このメソッドは普通にsayHi()を呼び出します。

  2. 変数xを作成し、それに値を代入します。

  3. downloadImageAndMetadataが呼び出され、
    内部的に実行に必要な最初の変数(imageUrl)がセットアップされます。

  4. 再び同期的に、dataTaskを保持する変数を作成し、
    ダウンロード終了後に呼び出される完了ハンドラを提供する。

  5. このタスクでresume()を呼び出し、ダウンロードを開始する。

  6. 完了ハンドラの内容はすぐには実行されません。
         ダウンロードが行われている間、プログラムは実行を続けます。

  7. プログラムは "We got results "と表示してもしなくてもいいです。
          ネットワーク・ダウンロードの場合は常に時間がかかりますが、より高速な非同期処理であれば、
          この時点で呼び出されるかもしれません。プログラムは変数yを作成します。

  8. 両方のダウンロードが正常に終了した場合、プログラムは "We got results "と表示します。
          そうでない場合は、result変数を作成し、multiplyを呼び出します。

  9. ダウンロードが正常に終了すれば、プログラムは "We got results "と表示し、
    そうでなければsayByeを呼び出します。

  10. 画像タスクがダウンロードを完了した後、
    プログラムはメタデータ・ダウンロード・タスクを開始することができます。

なぜなら、ネットワークからのデータのダウンロードは非同期であり、すべての作業は別の場所で起こるからです。
ダウンロードが行われている間、メインスレッドで他のことが起こるかもしれません。
コンソールが表示する出力は、実行のたびに異なるかもしれません。
ダウンロードはメイン・スレッドから別のスレッドに生まれますが、
プログラムはメイン・スレッドで何の問題もなくコードを実行し続けます。
このため、手続き的に考えるのが難しくなります。
なぜなら、completionHandlerが処理を終えたことを知らせてくれるのを頼りにしているからです。
メイン・スレッドで実行できるタスクがあっても、それが画像やそのメタデータに依存している場合は、
その作業をすべてcompletionハンドラーで行わなければなりません(関連するときはいつでも、DispatchQueue.main.asyncを使ってメイン・スレッドに作業をルーティングし直します)。

コールバックベースの非同期コードの場合、補完ハンドラが実行されるたびに制御が戻されます。

そして、ご想像の通り、これらの呼び出しはますます複雑になり、入れ子になる可能性があります。

async/awaitの導入

async/awaitを一言で説明するとすれば、こうなるでしょう。

async/awaitは、手続き型プログラミングとコールバックベースのクロージャのハイブリッドのようなものです。

その理由を説明する前に、2つのことを覚えておきましょう。

  1. 手続き型コードは上から下へ実行される。制御はreturnを通じて呼び出し元に戻される。
  2. コールバック・ベースのconcurrencyは、非同期タスクを作成しますが、
          それらのタスクが実行中であっても、問題なく現在のスレッドの実行を継続します。
    制御は完了ハンドラを通じて呼び出し元に戻されます。

asyncキーワードとawaitキーワードについて、個別に説明しましょう。

async

asyncには2つの使い方があります。

コードの一部が非同期であることをコンパイラーに伝える。
非同期タスクを並列に実行する。
関数を非同期としてマークするには、次のように関数の閉じ括弧の後、矢印の前にキーワードを置くだけです。

func downloadImage(id: Int) async -> UIImage? { ... }

もしくは、

func downloadImage(id: Int) async throws -> UIImage { ... }

すでに大きな利点がおわかりでしょう。完了ハンドラーがなくなり、
関数のシグネチャは目的が非常に明確になっています。非同期かどうかも、何を返すかも一目でわかります。

asyncコードは並行コンテキストでのみ実行できます。つまり、他の非同期関数の中か、
Task {}を介して手動でディスパッチされた場合です。Task {}についてはもう少し詳しく説明します。

await

awaitは魔法が起こる場所です。プログラムがawaitキーワードを見つけるたびに、
関数を一時停止するオプションがあります。そうするかどうかはシステム次第です。

システムが関数を一時停止した場合、awaitは呼び出し元ではなくシステムに制御を戻します。
システムは、中断された関数が終了するまで、そのスレッドを使って他の処理を実行します。
await以下のステートメントは、awaitが終了するまで実行されません。
システムは、何を実行するのが重要かを決定し、ある時点で、awaitされた関数が終了したことを確認した後、あなたに制御を戻します。

信号機のようなものだと思えば大丈夫です。道路を走っていて赤信号を見つけたら、あなたは停止します。
しかし、朝の4時で車が来ていなければ、そのまま走ってしまうかもしれません。

awaitについて理解しておく必要があるのは、サスペンドを選択した場合、システムが指示するまで
そのスレッドより下は何も実行されず、システムはそのスレッドを使って他の作業を行うということです。

非同期関数への呼び出しはすべて、awaitとしてマークされなければなりません。

このことをよりよく理解するために、downloadImageAndMetadata関数を書き直し、
今度はasyncを使用し、ボディ内でawaitを使用します。

func downloadImageAndMetadata(imageNumber: Int) async throws -> DetailedImage {

        // Attempt to download the image first.
        let imageUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(imageNumber).png")!
        let imageRequest = URLRequest(url: imageUrl)
        let (imageData, imageResponse) = try await URLSession.shared.data(for: imageRequest)
        guard let image = UIImage(data: imageData), (imageResponse as? HTTPURLResponse)?.statusCode == 200 else {
            throw ImageDownloadError.badImage
        }

        // If there were no issues, continue downloading the metadata.
        let metadataUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(imageNumber).json")!
        let metadataRequest = URLRequest(url: metadataUrl)
        let (metadataData, metadataResponse) = try await URLSession.shared.data(for: metadataRequest)
        guard (metadataResponse as? HTTPURLResponse)?.statusCode == 200 else {
            throw ImageDownloadError.invalidMetadata
        }

        let detailedImage = DetailedImage(image: image, metadata: try JSONDecoder().decode(ImageMetadata.self, from: metadataData))

        return detailedImage
    }

これは長い機能だが、すでにピラミッド版よりずっと良くなっている。まず重要な部分を強調しましょう。

  1. プログラムはimageUrlとimageRequestを手続き的に作成します。

  2. プログラムは非同期呼び出しであるURLSession.shared.data(for:)の呼び出しに到達します。

  3. プログラムは機能を中断するか、継続するかを判断する。この場合、ネットワークの性質上、
    中断される可能性が高いが、それを当然だと思わないでほしい。
    ここでは、プログラムが関数を中断すると仮定する。

  4. これで制御はシステムに戻る。

  5. ダウンロードが待たされている間、システムはこのタスクとは関係のない他の作業を行うかもしれません。

  6. 最初のawait以下は実行されない。ガードに到達せず、メタデータのための変数を作成せず、
    待機中の関数が終了するまで何もしません。

  7. しばらくして、待ち関数が終了すると、システムは制御をあなたに返します。

  8. ガード文に到達し、必要であればエラーを投げます。

  9. プログラムはステップ2~8を繰り返しますが、メタデータ・タスクについては同じです。

  10. プログラムは新しいDetailedImageを返します。

ご覧のように、これは非常に直線的な流れであり、システムが必要と判断するまで
awaitが残りの実行を一時停止する方法は、手続き型プログラミングに非常によく似た動作をします。

この関数を別の関数に分離することもできます。

func downloadImageAndMetadata(imageNumber: Int) async throws -> DetailedImage {
    let image = try await downloadImage(imageNumber: imageNumber)
    let metadata = try await downloadMetadata(for: imageNumber)
    return DetailedImage(image: image, metadata: metadata)
}

func downloadImage(imageNumber: Int) async throws -> UIImage {
    let imageUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(imageNumber).png")!
    let imageRequest = URLRequest(url: imageUrl)
    let (data, imageResponse) = try await URLSession.shared.data(for: imageRequest)
    guard let image = UIImage(data: data), (imageResponse as? HTTPURLResponse)?.statusCode == 200 else {
        throw ImageDownloadError.badImage
    }
    return image
}

func downloadMetadata(for id: Int) async throws -> ImageMetadata {
    let metadataUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(id).json")!
    let metadataRequest = URLRequest(url: metadataUrl)
    let (data, metadataResponse) = try await URLSession.shared.data(for: metadataRequest)
    guard (metadataResponse as? HTTPURLResponse)?.statusCode == 200 else {
        throw ImageDownloadError.invalidMetadata
    }

    return try JSONDecoder().decode(ImageMetadata.self, from: data)
}

関数をasyncとしてマークする限り、これは可能です。

この直線性に注意することが重要です。メタデータと画像は同時にダウンロードされません。
最初に画像をダウンロードし、メタデータは後でダウンロードします。
画像とメタデータの両方を同時にダウンロードさせることもできますが、
この記事ではまだ実際の並行処理については触れません。
構造化された並行処理について学ぶときに、両方のタスクを同時に行う方法を探ります。

ファンクション・サスペンションを実際に見てみたい場合は、awaitコードの前後にprint文を記述してください。print文はゆっくりと実行され、システムがダウンロードタスクを中断し、
他のタスクを実行し、制御をあなたに戻すのがわかるでしょう。

func downloadImageAndMetadata(imageNumber: Int) async throws -> DetailedImage {
    print("Will download image")
    let image = try await downloadImage(imageNumber: imageNumber)
    print("Has downloaded image")
    print("Will download metadata")
    let metadata = try await downloadMetadata(for: imageNumber)
    print("Has downloaded metadata")
    return DetailedImage(image: image, metadata: metadata)
}

もしあなたのインターネットがちょっと速すぎて、スロープリントのありがたみが分からないのであれば、
アップルは素敵な方法を提供してくれます:Task.sleepです。
この関数は、指定された時間だけスレッドをスリープさせるために存在し、
async/awaitを探索するために使うことができます。

注意:残念ながら、Xcode 13 Beta 1の時点でTask.sleepはクラッシュするようです。
await Task.sleep(2 * 1_000_000_000) .

awaitに関する最後の重要な注意点:上のコードを実行したスレッドと下のコードを実行するスレッド
(一般に継続と呼ばれる)が同じであることは保証されません。これはUIを扱うときに重要な意味を持ちます。ViewController のようなメインスレッドが必要なコンテキストで await を使用する場合は、
await を使用する関数を @MainActor 属性でマークするか、
クラス宣言全体にその属性を追加してください。
Swift で新しい並行処理がどのように動作するかの完全なツアーが必要な場合は、
Swift concurrency: Behind the scenes WWDC2021 session talk
(https://developer.apple.com/videos/play/wwdc2021/10254/)を確認してください。

タスクで同期と非同期の世界を "橋渡し"

同期と非同期の世界をつなぐ「橋渡し」をするのがタスクです。
なぜこれが必要なのかを理解するために、次のコードを考えてみましょう。

func performDownload() {
    let imageDetail = try? await downloadMetadata(for: 1)
}

コンパイラーは、次のようなエラーを表示し、誤ってこれを実行しないように保護してくれています。

'async'呼び出しが同時実行をサポートしていない 関数 'performDownload()'に
'async'を追加して非同期にする。

コンパイラーは、performDownloadをasyncとしてマークすることを示唆しています。

func performDownload() async {
    let imageDetail = try? await downloadMetadata(for: 1)
}

しかし、これは常に可能というわけではありません。
performDownloadがビューコントローラーの中にあったり、
非同期コンテキストを提供できない別の場所にあったりしたらどうでしょう?

これを解決するには、Task {}を使ってこの同期関数を非同期の世界に橋渡しします。

func performDownload() {
    Task {
        let imageDetail = try? await downloadMetadata(for: 1)
    }
}

明示的に非同期コンテキストを作成しているので、そのように動作する。
これで、どの同期コンテキストからでも問題なくダウンロードの実行を呼び出せるようになりました。

get async

さらに良いことに、読み取り専用のプロパティは待機させることができます。

次のようなラッパー・オブジェクトがあるとします。

struct Character {
    let id: Int
}

downloadImageAndMetadataを呼び出すことで、その画像とメタデータを取得することができますが、
このオブジェクトに2つの計算プロパティを与えて、画像やメタデータを個別に取得することもできます。

struct Character {
    let id: Int

    var metadata: ImageMetadata {
        get async throws {
            let metadata = try await downloadMetadata(for: id)
            return metadata
        }
    }

    var image: UIImage {
        get async throws {
            return try await downloadImage(imageNumber: id)
        }
    }
}

そして、このように使うことができます。

let metadata = try? await character.metadata

まとめ

async/awaitは新しいconcurrencyシステムの心臓部なので、しっかり理解しておく必要があります。
今後の記事はそれほど長くならないかもしれません。一般的に、何かの基本をカバーすることは、
細部を見逃さないことが重要なので、多くの労力を必要とします。この記事が役に立つことを願っています。

ダウンロードした画像とメタデータをUIKitプロジェクトで利用するサンプルプロジェクトを作成しました。
こちらからダウンロードできます。

実行すると、このように単純にコンテンツをダウンロードして表示します。

viewDidAppearメソッドには以下のコードがあります。

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    // MARK: METHOD 1 - Using Async/Await

    Task {
        if let imageDetail = try? await downloadImageAndMetadata(imageNumber: 1) {
            self.imageView.image = imageDetail.image
            self.metadata.text = "\(imageDetail.metadata.name) (\(imageDetail.metadata.firstAppearance) - \(imageDetail.metadata.year))"
        }
    }

    // MARK: METHOD 2 - Using async properties

//        Task {
//            let character = Character(id: 1)
//            if
//                let metadata = try? await character.metadata,
//                let image = try? await character.image{
//                imageView.image = image
//                self.metadata.text = "\(metadata.name) (\(metadata.firstAppearance) - \(metadata.year))"
//            }
//        }

    // MARK: Method 3 - Using Callbacks

//        downloadImageAndMetadata(imageNumber: 1) { imageDetail, error in
//            DispatchQueue.main.async {
//                if let imageDetail = imageDetail {
//                    self.imageView.image = imageDetail.image
//                    self.metadata.text =  "\(imageDetail.metadata.name) (\(imageDetail.metadata.firstAppearance) - \(imageDetail.metadata.year))"
//                }
//            }
//        }
}

MARK: - メソッド x の下で、データを取得するさまざまなメソッドによって提供されるデータでアウトレットを
埋めるために、すべてをコメントしたりコメント解除したりすることができます。うまくいけば、
Swiftで非同期/待ち受けがどのように動作するかをよりよく知るために、これで遊ぶことができます。

私は以前に行ったこれらの2つのポイントを再訪したいです。

  1. 手続き型コードは上から下へ実行される。制御はreturnを通じて呼び出し元に戻されます。
  2. コールバック・ベースのconcurrencyは、非同期タスクを作成しますが、
    それらのタスクが実行中であっても、問題なく現在のスレッドの実行を継続します。
    制御は完了ハンドラを通じて呼び出し元に戻されます。

これでもうひとつ、要約を加えることができます。

3 async/awaitは手続き型プログラミングと同じように順番に実行されます。
await呼び出しが見つかると、ジョブは一時停止し、呼び出し元の代わりにシステムに制御を戻します。
コールバックベースのconcurrencyとは異なり、ジョブが終了するまでその下のステートメントの実行を
継続することはありません。システムはそのスレッドを使用して他の作業を行い、
あなたの関数を再訪する時が来たと判断すると、そのスレッドは実行され、実行はリニアに再開されます。

準備ができたら、継続、明示的な継続、クロージャベースとデリゲートベースのコードを
async/awaitに橋渡しする方法についての詳細を学ぶために、
シリーズの3番目の記事、Swiftでクロージャベースのコードをasync/awaitに変換する、
に進むことができます。

【翻訳元の記事】

Understanding async/await in Swift
https://www.andyibanez.com/posts/understanding-async-await-in-swift/

Discussion