[Pitch] Concurrency-Safe Notifications

Hi everyone,

I've developed a pitch with @Philippe_Hausler to introduce Swift Concurrency to NotificationCenter.

Please let us know your thoughts, and thanks for reading.


Concurrency-Safe Notifications

Introduction

The NotificationCenter API provides the ability to decouple code through a pattern of "posting" notifications and "observing" them. It is highly-integrated throughout frameworks on macOS, iOS, and other Darwin-based systems.

Posters send notifications identified by Notification.Name, and optionally include a payload in the form of object and userInfo fields.

Observers receive notifications by registering code blocks or providing closures for a given identifier. They may also provide an optional OperationQueue for the observer to execute on.

This proposal aims to improve the safety of NotificationCenter in Swift by providing explicit support for Swift Concurrency and by adopting stronger typing for notifications.

Motivation

Idiomatic Swift code uses a number of features that help maintain program correctness and catch errors at compile-time.

For NotificationCenter:

  • Compile-time concurrency checking: Notifications today rely on an implicit contract that an observer's code block will run on the same thread as the poster, requiring the client to look up concurrency contracts in documentation, or defensively apply concurrency mechanisms which may or may not lead to issues. Notifications do allow the observer to specify an OperationQueue to execute on, but this concurrency model does not provide compile-time checking and may not be desirable to clients using Swift Concurrency.
  • Stronger types: Notifications do not use very strong types, neither in the notification identifier nor the notification's payload. Stronger typing can help the compiler validate that the expected notification is being used and avoid the possibility of spelling mistakes that come with using strings as identifiers. Stronger typing can also reduce the number of times a client needs to cast an object from one type to another when working with notification payloads.

Well-written Swift code strongly prefers being explicit about concurrency isolation and types to help the compiler ensure program safety and correctness.

Proposed solution and example

We propose a new protocol, NotificationCenter.Message, which allows the creation of types that can be posted and observed using NotificationCenter. NotificationCenter.Message provides support for isolation in Swift Concurrency and is designed to interoperate with the existing Notification type for easy adoption.

NotificationCenter.Message is created by specifying a name of type Notification.Name:

struct EventDidOccur: NotificationCenter.Message {
    static var name: Notification.Name { eventDidOccurNotification }
}

Providing a Notification.Name enables NotificationCenter.Message to interoperate with posters and observers of the existing Notification type.

By default, observers of types conforming to NotificationCenter.Message will be observed on MainActor, and other isolations can be expressed as well:

struct EventDidOccur: NotificationCenter.Message {
    static var name: Notification.Name { eventDidOccurNotification }
    static var isolation: EventActor { EventActor.shared }
}

This information is used by the compiler to ensure isolation:

NotificationCenter.default.addObserver(EventDidOccur.self) { message, isolation in
    // This is bound to the isolation of EventActor as specified by EventDidOccur
}

When a NotificationCenter.Message shares its name with an existing Notification, its observers will be called when either NotificationCenter.Message or Notification is posted. To make this behavior transparent to any observers, you can optionally define a static makeMessage(:Notification) method to transform the contents of a posted Notification into a NotificationCenter.Message:

struct EventDidOccur: NotificationCenter.Message {
    ...
    
    static func makeMessage(_ notification: Notification) -> Self? {
        // Transform notification.userInfo? into stored properties, etc.
        guard let contents = notification.userInfo?["contents"] as? String else {
            return nil
        }
        
        ...
    }
}

You can also offer the reverse, posting a NotificationCenter.Message and transforming its contents for observers expecting the existing Notification type, e.g. observers in Objective-C code:

struct EventDidOccur: NotificationCenter.Message {
    ...
    
    static func makeNotification(_ message: Self) -> Notification {
        // Transform stored properties into notification.object? and notification.userInfo?
        return Notification(name: Self.name, object: message.someProperty, userInfo: ["payload": message.someOtherProperty])
    }
}

Here's an example of adapting the existing NSWorkspace.willLaunchApplicationNotification Notification to use NotificationCenter.Message:

extension NSWorkspace {
    // Bound to MainActor by default
    public struct WillLaunchApplication: NotificationCenter.Message {
        public static var name: Notification.Name { NSWorkspace.willLaunchApplicationNotification }
        public var workspace: NSWorkspace
        public var runningApplication: NSRunningApplication

        init(workspace: NSWorkspace, runningApplication: NSRunningApplication) {
            self.workspace = workspace
            self.runningApplication = runningApplication
        }

        static func makeMessage(_ notification: Notification) -> Self? {
            guard let workspace = notification.object as? NSWorkspace,
                  let runningApplication = notification.userInfo?["applicationUserInfoKey"] as? NSRunningApplication
            else { return nil }
            
            self.init(workspace: workspace, runningApplication: runningApplication)
        }
        
        static func makeNotification(_ message: Self) -> Notification {
            return Notification(name: Self.name, object: message.workspace, userInfo: ["applicationUserInfoKey": message.runningApplication])
        }
    }
}

This notification could be observed by a client using:

NotificationCenter.default.addObserver(WillLaunchApplication.self) { message in 
    // Do something with message.runningApplication ...
}

And it could be posted using:

NotificationCenter.default.post(
    WillLaunchApplication(workspace: someWorkspace, runningApplication: someRunningApplication)
)

Read on

You can read the full pitch including the detailed design in the pull request on the swift-foundation repository.

38 Likes

A couple of questions:

  1. Why are the requirements for the message protocol static methods rather than a failable initializer (for makeMessage) and an instance property/method (for makeNotification)?
  2. Would it make sense to add a type-safe func message<M: Message>(ofType: T.Type) -> some AsyncSequence<M, Never> sequence to NotificationCenter, perhaps only when the message type is Sendable?
1 Like

We did some exploration down the path of initializers and that ended up causing a problem; we felt it was beneficial to keep the definitions approachable such that there was a default implementation, but we also felt that allowing messages to be constructed via member-wise initialization was also important. Those two constraints mean that requiring an init?(_ notification: Notification) would potentially violate both of those desires. The make* methods eschew that requirement, and are only exposed for the folks who need to have a more advanced construction. Common use cases won't need to implement either, complex use cases like large frameworks like AppKit however may need that behavior. So we felt that it was the correct tradeoff of progressive disclosure.

1 Like

I like it. +1.

What happens if an old style post posts on the wrong isolation? Does the app crash?

As an implementation detail, we're planning on using Actor.assumeIsolated, which would cause a crash if the isolation assumption is incorrect, but this detail could change to a different mechanism in the future.

assumeIsolated will make concurrency bugs readily apparent and reduce the possibility of hard-to-diagnose, subtle isolation errors.

1 Like

It might! For the moment, we're focused on establishing the basic model of a concurrency-safe NotificationCenter, and can consider adding this functionality later on.

1 Like

That necessitate knowing the instance of the actor at runtime. An alternative way to capture that info is

     @available(FoundationPreview 0.5, *)
     public func addObserver<MessageType: NotificationCenter.Message>(
         _ notification: MessageType.Type,
         @_inheritActorContext observer: @Sendable @isolation(any) @escaping (MessageType) -> Void
     ) -> ObservationToken

By leveraging @isolated(any), you can call observer.isolation to retrieve the isolation at runtime in your implementation. This value could be MainActor.shared, or some other actor instance, therefore, 2 separate signatures are (probably?) no longer needed.

2 Likes

Because notifications are, by their nature, "push", and AsyncSequence is, by its nature, "pull", this necessitates talking about buffering (something which the existing NotificationCenter AsyncSequence APIs ignore, making it very difficult to know whether a given use is safe).

Still, based on the proposal, you can more or less add the functionality in an extension yourself:

extension NotificationCenter {

    func notifications<MessageType: NotificationCenter.Message & Sendable>(
        _ notification: MessageType.Type,
        bufferingPolicy limit: AsyncStream<MessageType>.Continuation.BufferingPolicy = .unbounded
    ) -> some AsyncSequence<MessageType, Never> {
        AsyncStream(bufferingPolicy: limit) { continuation in
            let token = addObserver(notification) { message, _ in
                continuation.yield(message)
            }
            continuation.onTermination = { [weak self] termination in
                self?.removeObserver(token)
            }
        }
    }

}

I'd just be a little concerned about edge cases here (eg. the stream should finish if the notification center is deallocated, but adding that isn't trivial)

(OTOH, maybe that means it should be folded into the proposal, for parity with old-style notifications, and so that the edge cases are correct)

There's another big caveat using an async sequence and I'm glad it's not in the pitch, it's that you are observing things with a delay, not synchronously as the notification is posted. It means that by the time you are handling the notification, the state that the notification represents might have changed already and not be valid anymore. It's a big source of potential bugs. Be very careful here.

10 Likes

The approach to isolation here is fascinating! I have not seen something like this used anywhere else.

It certainly seems like @isolated(any) + @_inheritActorContext could work very well for this use-case. It might even make it possible to remove the MainActor special casing. Did something not work with that pattern?

Also, the type of #isolation is (any Actor)?. So, I guess you cannot post notifications from a non-isolated context?

2 Likes

Great enhancement to the API of NotificationCenter. I like how new Message brings more structure and ability to provide typed info.


That could be a good future reference for designing libs API to support dynamic isolation.

A nonisolated context results in a nil value of #isolation.

Im a bit iffy about resorting to assume here. I would suggest exploring the space of isolated(any) and #isolation some more and reporting back whatā€™s lacking. Assume is a bit of an last resort ā€œbecause we lost the informationā€ and itā€™s better to try to not lose the the isolation information to begin with :thought_balloon:

2 Likes

This looks pretty cool, not only we will have typed notifications :heart: but also concurrency isolation.

Now on that topic, at a first read is not clear to me how the isolation guarantees work when the Message type itself declares an isolation, but the post method can specify a different one. I understand that having both sides define the isolation is to avoid concurrency jumps (awaits) so we get sync delivery?

I wonder what happens if given the type in the proposal

struct EventDidOccur: NotificationCenter.Message {
    static var name: Notification.Name { eventDidOccurNotification }
    static var isolation: EventActor { EventActor.shared }
}

I endup calling

let notTheSharedActor = EventActor()
NotificationCenter.default.post(
    EventDidOccur(...),
    isolation: notTheSharedActor 
)
2 Likes

The isolated(any) + inherit would not model the correct or expected behavior of NotificationCenter. There is an expectation that some Notifications are synchronously posted to state changes. What you would be suggesting is tantamount to making the observer itself asynchronous unilaterally. That will break in a number of use cases (especially with notifications about UI).

It would also force all messages to require Sendable. Which is also less than ideal; since some types that are ferried in notifications are inherently bound to specific actors (namely the main actor).

I don't think this is true. What I suggested put no obligation on any party to call or define async code.

You were suggesting that the observer is marked as isolated to the caller, but the problem is that the existing notifications in the system are not isolated to the caller per-se; by definition they are isolated to the poster. The proposal as listed defines the messages as isolated to the poster and lets the observer be registered with that explicit contract. By forcing the observer to be isolated to the caller it means the distribution of that message will need to switch from a potential different isolation to the caller of the observation. Which behaviorally would break some notifications (not all).

Got it. Having the generic argument store the isolation makes it possible to force the observer to isolate to the same place. In contrast, my suggestion does not achieve that. :+1:

Indeed, it's unfortunately the case of not having the information in the first place (e.g. a post() call from Objective-C) where we're using assumeIsolated to validate the concurrency contract.

2 Likes

I LOVE this pitch. Iā€™ve often wished for a more type-safe NotificationCenter. And the more I work on adopting Swift concurrency, the more I wish for an update which really integrates nicely with Swift concurrency. I agree that it would be really nice to find a way to work without assumeIsolated when weā€™re working entirely in Swift.

I have a few questions:

Could you talk a little more about why you think the correct default isolation is MainActor? Itā€™s not that I disagree necessarily, so much as Iā€™d like a little more context on the why because it doesn't seem obvious to me.

One thing I donā€™t understand is how this works when the Message/Notification is issued from something other than a particular actor. So for example, many Notifications in Appleā€™s SDKs are documented not to post on any particular thread (e.g. AVPlayerItem.timeJumpedNotification). Many of the old-style Notifications in an iOS app I maintain are posted from a DispatchQueue used for background processing. Iā€™m a little behind on Swiftā€™s isolation terminology, but I think both of these cases would count as ā€˜non-isolatedā€™. If I were to convert these to Message (or use the interop feature), what would I use for the isolation property? I donā€™t think thereā€™s anything I can use, since itā€™s non-Optional.

Final question is about observing these Messages and wanting to call functions in other isolations. What if I have a Message which is bound to some actor and in response Iā€™d like to call a function on the MainActor? Say to show some UI about it. Is the only way to do this to start a new top-level Task in the observation block? I get the desire to make sure observation functions are synchronously run when the desired isolation of the observer is the same as the isolation of the poster, but it seems weird that the new Swift concurrency API requires me to start a new Task here where the original API would let me just specify an OperationQueue to run the observer on.

2 Likes

post() is a generic function with a type parameter that conforms to Message, and specifies that the function parameter isolation must be the concrete type underlying Message.Isolation.

So in this case, you'd be attempting to pass notTheSharedActor to a parameter that takes type EventActor, and the compiler would report an error.