Swift Digest
SE-0316 | Swift Evolution

Global actors

Proposal
SE-0316
Authors
John McCall, Doug Gregor
Review Manager
Joe Groff
Status
Implemented (Swift 5.5)

01 何が問題だったのか

SE-0306 で導入されたアクターは、インスタンスごとに状態をカプセル化し、そこへのアクセスをシリアライズすることでデータ競合を防ぎます。一方、実際のプログラムには「ひとつのインスタンスに閉じ込める」ことが難しい形のグローバルな状態や制約が多く存在します。

もっとも典型的なのが メインスレッド です。GUI アプリケーションでは、ユーザー入力の受け取りや UI の更新は必ずメインスレッド上で行う必要があります。しかし、UI に関わるコードや状態はしばしば多数の型・関数・モジュールに散らばっており、それらをひとつのアクター型にまとめて書き直すのは現実的ではありません。さらに、既存のフレームワーク(AppKit や UIKit など)は「メインスレッドで動くこと」を前提に設計されているため、型の構造を組み替えて対応することもできません。

グローバル変数・スタティック変数も同様です。これらはどの並行コンテキストからでも触れてしまうためデータ競合の温床ですが、アクターのインスタンス状態としてまとめることができないものも多くあります。

つまり、次のような状況に対して、アクターの安全性モデルをそのまま当てはめる方法がありませんでした。

  • 散在するコードとデータを「同じスレッド/同じ実行コンテキスト上でしか動かさない」という形で束ねたい
  • メインスレッドのような、プログラム全体で共有される唯一の実行コンテキストを型システムで表現したい
  • グローバル変数・スタティック変数へのアクセスを actor isolation で守りたい

アクターの枠組みを「インスタンスごと」ではなく「プログラム全体で唯一のアクター」に拡張する仕組みが必要でした。

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

グローバルアクター(global actor) を導入します。グローバルアクターは「型によって一意に識別される、プログラム全体で共有されるアクター」で、その型を属性として任意の宣言に付けることで、その宣言を当該グローバルアクターに isolate できます。これにより、散在する型・関数・プロパティを、同じグローバルアクター上で動くものとしてまとめ上げることができます。

もっとも代表的なグローバルアクターは、メインスレッドを表す @MainActor です。

@MainActor var globalTextSize: Int

@MainActor func increaseTextSize() {
    globalTextSize += 2   // OK: 同じ MainActor 上
}

func notOnTheMainActor() async {
    globalTextSize = 12        // error: globalTextSize は MainActor に isolate されている
    increaseTextSize()         // error: 同期呼び出しできない
    await increaseTextSize()   // OK: MainActor に非同期でホップして実行
}

同じグローバルアクター内の宣言は同期的に呼び出せますが、異なる isolation から呼ぶには await を付けた非同期呼び出しになります。

グローバルアクターを定義する

グローバルアクターは、@globalActor 属性を持ち、shared という static プロパティでアクターインスタンスを提供する型として定義します。型自体は struct、enum、アクター、あるいは final class のいずれでも構わず、実体は shared が返すアクターインスタンスです。

@globalActor
public struct SomeGlobalActor {
    public actor MyActor { }

    public static let shared = MyActor()
}

@globalActor が付いた型は GlobalActor プロトコルに暗黙的に適合し、その適合は型定義と同じソースファイルで、かつ無条件に行われる必要があります。MainActor もこの仕組みで定義されたグローバルアクターで、システムのメインスレッドを表すエグゼキュータを shared が持ちます。

関数・プロパティ・グローバル変数に付ける

関数や変数・定数に @MainActor などのグローバルアクター属性を付けると、その宣言は当該グローバルアクターに isolate されます。プロパティや subscript に付けた場合はアクセサが isolate され、stored property の observing accessor(willSet / didSet)も同様です。ローカル変数にはグローバルアクターを付けられません。

グローバル変数・スタティック変数にグローバルアクターを付けると、そのアクセスは同アクター経由でシリアライズされ、他の isolation からは await 越しでなければ読めません。

@MainActor var globalCounter = 0

@MainActor func incrementGlobalCounter() {
    globalCounter += 1   // OK
}

func readCounter() async {
    print(globalCounter)         // error: cross-actor read には 'await' が必要
    print(await globalCounter)   // OK
}

cross-actor なアクセスで受け渡される値は、他のアクター境界と同様に Sendable である必要があります。

型全体に付ける

ひとつの型のメソッド・プロパティ・subscript を一括でグローバルアクターに isolate したい場合は、型宣言自体に属性を付けます。個別のメンバーだけ外したい場合は nonisolated を使います。

@MainActor
class IconViewController: NSViewController {
    @objc private dynamic var icons: [[String: Any]] = [] // 暗黙に @MainActor
    var url: URL?                                          // 暗黙に @MainActor

    private func updateIcons(_ iconArray: [[String: Any]]) { // 暗黙に @MainActor
        icons = iconArray
    }

    nonisolated private func gatherContents(url: URL) -> [[String: Any]] {
        // MainActor から外れて呼べる
    }
}

グローバルアクターが付いた(プロトコル以外の)型は、暗黙に Sendable に適合します。状態へのアクセスがグローバルアクターで守られているため、インスタンスを並行コンテキスト間で共有しても安全だからです。

関数型・クロージャ

同期関数の型にもグローバルアクター修飾を付けられます。

var callback: @MainActor (Int) -> Void

グローバルアクター修飾された関数は、同じグローバルアクター上からでなければ同期的に呼べません。修飾のない関数型から修飾された関数型への変換は可能ですが、逆向きの変換は同期関数では許されません。ただし変換先が async なら、呼び出し時にグローバルアクターへホップする扱いで許可されます。

let callbackAsynchly: (Int) async -> Void = callback   // OK: 呼び出し時に MainActor へホップ

クロージャ自身にもグローバルアクター属性を書けます。Task.detached などと組み合わせれば、従来 Dispatch で DispatchQueue.main.async { ... } と書いていたパターンを次のように書き換えられます。

Task.detached { @MainActor in
    // メインアクター上で実行
}

また、@MainActor (Int) -> Void 型の変数・引数に直接クロージャリテラルを渡した場合は、クロージャ側にも暗黙的に @MainActor が推論されます。

継承・推論ルール

明示的にグローバルアクターも nonisolated も書かれていない宣言には、次のルールでグローバルアクターが推論されます。

  • サブクラスは、スーパークラスのグローバルアクターを継承します。
  • オーバーライドは、オーバーライド元のグローバルアクターを強制的に継承します(異なるアクターを付けたり、逆に外したりするとエラー)。
  • プロトコル要件のwitnessは、同じ型定義または同じ extension でプロトコルに適合しているなら、要件側のグローバルアクターを推論します。
  • プロトコル自体にグローバルアクターが付いている場合、そのプロトコルに適合する(プロトコル以外の)型に同じアクターが推論されます(ただし型の本体定義と同じソースファイルでの適合に限る)。
  • プロパティラッパーwrappedValue にグローバルアクターが付いていれば、それを使った型にアクターが推論されます。

クラスにグローバルアクターを付けられるのは、スーパークラスが無い/同じアクターを持つ/NSObject である場合に限られます。

既存のアクターインスタンスとの関係

グローバルアクターとインスタンスアクターは同時には付けられません。アクター型のメソッドにグローバルアクターを付けると、そのメソッドはグローバルアクターに isolate され、囲いのアクターインスタンスには isolate されなくなります

actor Counter {
    var value = 0

    @MainActor func updateUI(view: CounterView) async {
        view.intValue = value        // error: value は Counter に isolate されていて、ここは MainActor コンテキスト
        view.intValue = await value  // OK: 非同期読み取り
    }
}

同じ理由で、isolated パラメータとグローバルアクター修飾を同じ関数に同時に指定することはできません。また、アクター型自体にグローバルアクターを付けることや、アクター型の stored property にグローバルアクターを付けることも認められません。deinit にもグローバルアクターは付けられません。

Future Directions

この提案では踏み込まれていませんが、今後の方向として次の2つが挙げられています(いずれも speculative な見通しで、実現を約束するものではありません)。

  • グローバル変数・スタティック変数の isolation 必須化: 可変なグローバル/スタティック変数は「グローバルアクターに isolate する」か「letSendable な非 isolate 定数にする」かを必須とし、データ競合の原因を根絶する方向。ただし既存コードへの影響が大きいため、本提案では見送られました。
  • グローバルアクター制約のジェネリックパラメータ: GlobalActor に制約されたジェネリックパラメータをそのままアクター属性として使えるようにする拡張。ジェネリックパラメータに属性を付ける構文が必要になるため、別提案に委ねられています。