Swift Digest
SE-0306 | Swift Evolution

Actors

Proposal
SE-0306
Authors
John McCall, Doug Gregor, Konrad Malawski, Chris Lattner
Review Manager
Joe Groff
Status
Implemented (Swift 5.5)

01 何が問題だったのか

SE-0304 の structured concurrency は、クロージャでキャプチャされた範囲の状態に対してはデータ競合のない並行処理を実現できます。しかし、Swift にはクラスがあり、クラスは「プログラム全体で共有されるミュータブルな状態」を表現する中心的な仕組みです。クラスは並行プログラムでは扱いが難しく、データ競合を避けるためにロックなどの同期機構を人手で正しく組み合わせる必要があります。

class BankAccount {
    let accountNumber: Int
    var balance: Double

    func transfer(amount: Double, to other: BankAccount) throws {
        if amount > balance {
            throw BankError.insufficientFunds
        }
        balance = balance - amount
        other.balance = other.balance + amount
    }
}

このコードは単体では自然に見えますが、複数のスレッドから同時に呼ばれるとデータ競合を起こします。しかも、コンパイラはそれを検出してくれません。外側でロックを取るか、シリアルキューに乗せるかといった対処を、利用側が正しく設計・維持する必要がありました。

要するに Swift には、次の二つを同時に満たす「共有ミュータブル状態の抽象」が欠けていました。

  • クラスのように、名前の付いた型として宣言でき、メソッド・プロパティ・プロトコル適合・ジェネリクスといった通常の機能を持てる
  • それでいて、データ競合や一般的な並行性バグをコンパイル時に静的に検出できる

structured concurrency で扱えるのはクロージャに閉じたタスクの世界だけで、長寿命の共有状態を安全に扱う仕組みはまだ提供されていませんでした。

02 どのように解決されるのか

アクターモデルに基づく新しい型、アクター(actor) を Swift に導入します。アクターは、自身のミュータブルな状態を一つの isolation domain に閉じ込め、その外部からのアクセスを必ず非同期なメッセージ経由に強制することで、データ競合を静的に防ぎます。

actor 型の宣言

アクターは actor キーワードで宣言します。見た目はクラスに近く、stored property、computed property、メソッド、サブスクリプト、イニシャライザ、プロトコル適合、ジェネリクスといった Swift の型機能をひと通り備えています。

actor BankAccount {
    let accountNumber: Int
    var balance: Double

    init(accountNumber: Int, initialDeposit: Double) {
        self.accountNumber = accountNumber
        self.balance = initialDeposit
    }
}

アクターは参照型(reference type)ですが、クラスとは別のカテゴリの型として扱われます。継承は認められず、required / convenience / override / class メンバ / open / final といったクラス継承に関連する機能はありません。クラスをアクターに、あるいはその逆に変換するのも互換性を壊す変更とみなされます。

actor isolation と isolation domain

アクターがミュータブルな状態を守る仕組みを actor isolation と呼びます。アクターのインスタンスメンバ(stored property、computed property、メソッド、サブスクリプト)はすべてデフォルトで actor-isolated で、self という特定のアクターインスタンスに紐づいた isolation domain の内側でのみ同期的にアクセスできます。同じ isolation domain の内側からは、他の actor-isolated な宣言を自由に参照できます。

たとえば以下のように、別アクターの stored property に直接触れるコードはコンパイルエラーになります。

extension BankAccount {
    func transfer(amount: Double, to other: BankAccount) throws {
        if amount > balance {
            throw BankError.insufficientFunds
        }
        balance = balance - amount
        other.balance = other.balance + amount
        // error: actor-isolated property 'balance' can only be referenced on 'self'
    }
}

balanceself の isolation domain に属しているので、otherbalance に同期的に触ることはできません。static メソッド・static プロパティ・static サブスクリプトはアクターインスタンスを self に持たないため actor-isolated ではありません。

cross-actor な参照とメッセージング

アクターの isolation domain の外からアクターの宣言を参照することを cross-actor reference と呼びます。cross-actor reference として許されるのは次の二つだけです。

  1. 同じモジュール内の、Sendable な型のイミュータブルな let プロパティへの参照(同期的に可)
  2. async 関数としての非同期呼び出し

モジュール外からはイミュータブルな let プロパティであっても非同期アクセスが要求されます。これは「あとから letvar に変えても既存クライアントを壊さない」というライブラリ進化の自由度を守るためです。

非同期呼び出しはアクターの「メールボックス」にメッセージとして積まれ、アクターはそれを 一度に一つずつ 取り出して実行します。アクターはシリアルなエグゼキュータを一つずつ持ち、どんなに並行に呼び出されても、actor-isolated なコードが同じアクター上で同時に走ることはありません。これがデータ競合の発生を構造的に封じます。

メールボックスの処理順は厳密な FIFO ではなく、タスクの優先度を考慮した順序になる点で、serial DispatchQueue とは異なります。

アクターメソッドの呼び分け

アクターの同期メソッドは、self からは同期的に呼べる 一方、アクターの外からは async 関数として非同期に呼び出す必要があります。

extension BankAccount {
    // 同期メソッド(asyncを付ける必要がない)
    func deposit(amount: Double) {
        assert(amount >= 0)
        balance = balance + amount
    }

    // 自分自身には同期的に呼べる
    func passGo() {
        self.deposit(amount: 200.0) // OK
    }

    // 他アクターには非同期で呼ぶ必要がある
    func transfer(amount: Double, to other: BankAccount) async throws {
        if amount > balance {
            throw BankError.insufficientFunds
        }
        balance = balance - amount
        await other.deposit(amount: amount) // cross-actor call
    }
}

アクターのプロパティへの cross-actor アクセスは、読み取りのみ非同期で可能です。cross-actor に var プロパティを書き換えたり、inoutasync 関数に渡すことはできません(get と set の間に実質的な suspension が入り、そこで別の処理が割り込むと不変条件を壊せてしまうためです)。

func checkBalance(account: BankAccount) async {
    print(await account.balance)     // OK
    await account.balance = 1000.0   // error: cross-actor property mutations are not permitted
}

isolation boundary と Sendable

cross-actor な呼び出しは isolation boundary を越える操作です。境界を越える値が共有ミュータブルな参照だと、結局アクターの内と外から同じオブジェクトを同時に触れてしまい、データ競合が再発します。これを防ぐため、cross-actor な受け渡しに現れる型はすべて Sendable に適合している必要があります。具体的には次が要求されます。

  • cross-actor な async 呼び出しの引数型と戻り値型が Sendable
  • cross-actor に参照されるイミュータブル let プロパティの型が Sendable

アクター自身は内部で同期を行っているので、アクター型は自動的に Sendable に適合します。

class Person {       // Sendableでない
    var name: String
    let birthDate: Date
}

actor BankAccount {
    var owners: [Person]
    func primaryOwner() -> Person? { return owners.first }
}

if let primary = await account.primaryOwner() {
    // error: cannot call function returning non-Sendable type 'Person?' across actors
    primary.name = "The Honorable " + primary.name
}

一方、アクターの内側(actor-isolated なコードから)であれば primaryOwner() をそのまま呼べます。String など Sendable な値だけを境界の外に渡す API(例: primaryOwnerName() -> String?)に整えれば、外からの呼び出しも安全になります。

nonisolated メンバ

明示的にアクターの isolation domain から外れるメンバは nonisolated キーワードで宣言します。nonisolated なメンバはアクター外から同期的に呼べる代わりに、actor-isolated な state に同期的にアクセスすることはできません。

nonisolated キーワード自体の詳細は SE-0313 で定義されます。本 Proposal の範囲では、「actor-isolated でない宣言はすべて non-isolated であり、actor-isolated な宣言へは同期的にアクセスできない」という規則を押さえておけば十分です。

self 参照の isolation とクロージャ

アクター内で作るクロージャの isolation は、@Sendable かどうかで決まります。

  • @Sendable クロージャ(たとえば Task.detached に渡すもの)は 非 isolated になり、actor-isolated な状態には非同期アクセスしかできません。
  • @Sendable クロージャは、アクターの isolation domain から抜け出せないため、作られた文脈のアクターに isolated になります。たとえば forEach に渡したクロージャの中では、self に同期的にアクセスして問題ありません。
extension BankAccount {
    func endOfMonth(month: Int, year: Int) {
        Task.detached { // @Sendableクロージャ。非isolated。
            let transactions = await self.transactions(month: month, year: year)
            let report = Report(accountNumber: self.accountNumber, transactions: transactions)
            await report.email(to: self.accountOwnerEmailAddress)
        }
    }

    func close(distributingTo accounts: [BankAccount]) async {
        let transferAmount = balance / accounts.count
        accounts.forEach { account in // 非@Sendableクロージャ。selfにisolated。
            balance = balance - transferAmount
            await account.deposit(amount: transferAmount)
        }
    }
}

reentrancy: 協調的マルチタスクと await をまたぐ不変条件

アクターの actor-isolated な async 関数は reentrant(再入可能) です。ある actor-isolated 関数が await で suspend している間、別の actor-isolated タスクが同じアクター上で先に進むことができます。この挙動を interleaving と呼びます。

アクターの「シングルスレッド的振る舞い」は保たれます。どの瞬間でも一つのアクター上で actor-isolated なコードは一つしか走りません。しかし、await をまたいだ瞬間に、同じアクターの状態が他のタスクによって書き換わっている可能性があります

actor DecisionMaker {
    let friend: Friend
    var opinion: Decision = .noIdea

    func thinkOfGoodIdea() async -> Decision {
        opinion = .goodIdea                       // (1)
        await friend.tell(opinion, heldBy: self)  // (2) ここで他のタスクが割り込みうる
        return opinion                            // (3) .goodIdea とは限らない
    }

    func thinkOfBadIdea() async -> Decision {
        opinion = .badIdea                       // (4)
        await friend.tell(opinion, heldBy: self) // (5)
        return opinion                           // (6)
    }
}

thinkOfGoodIdeathinkOfBadIdea を並行に呼ぶと、たとえば (1)(2) の間に (4)(5) が割り込み、(3) で帰ってくる opinion.badIdea になる、といった実行順序もあり得ます。これは await を跨いだ不変条件(「opinion を書いてから戻り値として読み戻すまで変わらないはず」)が、実際には壊れうるという典型例です。

Swift が await を必ず明示させるのはまさにこのためで、await は「ここで別の処理が割り込みうる境界」 を表します。

reentrant を選んだ理由は大きく次の通りです。

  • デッドロックの多くを構造的に防げる(非 reentrant だと、アクター A から B を呼び、B から A にコールバックした瞬間に詰まる)
  • 高優先度のタスクが低優先度のタスクの完了を待たずに前に進め、応答性が上がる
  • 非 reentrant は、長時間の非同期処理(ネットワーク I/O など)の間、他の安価な処理まで止めてしまう

その代わり、プログラマー側の指針として次の点が重要になります。

  • 状態更新は同期メソッドにまとめる。アクター内の同期コードは事実上の critical section として働き、await で中断されません。
  • await をまたいで不変条件を仮定しないawait の前後で state は変わりうる前提でコードを書きます。
  • 相手に何かを伝える処理と、自分の意見を固める処理を分離するなど、責務を切り分ける。

プロトコル適合

すべてのアクター型は、暗黙に Actor プロトコルに適合します。

protocol Actor : AnyObject, Sendable { }

Actor プロトコルを要求するプロトコルは「アクターだけが適合できるプロトコル」となり、その要件(インスタンスメソッド・プロパティ・サブスクリプト)は self の isolation domain に属する扱いになります。アクター以外の具体型(class、struct、enum)は Actor プロトコルに適合できません。

protocol DataProcessible: Actor {
    var data: Data { get }           // self にisolated
}

extension DataProcessible {
    func compressData() -> Data {    // self にisolated
        // data を同期的に使える
    }
}

actor MyProcessor : DataProcessible {
    var data: Data
    func doSomething() {
        let newData = compressData() // OK
    }
}

アクターは、要件が async であるプロトコルにも適合できます。この場合、利用側は必ず非同期に呼ぶため、アクターの isolation は守られます。要件が async でなく、しかも actor-isolated でないプロトコルにアクターをそのまま適合させることはできません(例外的に nonisolated なメンバで実装できる場合は可能で、詳細は SE-0313 で扱われます)。

Equatable / Hashable の自動合成はない

アクターは struct や enum と違って Equatable / Hashable などの自動合成の対象にはなりません。同一性は「同じアクターインスタンスかどうか」で決まる参照型としてふるまいます。等値比較が必要なら Equatable などに明示的に適合させ、必要な振る舞いを実装します。

その他の制約

  • key path: アクターの actor-isolated なプロパティ/サブスクリプトを指す key path は作れません。key path は Sendable な値として流通するため、これを許すと isolation boundary を越えた同期アクセスが可能になってしまうからです。
  • inout: actor-isolated な stored property を async 関数に inout で渡すことはできません(suspension を挟むと排他性違反になりうるため)。同期関数への inout 渡しは可能です。
  • Objective-C との相互運用: @objc actor は可能ですが、Objective-C 側からは actor isolation を理解できないため、メンバを @objc 化するには async であるか nonisolated である必要があります。

グローバルアクターについて

@MainActor のような グローバルアクター の仕組みは、本 Proposal の対象外です。別 Proposal の SE-0316 Global actors で導入されています。

Future Directions

reentrancy に関しては、将来的に細かい制御を導入する方向が検討されています(speculative で、実現を約束するものではありません)。

  • @reentrant(never) 相当の非 reentrant 指定: 関数・アクター・エクステンション単位で reentrant を無効化し、mailbox の処理を直列化する。デッドロックのリスクを受け入れる代わりに、await を跨いだ不変条件の破れを防ぎたい場面向け。
  • task-chain reentrancy: 同じタスク階層からの再入だけを許し、無関係なタスクからの interleaving は禁じる中間的なモデル。同期コードの再帰呼び出しに近い感覚で書けるが、実装・運用の両面で実績が乏しく、本 Proposal では採用されていません。