Swift Digest

NotificationCenter の通知をデータ競合安全にする

Concurrency-Safe Notifications

Proposal
SF-0011
Authors
Philippe Hausler, Christopher Thielen
Review Manager
Charles Hu
Status
Accepted

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

NotificationCenter は通知の発行(post)と観測(observe)の仲介役として、macOS や iOS をはじめ Darwin 系のフレームワーク全体で広く使われています。発行側は Notification.Name で識別される通知に objectuserInfo をペイロードとして添えて投げ、観測側は同じ識別子に対してクロージャを登録します。

しかしこの仕組みは Swift から見ると次の 2 つの面で安全性が弱いという課題がありました。

コンパイル時の concurrency チェックが効かない

通知の観測クロージャは「発行側と同じスレッドで呼ばれる」という暗黙の取り決めに依存しており、isolation の契約はドキュメントを参照するか、観測側が防御的に concurrency 機構を組むしかありません。OperationQueue を渡して実行先を指定することはできますが、これも実行時の取り決めにとどまり、コンパイラが検査してくれるわけではありません。Swift Concurrency と組み合わせた場合の安全性も保証されません。

型が弱い

通知の識別子は Notification.Name(実体は文字列)であり、ペイロードの objectAny?userInfo[AnyHashable: Any]? という弱い型です。識別子のスペルミスをコンパイラに検出させることはできず、観測側ではペイロードを利用するたびにキャストを書く必要があります。

そのため、ある観測コードがどの型のペイロードを期待しているのか、どの actor 上で呼ばれるのかは、API のシグネチャだけからは読み取れない状態でした。

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

NotificationCenter に、通知ごとに専用の型を定義できる 2 つのプロトコルが追加されます。MainActor に bind したい通知のための NotificationCenter.MainActorMessage と、非同期に配送される NotificationCenter.AsyncMessage です。発行側はこれらに適合する型を作って投げ、観測側は型から isolation とペイロードの形を受け取ります。

@available(FoundationPreview 0.5, *)
extension NotificationCenter {
    public protocol MainActorMessage: SendableMetatype {
        associatedtype Subject

        static var name: Notification.Name { get }

        @MainActor static func makeMessage(_ notification: Notification) -> Self?
        @MainActor static func makeNotification(_ message: Self) -> Notification
    }

    public protocol AsyncMessage: Sendable {
        associatedtype Subject

        static var name: Notification.Name { get }

        static func makeMessage(_ notification: Notification) -> Self?
        static func makeNotification(_ message: Self) -> Notification
    }
}

MainActorMessage に適合した通知は、観測クロージャが @MainActor に bind され、MainActor 上の発行から同期的に届きます。AsyncMessageSendable で、観測クロージャは非同期コンテキストで呼ばれ、配送も非同期になります。

nameNotification.Name を介して既存の Notification と同じ識別子を共有でき、互換のための仕組みとして機能します。Notification との相互運用が不要であれば name の指定は省略でき、その場合は MyModule.MyMessage のような完全修飾名がデフォルトとして使われます(型名やモジュール名を変えると識別子も変わる点には注意してください)。

Subject はその通知が「何に紐づいているか」を表す型で、既存の Notification.object に相当します。プロトコル本体には適合要件はありませんが、addObserverpost の側で AnyObject への適合が要求されるため、特定のインスタンスとそのメタタイプの両方を扱えるようになっています。

通知型の定義例

既存の NSWorkspace.willLaunchApplicationNotificationMainActorMessage として表すと次のようになります。makeMessage(_:)makeNotification(_:) は、既存の Notification ベースの発行・観測との相互運用に使う任意のメソッドです。

extension NSWorkspace {
    public struct WillLaunchApplication: NotificationCenter.MainActorMessage {
        public static var name: Notification.Name { NSWorkspace.willLaunchApplicationNotification }
        public typealias Subject = NSWorkspace

        public var application: NSRunningApplication

        public static func makeMessage(_ notification: Notification) -> Self? {
            guard
                let application = notification.userInfo?["applicationUserInfoKey"] as? NSRunningApplication
            else {
                return nil
            }
            return Self(application: application)
        }

        public static func makeNotification(_ message: Self) -> Notification {
            return Notification(
                name: Self.name,
                userInfo: ["applicationUserInfoKey": message.application]
            )
        }
    }
}

観測

観測には addObserver の新しいオーバーロードを使います。観測側は対象の通知が MainActorMessageAsyncMessage かを意識する必要はなく、MainActorMessage ならクロージャは @MainActorAsyncMessage なら @Sendableasync クロージャになります。

通知型の探索を楽にするために、SE-0299 スタイルの static member lookup を可能にする NotificationCenter.MessageIdentifier プロトコルと、その既定実装である NotificationCenter.BaseMessageIdentifier が用意されます。

extension NotificationCenter.MessageIdentifier
    where Self == NotificationCenter.BaseMessageIdentifier<NSWorkspace.WillLaunchApplication> {
    static var willLaunchApplication: Self { .init() }
}

// 特定のインスタンスに紐づく通知だけを受け取る
let token = center.addObserver(of: workspace, for: .willLaunchApplication) { message in
    // message.application などにアクセスできる。MainActor に bind されている
}

// メタタイプを渡せば、既存 NotificationCenter の object = nil と同じく
// その識別子を持つすべての通知を受け取る
let token = center.addObserver(of: NSWorkspace.self, for: .willLaunchApplication) { message in
    // ...
}

観測クロージャには Message-style の値だけが渡され、addObserverpost に指定した subject は渡されません。subject を観測側で参照したい場合は、通知型のプロパティとして含めておきます。

addObserverObservationToken を返します。removeObserver(_:) に渡せば解除でき、ObservationToken がスコープを抜けたときには(まだ登録されていれば)自動的に解除されます。トークンを取り違えて落としてしまっても観測者が漏れない、という安全側に倒した挙動です。

extension NotificationCenter {
    public struct ObservationToken: Hashable, Sendable { ... }

    public func removeObserver(_ token: ObservationToken)
}

AsyncMessage については、AsyncSequence 経由で観測する API も用意されます。for await ... in 構文を使えるので、既存の notifications(named:object:) と似た感覚で書けます。バッファ上限を超えた場合は実装側でログが出ます。

for await message in center.messages(of: anObject, for: .anAsyncMessage) {
    // ...
}

for await message in center.messages(for: AnAsyncMessage.self) {
    // ...
}

発行

発行は既存の post のオーバーロードとして提供されます。MainActorMessage 版は @MainActor でしか呼べず、観測者へ同期的に届きます。AsyncMessage 版は同期的に呼べますが、配送は非同期です。

NotificationCenter.default.post(
    NSWorkspace.WillLaunchApplication(application: launchedApplication),
    subject: workspace
)

addObserver と異なり、postMessageIdentifier ベースの static member lookup を使わないのは、発行時には通知型を直接初期化する必要があるためです。

既存の Notification との相互運用

makeMessage(_:)makeNotification(_:) を実装しておくと、Notification 型と Message-style 型の発行・観測を相互に組み合わせられます。

発行 観測 挙動
Message-style Notification makeNotification(_:) の結果が Notification 観測者に届く。未実装なら userInfonil で呼ばれる
Notification Message-style makeMessage(_:) の結果が Message-style 観測者に届く。未実装なら観測者は呼ばれない

これにより、Notification 側の post を変えなくても、観測側だけ Message-style 型を導入する、といった段階的な移行が可能です。Objective-C などからの既存の post(name:object:userInfo:) 呼び出しに対しても、Notification.Name を合わせた MainActorMessage または AsyncMessage を定義すれば、makeMessage(_:) を通して安全に値を取り出せます。

ただし、Notification として発行された通知が、対応する MainActorMessage / AsyncMessage で宣言した isolation 契約を満たしていない場合(たとえば MainActor 以外のスレッドから MainActorMessage 相当の通知が発行される、など)には、観測側ではなく既存の post 呼び出し側を直して契約を守るのが基本的な対処になります。