🕌

【翻訳】Structured Concurrency With Task Groups in Swift

2023/07/15に公開

Structured Concurrencyとasync letを理解することは、この記事を読むための前提条件です。
もしその概念に馴染みがないのであれば、この記事シリーズの第3部を気軽に読んください。
Beginning Concurrency in Swift: Structured Concurrency and async-let
(https://www.andyibanez.com/posts/structured-concurrency-with-group-tasks-in-swift/)

タスクグループは、Swiftの構造化された並行処理の2番目の形式です。
私たちがasync letを探求したとき、1つの特別な制限に気づきました。
可変数のタスクを同時に実行することはできません。
なぜなら、ループなどで実行しようとすると、結果を待つ必要があるからです。
これによって例えば、一度に複数の写真をダウンロードすることを可能にしないといったことが起こります。

可変数のタスクを実行するために、SwiftはTask Groupsを提供します。

Task Groups

Task Groupsは、構造化された並行処理のシンプルさを放棄することなく、
async letよりも柔軟性を提供する。

タスク・グループは構造化並行処理の一形態で、動的な並行処理を提供するように設計されています。
これを使えば、複数のタスクを起動し、グループで起動し、同時に実行することができます。

タスク・グループを起動するには2つの方法がある:

・ withThrowingTaskGroupを呼び出す。
・ withTaskGroupを呼び出す。

この記事シリーズで何度も見てきたように、エラーを投げる可能性のあるタスクのためのvariantと、
投げないタスクのためのvariantがあります。
グループに追加されたタスクは、そのグループが定義されたブロックのスコープを超えることはできません。
子タスクがグループに追加されると、どのような順番でもすぐに実行を開始するので、
子タスクに依存関係がないようにコードを設計するように注意してください。
グループがスコープ外に出ると、グループ内のすべてのタスクの完了が暗黙のうちに待たされます。

構造化された並行処理によって、グループ内にasync letタスクを作成したり、
逆にasync let呼び出しの中でタスクグループを起動したりすることができます。

タスク・グループ内で変数を変更しようとすると、次のようになる:

func downloadMultipleImagesWithMetadata(images: Int...) async throws -> [DetailedImage]{
    var imagesMetadata: [DetailedImage] = []
    try await withThrowingTaskGroup(of: Void.self, body: { group in
        for image in images {
            group.async {
                async let image = downloadImageAndMetadata(imageNumber: image)
                imagesMetadata +=  [try await image]
            }
        }
    })
    return imagesMetadata
}

このコードは、
Beginning Concurrency in Swift: Structured Concurrency and async-let
(https://www.andyibanez.com/posts/structured-concurrency-with-group-tasks-in-swift/)で書かれたものの変形です。
この例は、Explore structured concurrency in Swift
(https://developer.apple.com/videos/play/wwdc2021/10134/)WWDC2021 talk
に基づいています。

コンパイラーは、
imagesMetadataが複数のタスクから同時に安全にアクセスされない可能性があることに気づくでしょう。
これは、複数の変数が同時に書き込もうとするため、データの破損につながります。
幸運なことに、新しい並行性APIがSwift自体に深く統合されているという事実のおかげで、
コンパイラはいくつかのチェックを静的に行うことができ、そのようなデータ競合の発生を防ぐことができます。

これをコンパイルしようとすると、コンパイラは次のようなエラーを出します。

同時実行コードでキャプチャされたvar 'imagesMetadata'の変異

では、Swiftは具体的にどのようにこれらのチェックを行うことができるのでしょうか?

Sendableクロージャ型

データレースの安全性を導入するために、Swift は @Sendable クロージャの概念を実装しています。

タスクを作成するたびに、本体は@Sendableクロージャであり、このクロージャは以下のプロパティを持ちます。

ミュータブル変数をキャプチャできません。
取り込めるのは、値型、アクター、クラスなど、独自の同期を実装しているオブジェクトだけです。
アクターについては今後の記事で説明します。
この知識を頭に入れておけば、上記のタスク・グループを修正することができます。withThrowingTaskGroupまたはwithTaskGroupでタスク・グループを作成すると、
タスク・グループは並行タスクが作成する戻り値の型をパラメータとして受け取ります。

func downloadMultipleImagesWithMetadata(images: Int...) async throws -> [DetailedImage]{
    try await withThrowingTaskGroup(of: DetailedImage.self, body: { group in
        for image in images {
            group.async {
                async let image = downloadImageAndMetadata(imageNumber: image)
                return try await image
            }
        }
    })
}

私たちの方法の実装はまだ完全ではないが、
一歩一歩進んでいくうちに、いくつかの重要な修正が加えられてきた:

withThrowingTaskGroupのパラメータは、
DetailedImagesを受け取ることを指定するようになりました。
配列に追加する代わりに、
group.asyncはループの各実行で待機中のDetailedImageを返すようになりました。
基本的に、エラーが発生しない限り、最終的に返すDetailedImageで「グループを満たす」ことになります。
エラーが発生した場合、子タスクはキャンセルされ、タスクを停止する必要があります。
SwiftでBeginning Concurrency in Swift: Structured Concurrency and async-let
(https://www.andyibanez.com/posts/structured-concurrency-with-group-tasks-in-swift/)から、
コードを設計するときにキャンセルを念頭に置く責任があることを思い出してください。

グループ変数は ThrowingTaskGroup<DetailedImage, Error> 型です。
そして驚いたことにこれはコレクションです!
これを反復処理したり、filter、map、reduceなどの関数型プログラミングを適用することができます。

func downloadMultipleImagesWithMetadata(images: Int...) async throws -> [DetailedImage]{
    var imagesMetadata: [DetailedImage] = []
    try await withThrowingTaskGroup(of: DetailedImage.self, body: { group in
        for image in images {
            group.async {
                async let image = downloadImageAndMetadata(imageNumber: image)
                return try await image
            }
        }
        for try await image in group {
            imagesMetadata += [image]
        }
    })
    return imagesMetadata
}

for try awaitの部分は、あなたを混乱させるかもしれません(ダジャレではないです)。
しかし、Swift 5.5で導入されたすべての新しい同時実行APIと並んで、
私たちは新しいAsyncSequence型を持っています。
このプロトコルは、時間をかけて値を受け取る型によって実装されます。
私たちの downloadMultipleImagesWithMetadata 関数では、group.async を使用して、
動的な数の DetailedImage のダウンロードを開始します。
ダウンロードが終了すると、for inループに画像が1枚ずつ配信されるため、
その中で変数を変更することなどが安全になります。

forループ内のawaitは、他のawait呼び出しと同じように動作することに注意してください。
その時点に達すると実行は中断され、新しい画像が配信されると実行が続行されます。
for-inループの下に何かあると、グループ内のすべての要素がfor-inループを通過するまで
実行されないので、覚えておくとよいでしょう。
3つの画像をダウンロードしたい場合、3つの画像が同時にダウンロードされる可能性がありますが、
forループは一度に1つずつしか配信しません。
かなりの数の画像をダウンロードする場合、for-in以下を実行する前に、
すべての画像をダウンロードする必要があります。
これはまた、エラーが発生した場合、for-inの実行が停止することを意味します
(最初のダウンロードに失敗した場合は、実行されないこともあります)。

AsyncSequenceについては、今後の記事で詳しく説明します。

タスク・グループを扱う場合、実際にはもう少し柔軟性があります。
例えば、与えられた優先順位でタスクを非同期に起動することができます。

group.async(priority: .userInitiated) {
//...
}

priorityはTask.Priority型である。このメソッドにはasyncUnlessCancelledメソッドもあり、
オプションで優先度も指定できます。

group.asyncUnlessCancelled(priority: nil) {
   //...
}

最後に、グループ内でcancellAll()を呼び出すこともできます。キャンセルはツリーを伝搬します。

async letと比較すると、小さな違いがあることに注意してください。通常の終了によってグループが
スコープ外に出たとき、タスクのキャンセルは暗黙の了解ではありません。代わりに待ち状態になります。
これは、他のタスクが終了するまでの時間を与えるためと、Fork-Joinモデル(https://en.wikipedia.org/wiki/Fork–join_model)を表現するためです。
Fork-Joinモデルとは、基本的に「分割して征服する」ことであり、私たちの場合は、
できるだけ多くの子タスクで複数の画像をダウンロードすることです。

要約

この記事では、タスクグループを使用することで、
Swiftで構造化された並行処理を行う別の方法を学びました。
タスクグループは、ループ内で複数の画像をダウンロードする必要がある場合など、
動的な並行処理を実行することを可能にします。
AsyncSequenceと、それがどのように時間をかけて
結果を提供するためにタスクグループと一緒に使用されるかについて簡単に触れました。

このシリーズの伝統として、ここに小さなサンプル・プロジェクトをダウンロードできます。
UIは最初のいくつかのプロジェクトと同じものですが、コンパイル可能なバージョンのdownloadMultipleImagesWithMetadataを手に入れることができます。

この記事で、Swiftで構造化された並行処理を行うための2つの方法の探求を終えました。

基本的な並行処理のサポートが必要な場合は、async letを使用します。
動的な同時実行タスクがある場合は、タスク・グループを使用します。

次回の記事では、非構造化並行処理を探求し始めます。
それは難しく聞こえるかもしれませんが、
Swiftは構造化された並行処理と同じように簡単に扱うことができます。

【翻訳元の記事】

Structured Concurrency With Task Groups in Swift
https://www.andyibanez.com/posts/structured-concurrency-with-group-tasks-in-swift/

Discussion