🙆‍♀️

【翻訳】Understanding Actors in the New Concurrency Model in Swift

2023/07/16に公開

並行処理を行う場合、開発者が直面する最も一般的な問題はデータ競合です。
あるタスクが値を更新するのと同時に別のタスクがその値を読み込む、
あるいは2つのタスクが値を書き込むのでその値が無効になってしまうなど、
データ競合はおそらく並行処理の主な問題点でしょう。
データ競合は非常に発生しやすく、デバッグも難しい。データ競合の問題と、
それを避けるための確立されたパターンに特化した本もあります。

データ競合は、変更可能な状態が共有されているときに発生します。
ミューテートされることのないlet変数だけを扱っているのであれば、
データ競合に遭遇することはまずないでしょう。
残念なことに、どんなに些細なプログラムであっても、ある時点でミュータブルな状態を持つことがあるため、
すべてをイミュータブルにしようと頭を悩ませても結果は出ません。
一般的には、できるだけletを使用し、(構造体のような)値セマンティクスを使用することが、
データ競合に対処する際に大いに役立つでしょう。

変更可能な状態を共有するには同期が必要です。
最も基本的な(そして最も難しい)形では、
ロック(一度に1つのプロセスによってのみ変更可能な状態を保証する概念)や
その他のプリミティブを利用することができます。
ここ数年、多くのApple Platform開発者はシリアル・ディスパッチ・キューを使っていますが、
これは並行処理を扱うためのより高度な概念です。この方法をとるには、すべてのコードを書く必要があります。

幸運なことに、Swift 5.5とWWDC2021で導入された新しい同時実行APIによって、
Swiftは、一度に1つのプロセスだけが値を変更することを保証する、
変更可能な状態を扱うためのはるかに簡単な方法を持つようになりました。
もちろん、これはこのシリーズでこれまで見てきた他の新しい並行性APIと同じ意味を持っています。
使いやすいが、より多くの制御が必要な場合には制限になるかもしれません。
良いニュースは、大多数の開発者にとってactors APIで十分だということです。

actorsの紹介

actorsは、変更可能なステートの同期を自動的に提供し、
そのステートをプログラムの残りの部分から分離します。
これは、actor自身を経由しない限り、誰も共有ステートを変更できないことを意味します。
actorは分離されており、値を変更するにはactorと話す必要があるため、
actorはそのステートへのアクセスが相互に排他的であることを保証します。
一度にステートを変更できるのは1つのプロセスだけです。
舞台裏では、actorが手動で同期を行い、
プロセスを「キューに入れる」ので、一度に1つのプロセスしか変更できません。

実装の詳細

Swift のactorsは、actor型として実装されます。クラス、列挙型、構造体を定義する方法と同様に、
actorキーワードを使用してactorを宣言します。
actorは参照型であり、その動作は構造体よりもクラスに似ていることを意味します。
actorは、他の型がアクセスする必要のある、共有された変更可能な状態を隠すためのものなので、
そう考えればまったく理にかなっています。
actorとクラスの主な違いは、actorはすべての同期メカニズムを舞台裏で実装し、
そのデータはプログラムの他の部分から分離され、actorは継承したり継承されたりすることはできませんが、
プロトコルに準拠したり拡張したりすることはできます。

actorがSwiftコンパイラに深く統合されているという事実のおかげで、
Swiftは、その同時実行の必要性のために狂うかもしれないコードからあなたを守るために
多くのことを行います。

次の例を考えてみましょう:

class Counter {
    var count = 0
    func increment() -> Int {
        count += 1
        return count
    }
}

class ViewController: UIViewController {
    
    var tasks = [Task<Void, Never>]()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let counter = Counter()
        
        tasks += [
            Task.detached {
                print(counter.increment())
            }
        ]

        tasks += [
            Task.detached {
                print(counter.increment())
            }
        ]
    }
}

(Appleは、SwiftのアクターによるWWDC2021セッションのProtect mutable stateで
  同様の例を使っています。

また、私はもともとこれらの例でサンプルPlaygroundを提供するつもりでしたが、
Xcode 13 Beta 4の時点で動作させることができなかったので、
この記事の最後に代わりに標準のiOSプロジェクトを提供します)

この例では、detached tasksの中でカウンター変数をインクリメントしようとしています。
ロック機構もなければ、コードが期待通りに動くことを保証する同期もありません。
システムは2回とも0にインクリメントする可能性があり、
出力される値はそれぞれのターンで大きく異なる可能性があります。

Counterをクラスではなくアクターにすることで、
これを修正し、出力が常に "1, 2 "になるようにすることができます。

actor Counter {
    var count = 0
    func increment() -> Int {
        count += 1
        return count
    }
}

この変更を行うだけでは十分ではありません。
コンパイルして実行しようとすると、出力しようとする両方の場所でこのエラーが出ます。

式は'async'だが、'await'は付けられていない。

これは美しく、バグの多い並行コードを書かなくて済むように、
コンパイラ・レベルで並行処理がどれだけ深く実装されているかを如実に示しています。
何時間も、何日も、何ヶ月も、あるいは何年もかけて、
自分で安全に並行コードを書くことを学ぶ必要がなくなるのだ。
コンパイラの統合は、このシリーズを通して探求してきたすべてのコンセプトが
収束することを示すものでもあるので、私は絶対に気に入っています。
コンパイラーは、これまで学んできたことの意味を理解する手助けをしてくれます。

このエラーを修正するには、increment()を呼び出すときにawaitを追加すればいいです。

print(await counter.increment())

actorのパブリックインターフェースはすべて、コンシューマーのために自動的に非同期化されます。
これによって、安全にactorと交流ができます。
awaitキーワードを使用すると、コードが次にactorの中に入って仕事をすることができると
通知されるまで実行が一時停止されるためです。

(これは、Swiftの新しい同時実行システムのための最も基本的な構成要素であるasync/awaitを
実際に理解しているかどうか、立ち止まって考える良いポイントです。もし復習が必要だと思うのであれば、
https://www.andyibanez.com/posts/understanding-async-await-in-swift/
を読んでください)。

プロパティ(この場合、カウント)に直接アクセスしようとするとき、
これはいくつかの意味を持っていることに注意してください。
まず、読み取り専用アクセスは可能ですが、非同期コンテキストを通して行う必要があります。
したがって、これはうまくいきません。

print(counter.count)

とコンパイラに怒鳴られることになる:

Actor-isolated property 'count' can only be referenced from inside the actor

これは、メソッドと同様に、プロパティもゲッターをasyncとして公開しているからです。

async {
    let count = await counter.count
    print("count is \(count)")
}

最後に、actor自身を経由しなければ、
誰もactorの共有ステートを変更できないと述べたのを覚えているでしょうか。
つまり、actorはその値を変更するメソッドを公開しなければなりません。
actorのプロパティを直接変更することはできません。

counter.count = 3
Actor-isolated property 'count' can only be mutated from inside the actor

actor内部

actorは外部呼び出し元に対して非同期コードを公開し、関連するすべてを非同期としてマークします。
しかし、actor自身では、すべての呼び出しは同期です。これにより、
奇妙な実行順序を気にする必要がなくなるので、actor内でより自然なコードを書くことができます。

次のメソッドをカウンターに追加すれば、自分でこれを観察することができる。

func reset() {
    while count > 0 {
        count -= 1
    }
    print("Done resetting")
}

次に、新しい関数fooを作成し、その中でresetと入力する。
オートコンプリートの候補が、reset()でオートフィルするよう提案するのがわかるでしょう。

一方、外部からresetを呼び出すと、reset()メソッドのシグネチャにasyncがあることが分かります。

actor内で呼び出されるものはすべて同期ですが(asyncキーワードがないので分かります)、
同じメソッドを外部から呼び出すと非同期であることがわかります。
actor上の同期コードは、中断されることなく常に完了まで実行されます。
actorが他のアクターや他の場所から非同期メソッドを呼び出すことを妨げるものは何もありませんが、
actorのプロパティやメソッドで待機できないことにお気づきでしょう。

actorの再入可能性

actorは自分の状態を他から隔離しますが、単独で動作することはほとんどありません。
actorは、他のactorやコードベース全般と相互作用する可能性があります。

そのため、予期しない動作が発生することがあります。次の例を考えてみましょう。

enum ImageDownloadError: Error {
    case badImage
}

func downloadImage(url: URL) async throws -> UIImage {
    let imageRequest = URLRequest(url: url)
    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
}

actor ImageDownloader {
    private var cache: [URL: UIImage] = [:]
    
    func image(from url: URL) async throws -> UIImage {
        if let image = cache[url] {
            return image
        }
        
        let image = try await downloadImage(url: url)
        cache[url] = image
        return image
    }
    
    private func downloadImage(url: URL) async throws -> UIImage {
        let imageRequest = URLRequest(url: url)
        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
    }
}

(このコードは、AppleのProtect mutable state with Swift actors WWDC2021セッションのImageDownloaderコードに似ていますが、私はあなたが実行できるサンプルを作成しました。

私たちは、再ダウンロードしないように画像をキャッシュする画像ダウンローダーを持っています。
if let は画像がキャッシュされているかどうかをチェックし、可能であればそれを返します。
そうでない場合、コードは画像をダウンロードし、ダウンロード後にキャッシュし、
新しくダウンロードした画像を返します。しかし、ここに2回入力したらどうなるでしょうか?

先ほどのImageDownloaderアクターを使った次のコードを考えてみましょう:

override func viewDidLoad() {
    super.viewDidLoad()
    
    Task.detached {
        await self.downloadImages()
    }
}

//...

func downloadImages() async {
    let downloader = ImageDownloader()
    let imageURL = URL(string:  "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part3/3.png")!
    async let downloadedImage = downloader.image(from: imageURL)
    async let sameDownloadedImage = downloader.image(from: imageURL)
    var images = [UIImage?]()
    images += [try? await downloadedImage]
    images += [try? await sameDownloadedImage]
}

重要な注意:Xcode 13 Beta 4(および Beta 3 まで)の時点で、
async let を介して同じタスクからアクタを 2 回入力すると、コードがデッドロックするバグがあります。Appleはこの問題を認識しており、後のベータで修正されることを期待しています。
このバグが修正されるまでは、複数のasync letバインディングを同時に使用する場合は、
Task.detachedを使用することで回避できます。
後のベータが出る頃、GMが出る頃、あるいは最終リリースが出る頃には、
このバグが修正されているかもしれません。
結局のところ、通常のTaskとTask.detached呼び出しは用途が異なるので、その点に留意してください。

私たちは、2つの異なるasync let呼び出しを介してactorに入ります。
最初の呼び出し(downloadedImage)はactorに入り、
downloadImages の await 呼び出しを見つけるまで実行します。
中断され、2番目の呼び出しであるsameDownloadedImageが実行を開始します。
downloadedImageはawaitに到達し、中断したため、
まだ画像をダウンロードする時間がないことに注意してください。
また、画像はキャッシュにないため、sameDownloadedImageも画像をメモリから取得する代わりに
ダウンロードします。本当に運が悪いと、サーバーが同じURLの裏で画像を更新している可能性があり、downloadedImageとsameDownloadedImageが異なるものをダウンロードする可能性があります!

問題は、await呼び出し後のプログラムの状態を想定していることです。
プログラムに対して「おい、君は画像をダウンロードしてキャッシュし、それにアクセスする人は
キャッシュされたバージョンを取得するんだよ」と言っているようなものです。
しかし実際には、このコードでこの保証をすることは不可能です。
なぜなら、同時にactorにアクセスしようとする異なるコールが存在する可能性があり、
その結果、同じ画像に対して2回ネットワークにヒットするバグが発生するからです。

これを回避するには、actorに各ダウンロードの状態を保持させ、
actorが画像をダウンロードしようとする前にまずその状態にアクセスします。

actor ImageDownloader {
    private enum ImageStatus {
        case downloading(_ task: Task<UIImage, Error>)
        case downloaded(_ image: UIImage)
    }
    
    private var cache: [URL: ImageStatus] = [:]
    
    func image(from url: URL) async throws -> UIImage {
        if let imageStatus = cache[url] {
            switch imageStatus {
            case .downloading(let task):
                return try await task.value
            case .downloaded(let image):
                return image
            }
        }
        
        let task = Task {
            try await downloadImage(url: url)
        }
	
	cache[url] = .downloading(task)
        
        do {
            let image = try await task.value
            cache[url] = .downloaded(image)
            return image
        } catch {
            // If an error occurs, we will evict the URL from the cache
            // and rethrow the original error.
            cache.removeValue(forKey: url)
            throw error
        }
    }
    
    private func downloadImage(url: URL) async throws -> UIImage {
        let imageRequest = URLRequest(url: url)
        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
    }
}

このコードは、AppleがWWDC2021のセッション「Protect mutable state with Swift actors」で
提供したコードに似ています。

これは多言語のように見えますが、非常に簡単です(そして、簡単さは新しい同時実行APIの力です!)。
私たちは、現在のURLの状態を保持するenumを宣言することから始めます。
URLが初めてダウンロードされると、このURLを.downloadingステータスでキャッシュに追加します。
同時に同じURLで他の呼び出しがactorに行われた場合、画像がキャッシュにあることが分かるので、
画像を再度ダウンロードするのではなく、直接待ち受けることになります。
より遠い未来に行われた呼び出しは、すでにダウンロードされた画像を見る可能性が高いので、
すぐにリターンします。画像のダウンロードが最初(そして最後)に終了すると、
画像は.downloadedというステータスでキャッシュされます。

actorの再入可能性はデッドロックを防ぎ、前進を保証しますが、
同じ画像を複数回ダウンロードするなど、必ずしも同時実行とは関係ないバグを防ぐために、
前提条件をチェックする必要があります。
ここでは、actorの再入可能性の概念をうまく使うためのポイントをいくつか紹介します。

同期コードで変異を起こす。同じタスクの中でキャッシュを変異させているのがわかるでしょう。
ステートは、awaitを実行した後のどの時点でも変化する可能性があります。
ステートがどのように変化したかを判断するために、手動でチェックする必要があるかもしれません。

Actor isolation

actorは分離が重要です。actorの主な目的は自分の状態を他から隔離することであり、
それによって自分のプロパティへのアクセスを管理し、複数の書き込みが同時に実行されて
プログラムが予期せぬ状態になることを防ぎます。

イミュータブルプロパティはいつでもアクセスすることができます。

actor DollMaker {
    let id: Int
    var dolls: [Doll] = []
    
    init(id: Int) {
        self.id = id
    }
}

extension DollMaker: Equatable {
    static func ==(_ lhs: DollMaker, rhs: DollMaker) -> Bool {
        lhs.id == rhs.id
    }
}

上記のコードでは、==演算子は2つの型を比較し、これはstaticメソッドです。
staticとは、このメソッドがactorの「外側」にあるということです(selfインスタンスはありません)。
このことと、メソッド内でしか不変のステートにアクセスしないという事実を組み合わせると、
コンパイラはこれが安全なことだと認識します。

extension DollMaker: Hashable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

一方、これは泥沼に入りつつあります。私たちもidフィールドを参照するだけですが、
このメソッドはインスタンス・メソッドです。分離するためにはasyncでなければなりません。
幸運なことに、この場合、明示的にメソッドをnonisolatedとマークして、
コンパイラにこれが分離されていないことを知らせることができます。
コンパイラーはこのメソッドをアクターの "外 "にあるものとして扱い、
その内部にある不変のプロパティにしかアクセスしない限り、先に進みます。
もしハッシャーがidの代わりにdollsプロパティを使っていた場合、
dollsはミュータブルなので、これは機能しません。

Sendable型

同時実行モデルでは、Sendable型も導入されています。
Sendable型とは、安全に同時共有できる型のことです。以下はSendable型の例です。

値型(構造体など)
Actor型

クラスはSendableになり得ますが、それは不変であるか、
それ自身の中で同期を提供する場合に限られます。Sendableなクラスは例外的です。

あなたの並行コードは、Sendable型を使用して通信することをお勧めします。
ある時点で、Swift は、関数間で非 Sendable 型を共有しているかどうかを、
コンパイル時にチェックできるようになりますが、Xcode 13, Beta 4 の時点では、そうではないようです。

Sendable プロトコル

おそらく想像がつくと思いますが、型をSendableにする方法は、
型をSendableプロトコルに適合させることです。
適合性を指定するだけで、Swiftコンパイラは私たちのために多くの仕事をします。

次の例を考えてみましょう:

struct Videogame: Sendable {
    var title: String
}

struct VideogameMaker: Sendable {
    var name: String
    var games: [Videogame]
}

VideogameMakerもVideogameもsendableなので、これは問題なくコンパイルできる。

構造体の場合は、Sendableへの準拠を避けても動作します。

struct Videogame {
    var title: String
}

struct VideogameMaker: Sendable {
    var name: String
    var games: [Videogame]
}

しかし、クラスはそうではありません。

class Videogame {
    var title: String
    
    init(title: String) {
        self.title = title
    }
}

struct VideogameMaker: Sendable {
    var name: String
    var games: [Videogame]
}

このようなエラーが表示されます。

Stored property 'games' of 'Sendable'-conforming struct 'VideogameMaker' has non-sendable type '[Videogame]'

Sendable と generics

Generic型は、そのすべてのプロパティがSendableである場合にのみ、Sendableになることができます。

struct Pair<T, U> {
    var first: T
    var second: T
}

extension Pair: Sendable where T: Sendable, U: Sendable {}

Sendable functions

actor間で受け渡し可能な関数については、@Sendable としてマークすることができます。

クロージャに関しては、@Sendableとマークするといくつかの制限が課せられます。
周囲のスコープから変更可能な変数を取り込むことはできず、
取り込むものはすべてSendableでなければなりません。

まとめ

画像ダウンロードのサンプルプロジェクトはこちらからダウンロードできます。

この記事では、actorとは何か、そしてactorをどのように使うかを探りました。
actorは自身の状態を分離し、そのプロパティへの書き込みアクセスは
すべてactor自身を通して行わなければならないことを学びました。
actorは自身の状態を分離することで、同時実行の安全性を提供します。

また、Sendable 型について学び、Swift の新しい同時実行システムにとって、
Sendable 型がいかに重要であるかを学びました。
Sendable型は、並行コードを書くためのコンパイル時のチェックを提供するのに役立ちます。
これらは静的なチェックを提供するため、並行性モデルを壊したり、
並行性のバグを導入するような間違ったコードを書くことは非常に難しくなります。

【翻訳元の記事】

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

Discussion