💨

【翻訳】Modern Swift Concurrency Summary, Cheatsheet, and Thanks

2023/07/16に公開

WWDC21以来、Swift 5.5で導入されたすべての新しい並行処理機能について、
広範囲にわたって話してきました。
私たちは多くのトピックをカバーしたので、
各記事の最も重要なトピックをカバーする要約記事を書いてこのシリーズを終えることにしました。
この要約が十分でない場合に備えて、必要に応じて関連記事へのリンクも示します。

async/await

・ asyncとawaitは、新しい並行処理システムの最も基本的なキーワードです。

・ プログラミングを学ぶとき、直線的に実行されるコードを書くことに慣れます。
  (手続き型プログラミングと呼ばれる)。あなたのコードは、あなたが指定した順序で行を実行します。

・ 並行タスクを扱う場合、async/awaitの前に、
Appleはコールバック/クロージャーベースの並行処理を提供してくれました。
コールバックとデリゲート・ベースの並行処理は、プログラムの実行順序を変えることができます。
リニアな命令セットがあれば、そのリニアなコードが実行されるときに通知され、
別のスレッドから新しいデータを受け取ることができます。
これによって並行処理に対応することが可能になるが、
時間が経つにつれて理解しづらくなる可能性があります。

・ async/awaitを使えば、上から下に実行される線形並行コードを書くことができます。
これを扱うには、非同期に呼び出せる関数は、関数のシグネチャでasyncとマークする必要があります。

func downloadData() async throws -> CustomData { 
	//...
}

asyncとマークされた関数を呼び出すときは、その前にawaitという言葉を付ける必要がある。

func processData() async throws -> CustomData {
   let newData = try await downloadData()
   return newData
}

・ コードの実行がawaitキーワードに達すると、コードの実行は一時中断され、
コードが実行されているスレッドは他の仕事をすることができます。
この他の仕事はシステムによって割り当てられます。コードがサスペンドされているため、
await以下の行はasyncタスクの実行が終わるまで実行されません。

・ コードが一時停止していた場合、ある時点で非同期タスクの実行が終了します。
   システムは私たちのコードに戻り、コードの実行を続けます。
   つまり、await呼び出しの下にあるすべてが実行を再開するということです。

・ async/awaitは、スレッドサスペンションのおかげで、
    上から下へと実行される手続きフローを維持することができます。

・ awaitコール以下は継続と呼ばれます。
    これは、デリゲートやクロージャベースの並行コードをasync/awaitに変換したい場合に
    知っておくべきことです。

・ 継続は、中断されたスレッドと同じスレッドで実行されるわけではないことに注意することが重要です。
    UIを更新する必要がある場合は、そのコードを@MainActorで実行する必要があります。

・ asyncコードはasyncコンテキストで実行する必要があります。
    これは、asyncとしてマークされた関数、
    またはTask {}でそのようなコンテキストを自分で作成した場合を意味します。

async/awaitについて学ぶには、
Understanding async/await in Swiftの記事
(https://www.andyibanez.com/posts/understanding-async-await-in-swift/)
をチェックしてください。

DelegateとClosureベースのコードをasync/awaitに変換する。

・ クロージャを持つメソッドをタイプし始めると、
コンパイラーはすでにそのasyncバージョンを無料で作ってくれていることに気づくかもしれません。

・ このような変換は自分でもできます。

・ このような変換を行うには、手動で継続を作成します。
継続とは、await呼び出しの後に起こるすべてのことです。

・ このような変換を自分で行うには、withCheckedContinuation関数や
withCheckedThrowingContinuation関数を使います。これらの関数を使用して、
クロージャベースの呼び出しをラップしたり、デリゲートベースの呼び出しの一部として後で呼び出すために
継続への参照を保存したりします。

・ これらのメソッドは、同時実行タスクが終了したときに明示的に呼び出す必要がある継続を提供します。
これらのメソッドは、"返された "値を渡して呼び出したり、
(withCheckedThrowingContinuationの場合は)エラーを投げたりすることができます。

・ 継続は一度だけ呼び出す必要があります。忘れずに呼び出すこと。
    一度だけ呼び出して、それ以上は呼び出さないでください。

・ 以下のコードは、クロージャベースの並行処理をasync/awaitに変換する方法を示しています。

func downloadImageAndMetadata(imageNumber: Int) async throws -> DetailedImage {
    return try await withCheckedThrowingContinuation({
        (continuation: CheckedContinuation<DetailedImage, Error>) in
        downloadImageAndMetadata(imageNumber: imageNumber) { image, error in
            if let image = image {
                continuation.resume(returning: image)
            } else {
                continuation.resume(throwing: error!)
            }
        }
    })
}

デリゲートベースの呼び出しをasync/awaitに変換するのは、少し面倒ですが、不可能ではありません。withChecked*Continuation呼び出しによって
提供される継続を保存し、適切なときにそれを呼び出す必要があります。

class ContactPicker: NSObject, CNContactPickerDelegate {
    private typealias ContactCheckedContinuation = CheckedContinuation<CNContact, Never> // 1

    private unowned var viewController: UIViewController
    private var contactContinuation: ContactCheckedContinuation? // 2
    private var picker: CNContactPickerViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
        picker = CNContactPickerViewController()
        super.init()
        picker.delegate = self
    }

    func pickContact() async -> CNContact { // 3
        viewController.present(picker, animated: true)
        return await withCheckedContinuation({ (continuation: ContactCheckedContinuation) in
            self.contactContinuation = continuation
        })
    }

    func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
        contactContinuation?.resume(returning: contact) // 4
        contactContinuation = nil
        picker.dismiss(animated: true, completion: nil)
    }
}

デリゲートベースの並行性変換に限定されるわけではありません。
同じスレッドですべてを行うデリゲートベースの呼び出しでさえ、この恩恵を受けることができます
(ただし、その労力が割に合うかどうか、過剰なエンジニアリングにならないかどうかは考慮してほしい)。

既存のクロージャまたはデリゲートベースのコードを async/await に変換することについての詳細は、
Converting closure-based code into async/await in Swift
(https://www.andyibanez.com/posts/converting-closure-based-code-into-async-await-in-swift/)
をチェックしてください。

Structured Concurrency

複数のawait呼び出しが連続しても、並行処理が行われていることにはなりません。
以下のコードは同時並行ではないが、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)
}

・ 構造化された並行性によって、上から下へ読むこともできる並行コードを書くことができます。
複数のタスクを簡単に並行して起動できます。

・ 構造化並行処理には、async letコールとTask Groupの2種類があります。

async let concurrency

・ awaitできる呼び出しは、同時に実行することもできます。

・ そのためには、変数定義のletまたはvarの前にasyncキーワードを追加し、
awaitコールを削除するだけです。

・ そして、必要な時点でその変数をawaitするだけです。

・ 以下のコードは上記と同じコードですが、両方のasyncタスクを同時に実行しています。

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

・ imageとmetadataが非同期の値であるにもかかわらず、このコードは非常に読みやすいです。
    なぜなら、関数から戻る前にその値を待っているからです。

・ async letは、実行する必要がある同時タスクの数が正確に分かっている場合に最適です。
   上の例では、downloadImageとdownloadMetadataの2つがあることがわかる。

async letを使用した構造化並行処理について詳しく知りたい場合は、
Structured Concurrency in Swit: Using async let
(https://www.andyibanez.com/posts/structured-concurrency-in-swift-using-async-let/)
を読んでください。

Group Tasks

・ Group Taskは、事前に同時実行の量がわからない場合に使用します。
例えば、ウェブサービスから可変数のURLをフェッチし、後で同時にダウンロードするような場合です。

・ Group Taskを起動するには、withThrowingTaskGroupまたはwithTaskGroupメソッドを使用します。

・ 上の例では、可変数の画像をダウンロードするTask Groupを作成しています。

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
}

・ グループ変数には、ダウンロードされたデータが格納されます。これはAsyncSequenceなので、
それを反復処理したり、filter、map、reduceなどの関数を適用することができます。

・ グループの優先度を指定できるので、
この構造化並行処理メソッドはasync letよりも少し柔軟性があります。

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

asyncUnlessCancelledでキャンセルに備えることができる。

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

Sendable Types

・ Sendable型は並行処理と相性の良い型です。並行コンテキストでこれらを使用しても、
コンパイラーは文句を言いません。
Sendable型(プロトコル)でのみ動作する@Sendableクロージャがあります。

・ @Sendableクロージャは変化する変数を捉えることができません。

・ 値型、アクター、クラス、または独自の同期を実装するその他のオブジェクトのみを
    キャプチャする必要があります。

Group Taskおよび/または Sendable 型の詳細を学びたい場合は、

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

Understanding Actors in the New Concurrency Model in Swift
(https://www.andyibanez.com/posts/understanding-actors-in-the-new-concurrency-model-in-swift/)

を読んでください。

The Task Tree

(async letとTask Groupの両方で)構造化された並行処理の重要な概念は、Task Treeです。

・ 非同期関数は他の非同期タスクを生成できます。
生成されたタスクは、それを起動したタスクの子タスクとなります。

・ 子タスクは、優先度、ローカル変数、キャンセルなどの情報を親タスクから継承します。

・ 親タスクは、子タスクが処理を終了したときにのみ、その処理を終了することができます。

・ タスクのキャンセルはタスクツリーによって管理され、協調的である。
タスクがキャンセルされた場合(cancellまたはcancellAll呼び出しによって手動でキャンセルされた
場合、あるいはエラーが発生した場合)、ツリー内のタスクは即座にキャンセルされません。
その代わり、タスクはキャンセルされたとマークされますが、
キャンセルが適切と判断されるまで作業を続けます。
親タスクがキャンセルされると、その子タスクもキャンセルされます。

・ タスクのキャンセル・ステータスを確認し、作業を停止する必要があるかどうかを判断するには、
エラーを投げる可能性のあるタスクの場合はTask.checkCancellation()メソッドを、
エラーを投げないタスクの場合はTask.isCancelledを使用します。

func downloadImage(imageNumber: Int) async throws -> UIImage {
    try Task.checkCancellation() // <- If we are cancelled, this throws.
    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
}

タスク・ツリーの詳細については、
Structured Concurrency in Swit: Using async let
(https://www.andyibanez.com/posts/structured-concurrency-in-swift-using-async-let/)
をご覧ください。

Unstructured Concurrency

非構造化同時実行は、Taskにそのような手続き的なフローがない場合に便利ですが、
それでも異常な実行フローを大幅に削減するのに役立ちます。
また、非構造化並行処理は、構造化並行処理よりもコントロールしやすいです。

非構造化並行処理には2つの方法があります。
TaskコールとTask.detachedを使ったdetached taskです。

Task

・ Task {}を使うと、実際には並行タスクが起動されます。
このようにして、非同期と同期の世界の「橋渡し」が行われます。

・ Taskは変数に格納できるので、必要なときに手動でキャンセルできます。

・ また、特定の優先度で起動することもできます。

Taskによる非構造化並行処理についてもっと学ぶには、
Swift の非構造化並行処理入門の記事をチェックしてください。

Detached tasks

Task.detached {}で起動する。
他の種類のタスクとは異なり、親タスクから何も継承しません。
優先順位さえも。起動されたコンテキストから独立しています。
Detachedタスクの詳細については
Unstructured Concurrency With Detached Tasks in Swift
(https://www.andyibanez.com/posts/unstructured-concurrency-with-detached-tasks-in-swift/)
をご覧ください。

Actors

・ Actorは、プログラムの他の部分から状態を分離する参照型です。
    これは、プログラム内のデータ競合を防ぐための完璧なメカニズムです。

・ Actorは、アクセスされたときに独自の内部同期を提供します。
    これによりデータ競合を防ぐことができます。

・ Actorの状態を直接変更することはできません。
    Actorを変更するすべての呼び出しは、Actor自身を経由する必要があります。

・ Actorが提供するすべてのメソッドは、明示的にマークしていなくても
    await 呼び出しを通じて公開されます。

・ プロパティは、分離する必要がない、あるいは分離できないメソッドは、
    nonisolatedとしてマークすることができます。

・ Actorの再入可能性(複数回アクターに入ること)に注意して設計する必要があります。
   状態が変化するため、いくつかの考慮が必要な場合があります。
   例えば、画像をダウンロードしてキャッシュするActorは、
   連続して入力すると同じ画像を 2 回ダウンロードしてキャッシュする可能性があります。

@MainActor と Global Actors

・ 異なるファイルやタイプにまたがるグローバル・アクタを定義できます。
    クラスを特定のActorで実行するようにマークすることで、
    すべてのコードが同じスレッドで実行されるようになります。

・ グローバル・アクタを@globalActor属性で宣言し、その名前を@の前に付けて参照することで、
    そのActorを使用します。上の例では、MediaActor というActorを作成し、
    このActorで実行される videogames という変数を作成しています。

@globalActor
struct MediaActor {
  actor ActorType { }

  static let shared: ActorType = ActorType()
}

struct Videogame {
    let id = UUID()
    let name: String
    let releaseYear: Int
    let developer: String
}

@MediaActor var videogames: [Videogame] = []

MainActorは、メインスレッドで実行されるSwiftによって提供される特別なグローバルアクターです。
ViewController、ViewModel、メインスレッドで実行させたい他のコードを
@MainActorとしてマークすることができます。
Actorでクラスをマークすることは、
そのすべてのプロパティとメソッドが同じActor上で実行されることを意味します。
以下の例では、ViewControllerに
@MainActor 属性を追加し、すべてのコードがメインスレッドで実行されるようにしています。

@MainActor
class GameLibraryViewController: UIViewController {
	//...
	nonisolated var fetchVideogameTypes() -> [VideogameType] { ... }
	//...
}

特定のメソッドのAcotrをオーバーライドすることも可能だ。

@MainActor
class GameLibraryViewController: UIViewController {
   @MediaActor func doThisInAnotherActor() {}
}

Sharing Data Across Tasks with @TaskLocal

・ TaskLocalプロパティ・ラッパーを使うと、ローカル・タスク間でデータを共有することができます。
・ タスクは同じツリーの一部であるべきで、
    あるタスクの中で開始されたdetached taskはそれを継承しません。

class ViewController: UIViewController {
    @TaskLocal static var currentVideogame: Videogame?
    // ...
}

・ 静的プロパティのみがこのプロパティ・ラッパーを持つことができます。
・ プロパティに値を書き込むには、値をバインドする必要があります。

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    
    let vg = Videogame(title: "The Legend of Zelda: Ocarina of Time", year: 1998)
    Self.$currentVideogame.withValue(vg) {
        // we cam launch some async tasks here that make use of the LocalValue
    }
}

それらを読むことは、待ち望んでいた呼びかけです。

func expensiveVidegameOperation() async {
    if let vg = await ViewController.currentVideogame {
        print("We are processing \(vg.title)")
    }
}

AsyncSequence と AsyncStream

AsyncSequenceを使えば、時間をかけて値を受け取ったり、ループの中で値を待ったり、あるいは
filter、map、reduceなどの関数を適用することができます。

func loadVideogames() async {
    let url = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part11/videogames.csv")!
    
    let videogames =
        url
        .lines
        .filter { $0.contains("|") }
        .map { Videogame(rawLine: $0) }
    
    do {
        for try await videogame in videogames {
            print("\(videogame.title) (\(videogame.year ?? 0))")
        }
    } catch {
        
    }
}

・ 注目すべきは、ループに入れるまでシーケンスは「開始」しないということです。
高次関数を適用することで、await forループで受信されるものが制限されるだけです。

・ WWDC21では、NSNotificationCenter APIを含む複数のAPIが
これをサポートするように更新されました。

・ AsyncStreamオブジェクトは、どこかから値のストリームを受け取り、
それをfor awaitループで使えるものに変換するために使うことができます。

・ 例えば、GPSの更新をデリゲートでリアルタイムに受け取る場合、
そのすべてをラップして、代わりに新しい座標をループで受け取ることができます。

感謝

このシリーズの記事は、2019年にウェブサイトをリニューアルして以来、
すぐに私のウェブサイトで最もアクセス数の多いページのひとつとなりました。
そのおかげで、コミュニティのメンバーからも多くのフィードバックを頂いています。

誤字脱字や文の言い回しがおかしいというご意見をいただいた方には、この場を借りてお礼を申し上げたい。
皆さんの意見やコメントを参考にしながら、記事を改善するために多くの注意を払ってきました。
皆さんのおかげで、記事のクオリティを上げることができました。

たくさんのメールをいただいたが、あまりの多さに全員の名前を挙げるのは本当に本当に難しい。
だから、私のブログの質を向上させる手助けをしてくれた皆さん、本当にありがとう。
また、全員にお返事できなかったことをお詫びします。
たくさんのメールをいただいたので、誰にお返事したかわからなくなることもありました。

というのも、彼はこのシリーズのすべての記事に目を通し、
非常に詳細な意見や改善点をメールで送ってくれたからです。
この人のメールは実際とても長く、彼からメールを受け取るたびに、
私は修正点を確認する作業に長い時間を費やしました。
とはいえ、彼の提言に取り組むのに費やした1秒1秒が実を結び、
この記事シリーズは私にとって非常に誇らしいもののひとつとなりました。
この人物はデニス・バーチです。
この記事シリーズが私のお気に入りのひとつになる手助けをしてくれたデニスに大感謝です。

【翻訳元の記事】

Modern Swift Concurrency Summary, Cheatsheet, and Thanks
https://www.andyibanez.com/posts/modern-swift-concurrency-summary-cheatsheet-thanks/

Discussion