📚

【翻訳】Structured Concurrency in Swift: Using async let

2023/07/15に公開

async/awaitを理解することは、この記事を読むための前提条件です。
もしその概念に馴染みがないのであれば、この記事シリーズの最初の部分を読んでください。
Understanding async/await in Swift
(https://www.andyibanez.com/posts/understanding-async-await-in-swift/)

async/awaitは、Swiftの新しい同時実行システムに関して最も重要な概念です。
async/awaitを理解することで、きれいな構文とわかりやすいコードで
複数のタスクを並行して実行するための扉を開くことができます。

実際にこれを行うには複数の方法がありますが、AppleがWWDC2021でSwift 5.5で私たちに与えた方法は、
最も安全な方法であり、非常に特殊なニーズがない限り、おそらくほとんど独占的に使用する方法でしょう。

構造化された同時実行の導入

以前の記事で、コールバック・ベースのコードを並行コンテキストで使用すると、
管理が面倒になることを説明しました。
そのためアップルは、コードの直線的な流れを維持しながら同時並行コードを書くのに役立つ
キーワード群であるasync/awaitを与えてくれた。このコードは上から下に読むことができます。
しかし、Understanding async/await in Swift
(https://www.andyibanez.com/posts/understanding-async-await-in-swift/)では、async/awaitを使用するだけでは、一度に複数のタスクを実行することを意味しないことを指摘しました
(私たちが呼び出すタスクは内部的にそうするかもしれませんが)。
これから、いくつかのコードを並列に実行し始め、構造化された同時実行の概念から始めます。

構造化並行処理の背後にある考え方は、構造化プログラミングと同じ考え方に基づいています。
私たちは構造化されたコードを書くことがほとんどなので、構造化について考えることはありません。
構造化されたコードは、出力が予測可能で、コードが正確に与えられた順序で実行されるように、
直線的な流れに沿って、上から下へと読むことができます。
変数を使用する場合、変数は宣言されたブロック内で明確に定義された有効期間を持ちます。
コールバック・ベースの並行処理では、メイン・スレッドが実行され続けている間に、
異なるスレッドやコンテキストでタスクが実行されます。
Objective-Cを記述している場合、ブロック内で変数を変更するには、
変数を__ブロックとして扱う必要があります。
これは、あなたが望む結果を得るために、すべてがどのような順序でも起こりうるコードの迷宮を作り出します。

では、次の関数を考えてみよう:

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)
}

//...

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
}

downloadImageAndMetadataは、画像をそのメタデータとともにダウンロードする関数で、
すべてDetailedImageオブジェクトに包まれています。
ダウンロードを実行するには、画像そのものをダウンロードするdownloadImage関数と、
メタデータをダウンロードするdownloadMetadata関数を呼び出します。
downloadImageAndMetadataをもう少し詳しく調べてみましょう。

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)
}

ダウンロードは順次行われます。この関数は最初に画像をダウンロードし、後でメタデータをダウンロードします。これは多くの場合素晴らしいことですが、タスク同士が依存関係を持たず、同時実行が可能な場合もあります。

この例では、画像のダウンロードとメタデータのダウンロードは独立した2つのタスクなので、
関数を少しプッシュして両方を同時にダウンロードすることで、より早く終了させることができます。

次に進む前に、クロージャベースのコードでこれを行う方法を考えてみましょう。
まず、2つのURLSessionデータタスクを起動し、それぞれに完了ハンドラを持たせる必要があります。
しかし、それからどうするのでしょうか?タスクはどうやってこのタスクの完了を調整するのでしょうか?
画像が先にダウンロードされたらどうなるのか?メタデータが先に終了したらどうなるのか?
どうやって "ロック "してアクセスを保証し、最後の完了ハンドラが呼ばれるようにするのか?

実際、このタスクを純粋なクロージャベースのコードでやると(デリゲートベースのコードでも)、
すぐにかなり面倒なことになります。しかも、たった2つのタスクを同時に処理するだけです!

Swiftでは、構造化された同時実行を扱う2つの方法があります:

async let
Task groups

この記事ではasync letに限定しますが、将来の記事でTaskグループを取り上げます。

tasksを理解する

tasksは、Swift がコードを並列に実行する基本的なメカニズムです。
各タスクは、他のタスクと並行して実行できる新しい非同期コンテキストを提供します。
それらは、そうすることが安全で効率的である限り、自動的に並列に実行されます。

downloadImageAndMetadata関数は、実際にはタスクを生成しません。
両方のダウンロードは待機状態であり、これが並列に実行されない理由である。これを解決します。

これらの新しい並行処理機能はSwiftに深く統合されているので、並行処理コードを書いていくうちに、
コンパイラは並行処理でよくあるバグを発生させないようにしてくれます。
コンパイラのエラーとして報告されるため、新人プログラマにとってはイライラするかもしれませんが、
実際には、Swiftはおかしなことをしないように、あなたとあなたのコードを守るために最善を尽くしています。
結局のところ、並行処理は解決するのが非常に難しい問題です。
もしあなたがオペレーティングシステムの本を読んだことがあるなら、
おそらく開発者が安全な並行コードを書くために利用できる複数のパターンがあることを見たことがあるでしょう。
しかし、このコードを手作業で書くのは大変で、エラーが起こりやすく、文脈によってはテストも難しい。
コンパイル時にこれらのチェックを行うことは、素晴らしいセキュリティ機能です。

どちらかというと、デフォルトでは、コンパイラーは非同期とマークされた関数を見ると、
呼び出しのたびに待ち状態になることを期待します。タスクの作成は自動的に行われるわけはありません。
並行コードを実行したいことをコンパイラーに伝えることはできますが、
あなたの要求を尊重するかどうかはコンパイラー次第です。タスクは常に明示的に作成されます。

構造化された並行処理とは、単純さと柔軟性のバランスをとることである。
この制約の下で、すべてではないにせよ、多くの並行処理を行うことができるでしょう。
しかし、さらに柔軟性が必要な場合は、必要な制御を与えてくれるより低レベルのAPIが見つかるでしょうが、
安全性は低いということを常に覚えておいてほしい。
Multithreading Options on Apple Platforms
(https://www.andyibanez.com/posts/multithreading-options-on-apple-platforms/)で、代替手段の概要を確認してください。

async letの導入

並行バインディングとも呼ばれるasync letを使うと、タスクが並行して起動する。

async let result = //... an async function call (without await)

Swiftがasync letを見つけると、等号の右側の関数は同時実行を開始します。
つまり、await呼び出しがそこでプログラムの実行を一時停止するのに対し、
async letはタスクを起動しますが、その値が必要になるまで、その下のコードを実行し続けます。

次の例を考えてみよう:

func downloadImageConcurrentlyWhilePrinting(imageNumber: Int) async throws -> UIImage {
    print("One lint prints")
    print("We will begin downloading now")
    async let image = downloadImage(imageNumber: imageNumber)
    print("Another line prints until we have the image")
    print("Keep on printing")
    return try await image
}

すべてのprint文が基本的に即座に実行されていることに気づくでしょう。
これは、async letがdownloadImageを別のタスクとして起動したためです。
async let呼び出しの前にある2つのprint文は、期待通りに実行されます。
downloadImageは待ち呼出しではないので、他のprint文も同じようにすぐに印刷される。
return try await imageに到達するまでに、return文で画像のダウンロードが終わるまで
(あるいはエラーが投げられたら)中断するようプログラムに指示していることになります。

これはコードの同時実行を可能にする仕組みの1つなので、
任意の時点で複数のasync letコールを持つことができ、
システムは可能であればそれらを同時に実行します。

これで、downloadImageAndMetadata関数を書き換えて、
画像とメタデータを同時にダウンロードできるようになりました。

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

注:この記事のほとんどのセッションは、
Explore structured concurrency in Swift(https://developer.apple.com/videos/play/wwdc2021/10134/)
に基づいていて、これと似たような例を使っている。

letの前にasyncを追加し、awaitキーワードを値が存在すると予想される場所に移動することで、
構造化フローを使用して、一度に複数のものをダウンロードすることに成功しました。実に素晴らしい!

以上です。これが、新しいasync/await APIを使ってコードを同時実行する方法です。
しかし、この記事はまだ終わっていません。
その前に、非常に重要な概念を探る必要があります。タスク・ツリーです。

タスクツリー

構造化並行処理では、タスク・ツリーと呼ばれる概念を利用します。
タスク・ツリーとは、構造化並行処理コードが実行される階層のことです。
タスク・ツリーは、キャンセル、優先度、ローカル変数といったタスクの属性に影響を与えます。
ある非同期関数から別の関数にジャンプするとき、同じタスクが新しい呼び出しの実行に使われる。

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

downloadImageAndMetadataを呼び出すと、親タスクからすべての属性を継承します。
async letを呼び出すたびに、新しいタスクが作成されます。
このタスクは、現在の関数が実行されているタスクの子タスクです。

downloadImageAndMetadata関数は、2つの子タスクにまたがる可能性があります。
1つは画像用、もう1つはメタデータ用で、
これらのコードはすべて(やはり潜在的に)同時に実行されることになります。

downloadImageAndMetadataは実行中のタスクの属性を継承し、downloadImageとdownloadMetadataはdownloadImageAndMetadataのプロパティを継承します。

タスクのライフタイムは関数と結びついているかもしれませんが、
タスクは実行中の関数の子ではないことに注意することが重要です。

タスクツリーは非常に重要なルールを強制します。
親タスクは、すべての子タスクが処理を終了している間のみ、その処理を終了することができます。

awaitコールは、実行続行の許可が下りるまで実行を続行させないので、
このような強制が行われていることが分かります。
downloadImageもdownloadMetadataも、エラーを投げるか値を返すかもしれませんが、
どちらの場合でも、それらを必要とするコードが実行を続行する前に作業を終了します。

downloadImageAndMetadataの通常のケースは、
downloadImageとdownloadMetadataの両方が正常に終了することです。
しかし、一方がエラーを投げ、もう一方が問題なく終了したらどうなるでしょうか?

素晴らしいのは、直感的に、そしてコードが構造化されていて上から下へと実行されるおかげで、どちらかが
エラーを投げるたびに、downloadImageAndMetadataも同じエラーを投げることがわかることです。
しかし、他のタスクの実際の実行はどうなるでしょうか?
つまり、downloadMetadataが失敗し、downloadImageが大きな画像をダウンロードしているとする。
画像のダウンロードはどうなるのか?

タスクが失敗したとき、Swiftは残りの子タスクをキャンセルとしてマークします。
この例では、downloadMetadataが失敗したので、downloadImageはキャンセルされたとマークされます。タスクをキャンセルとしてマークすることは、実際にタスクがキャンセルされたことを意味しません。
その代わり、そのタスクの結果が不要になったことを知らせるだけです。
すべての子タスクとその子孫タスクは、親タスクがキャンセルされたときにキャンセルされます。

しかし、タスクが実際に実行を停止するのはいつだろうか?これは構造化タスクの巧妙な特性です。
タスクはすぐには停止しません。その代わり、適切と判断されればすぐに実行します。
ネットワーク通話がある場合、キャンセル通知を受け取った瞬間に通話を停止するのは不適切かもしれない。

タスクは明示的にキャンセルをチェックしなければなりません。キャンセルのチェックはどこからでもできます。
このため、特に完了までに非常に長い時間がかかるタスクがある場合は、
キャンセルを考慮してコードを設計する責任があります。

キャンセルをチェックする方法は2つある。1つ目は、関数がthrowsとマークされているときに
try Task.checkCancellation()を呼び出す方法です。そしてもう1つは、タスクが
throwingコンテキストの中で実行されていない場合に真偽値を返すTask.isCancelledがあります。

func downloadImage(imageNumber: Int) async throws -> UIImage {
    try Task.checkCancellation()
    let imageUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part3/\(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 {
    try Task.checkCancellation()
    let metadataUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part3/\(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)
}

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

// NEW FUNCTION
func downloadMultipleImagesWithMetadata(images: Int...) async throws -> [DetailedImage]{
    var imagesMetadata: [DetailedImage] = []
    for image in images {
        print(image)
        async let image = downloadImageAndMetadata(imageNumber: image)
        imagesMetadata +=  [try await image]
    }
    return imagesMetadata
}

上の例では、downloadImageとdownloadMetadataの最初にキャンセルチェックを追加しました。
また、複数の画像のダウンロードを試みる関数も追加しました(ただし同時実行ではありません -
タスクグループについて説明するときに、可変数の同時実行タスクの実行方法を学びます)。
画像やメタデータのダウンロードに失敗すると、子タスクにキャンセルが通知され、キャンセルするチャンスが
あれば、つまり画像やメタデータのダウンロードを開始していなければ、子タスクは実行を停止します。

要約

新しいasync/await APIを使った実際の並行実行の世界をようやく探求し始めました。
構造化並行処理とは何か、そしてasync letでそれを実装する方法を学びました。
また、タスクツリーについて、そしてキャンセルがどのように協調的で、
どのように機能するかについても学びました。

最新の関数 downloadMultipleImagesWithMetadata は、
3つの画像を同時にダウンロードしないことにお気づきかもしれません。
可変数のタスクを同時に実行する方法については、タスク・グループについて説明するときに説明します。

それまでの間、この記事の内容をじっくりと分析してください。
いつものように、この記事のコンセプトをよりよく理解するために、サンプル・プロジェクトで遊んでみてください。

【翻訳元の記事】

Structured Concurrency in Swift: Using async let
https://www.andyibanez.com/posts/structured-concurrency-in-swift-using-async-let/

Discussion