🙆

【翻訳】Introduction to Unstructured Concurrency in Swift

2023/07/15に公開

Swift の構造化された同時実行を理解することは、この記事を読むための前提条件です。

その概念に馴染みがない場合は、

Beginning Concurrency in Swift: Structured Concurrency and async-let(https://www.andyibanez.com/posts/structured-concurrency-in-swift-using-async-let/)

Structured Concurrency With Group Tasks in Swift
(https://www.andyibanez.com/posts/structured-concurrency-with-group-tasks-in-swift/)

を自由に読んでください。

これまでのところ、Swift 5.5で導入された新しいAPIを使った構造化並行処理の探求に
焦点を当ててきました。
構造化された同時実行は、私たちのプログラムで直線的なフローを維持し、
フォローしやすいタスクの階層を維持するために素晴らしいものです。
構造化された同時実行は、タスクのキャンセルを軌道上に維持し、
エラー処理を同時実行がない場合と同じように明白にすることに大いに役立ちます。
構造化並行処理は、コードを読みにくくすることなく、さまざまなタスクを一度に実行できる優れたツールです。

非構造化並行処理の導入。

構造化された同時実行は本当に便利ですが、
タスクに構造化されたパターンがまったくない場合も(できれば少数派であってほしいですが)あるでしょう。
このような場合、非構造化並行処理を活用することで、
シンプルさと引き換えに、より多くの制御を行うことができます。
素晴らしいニュースは、Swift 5.5がシンプルさの多くを犠牲にすることなく、
これを行うためのツールを提供してくれることです。
これの一つの例は、ユーザーに画像をダウンロードする能力を与えるだけでなく、
ダウンロードをキャンセルするオプションを与えることです。

非構造化並行処理を使用する必要性を感じる状況もあるでしょう。

非同期コンテキストからのタスクの起動。タスクはそのスコープより長く起動できる。
親タスクの情報を継承しないタスクの切り離し。

この記事では、前者に焦点を当てます。

非同期コンテキストからのタスク起動

今回はタスク{}ブロックについて詳しく説明します。
async/awaitの話を始めたときに、タスクでawaitする必要があるときは
asyncコンテキストの中にいる必要があると述べたのを思い出してください。
シグネチャにasyncとある関数の中であれば問題なく、特別なことをしなくてもawaitできます。

問題は、AppleのSDKが最初から並行処理をサポートするように設計されていないことです。
UIKitを例にとってみましょう。
viewDidAppearなど、ビューコントローラのライフサイクルの一部となるメソッドは、
どれもasyncとしてマークされていません。
非同期タスクに対して並行処理や単純な待ちを行う必要がある場合は、
Taskブロックを使わない限りできません。

Understanding async/await in Swift(https://www.andyibanez.com/posts/understanding-async-await-in-swift/)
について話したとき、私たちは実際にこれを行いました。
元記事を読んでいない方のために補足しておくと、その記事の最後にはこんなコードになっていました。

// MARK: - Definitions

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

//...

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

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

上のコードでは、待ち受け可能なメソッドがいくつかあります。
それらをviewDidAppearから呼び出したいのですが、
viewDidAppearは関数シグネチャの一部にasyncを持っていないので、直接呼び出すことはできません。
代わりに、asyncを使ってasyncコンテキストを作成し、その中でawaitする必要があります。

この方法は興味深い。まず、Task {}は実際に明示的なタスクを生成します。
次に、これは新しいタスクを起動するので、Task {}ブロックの下にあるものは、
Task {}ブロックの中にあるものと並行して実行され続けます。

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

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

        async {
            if let imageDetail = try? await downloadImageAndMetadata(imageNumber: 1) {
                print("Image downloaded")
            }
        }

        print("Continue execution alongside the async block")
    }
}

これを実行すると、出力がこうなっていることに気づくでしょう。

Continue execution alongside the async block
Image downloaded

リニア・コードの実行は、ネットワークから何かをダウンロードするよりもはるかに速いので、
実行するたびにこの出力を受け取ることがほぼ保証されています。
複数のTask {}ブロックがある場合、それぞれで非同期タスクを起動していることになります。

最後に(ここが一番面白いところだが)、この方法でTaskを使うと、
実際にTask<T, Error>型のハンドルが返ってきます。
後でこのハンドルをどこかに保存して、明示的にタスクをキャンセルしたり、
結果を待ったりするのに使うことができます。

ここが「構造化されていない」部分の出番です。どこかでタスクを開始し、
まったく関係のない場所からそれをキャンセルすることができます。

例えば、ボタンをタップしたところからダウンロードを始めることができます。

// You can get the full code at the end of the article.
class ViewController: UIViewController {
// ...
var downloadAndShowTask: Task<Void, Never>? {
    didSet {
        if downloadAndShowTask == nil {
            triggerButton.setTitle("Download", for: .normal)
        } else {
            triggerButton.setTitle("Cancel", for: .normal)
        }
    }
}

func downloadAndShowRandomImage() {
    let imageNumber = Int.random(in: 1...3)
    downloadAndShowTask = async {
        do {
            let imageMetadata = try await downloadImageAndMetadata(imageNumber: imageNumber)
            imageView.image = imageMetadata.image
            let metadata = imageMetadata.metadata
            metadataLabel.text = "\(metadata.name) (\(metadata.firstAppearance) - \(metadata.year))"
        } catch {
            showErrorAlert(for: error)
        }
        downloadAndShowTask = nil
    }
}

// Inside ViewController
@IBAction func triggerButtonTouchUpInside(_ sender: Any) {
    if downloadTask == nil {
        // If we have no task going, we have now running task. Therefore, download.
        Task {
            await downloadAndShowRandomImage()
        }
    } else {
        // We have a task, let's cancel it.
        cancelDownload()
    }
}

// ...
}

また、ユーザーがダウンロードを希望する場合には、ダウンロードをキャンセルします。

func cancelDownload() {
    downloadAndShowTask?.cancel()
}

このプログラムには、downloadAndShowTaskの値が変化すると
ラベルが変化するtriggerButtonが含まれています。
nilのときは、タスクが実行されていないので、ボタンを使って画像をダウンロードします。
そうでなければ、ボタンを使ってアクションをキャンセルします。

downloadAndShowTaskはTask<Void, Never>型です。タスク自体は何も返さず、
エラーも投げないからです。このボタンは画像をダウンロードし、ラベルを設定します。

画像をダウンロードするだけで、直接処理する必要がない場合は、
特定の値を返すようにタスクを定義するとよいでしょう。

次の例はもっと複雑ですが、Task {}構造化されていないタスクの柔軟性を示しています。

まず、ビューコントローラの宣言に @MainActor を追加します。
メインスレッド以外のスレッドがビューコントローラの値にアクセスしたくなる可能性があります。

@MainActor
class ViewController: UIViewController //...

次に、downloadAndShowTaskをdownloadTaskに変更し、シグネチャをTask<DetailedImage, Error>に変更します。これにより、DetailedImageで待機したり、
必要に応じてタスク内からエラーをスローしたりできるようになります。

var downloadTask: Task<DetailedImage, Error>? {
    didSet {
        if downloadTask == nil {
            triggerButton.setTitle("Download", for: .normal)
        } else {
            triggerButton.setTitle("Cancel", for: .normal)
        }
    }
}

次に、画像のダウンロードを開始し、それをdownloadTaskハンドルに保存する新しいメソッドbeginDownloadingRandomImageを作成します。その間に、アウトレットを適宜更新するコードを作成します。

func beginDownloadingRandomImage() {
    let imageNumber = Int.random(in: 1...3)
    downloadTask = Task {
        return try await downloadImageAndMetadata(imageNumber: imageNumber)
    }
}

func showImageInfo(imageMetadata: DetailedImage) {
    imageView.image = imageMetadata.image
    let metadata = imageMetadata.metadata
    metadataLabel.text = "\(metadata.name) (\(metadata.firstAppearance) - \(metadata.year))"
}

downloadAndShowRandomImageの実装を更新し、2つの新しい関数を利用できるようにします。

func downloadAndShowRandomImage() async {
    beginDownloadingRandomImage()
    do {
        if let image = try await downloadTask?.value {
            showImageInfo(imageMetadata: image)
        }
    } catch {
        showErrorAlert(for: error)
    }
    downloadTask = nil
}

このメソッドはbeginDownloadingImageを呼び出し、その中でdownloadTaskに値を代入します。
そして、downloadTask?.valueはダウンロードが完了すると画像を返してくれます。

cancelDownloadはいつもと同じです。いつでもダウンロードをキャンセル(開始)できます。

この方法で作成されたタスクは、優先度、ローカル値、アクターも継承します。
これらのタスクはスコープを超えることができるため、そのライフタイムをよりコントロールできるようになります。

要約

ここまで、asyncが実際に何をするのかを探ってきました。
タスク {} ブロックを使って明示的なタスクを作成し、
後で手動で明示的にキャンセルできるようにしました。
タスク {} ブロックを使って、何らかの構造を持たない並行処理を行うことができます。
これはタスクをもっとコントロールしたいときに便利です。
必要と判断されたときにタスクをキャンセルすることができれば、
ユーザー・エクスペリエンスを向上させることができます。
このようなタスクは、定義された元のスコープよりも長生きする可能性があり、
構造化されていない並行処理を作成するという考えを強制する。

タスク{}をよりよく理解するために、最後のコードを使った小さなプロジェクトがあります。
このプログラムにはダウンロード・ボタンがあり、ダウンロードが進行するとキャンセル・ボタンに切り替わります。
ボタンを素早くタップすると、ダウンロードする画像に変更を与えることなく、
明示的にタスクをキャンセルしたので、「キャンセルされました」というアラートが表示されます。

【翻訳元の記事】

Introduction to Unstructured Concurrency in Swift
https://www.andyibanez.com/posts/introduction-to-unstructured-concurrency-in-swift/

Discussion