[Review, 2nd] SF-0011: Concurrency-Safe Notifications

Hello community,

The second review of SF-0011: Concurrency-Safe Notifications begins now and runs through Thursday, May 22nd, 2025.

First Review

Pitch Thread

This new version includes the following changes requested during the original review:

  • AsyncSequence APIs for observing

  • Automatic de-registration when ObservationToken goes out of scope

In addition, this new version:

  • Supports Message.Subject conforming to Identifiable where Identifiable.ID == ObjectIdentifier in addition to AnyObject

  • Clarifies use of the post() function by renaming the with argument to subject


Reviews are an important part of the Swift-Foundation evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager by email or DM. When contacting the review manager directly, please include proposal name in the subject line.

Trying it out

Unfortunately, this API is not available in swift-foundation today due to the lack of an underlying NotificationCenter implementation, but we are planning to make it available there in the near term. Stay tuned!

More information about Swift-Foundation review process is available at

https://github.com/apple/swift-foundation/blob/main/CONTRIBUTING.md

Thank you,

Charles Hu

Review Manager

3 Likes

Overall, I'm still +1 on providing these new APIs but there is some feedback from my first review of this proposal that is left unaddressed in the proposed solution or the alternatives considered section.

AsyncMessage protocol vs Sendable constraint on addObserver

From my previous review

Now onto the AsyncMessage type. Do we really need this? Specifically do we need the Sendable requirement on the protocol itself? Wouldn't it be enough if we use the bare Message protocol and constrain the post and addObserver methods to take a some (Message & Sendable) . If we ever get the tools in the language for dynamic isolation e.g. @isoalted(parameter) closures, then we could add an optional isolation property to the Message protocol. In general, I think less protocols is good and IMO the AsyncMessage protocol doesn't seem to carry its weight.

ObservationToken

From my previous review

These addObserver() methods return a new ObservationToken , which can be used with a new removeObserver() method for faster de-registration of observers:

Can you expand on when an observer is de-registered if removeObserver is not called?
Should the ObservationToken be a ~Copyable type so that it can only be removed once and also just isn't dropped on the floor? I assume that currently this is done with a backing class and a deinit. Deinit based resource management is really problematic due to the potential non-determinism it introduces. It can open up server applications to denial-of-service attacks when such denits can be triggered during a request.

Returning AsyncSequence APIs

The new APIs that return AsyncSequences are great but IMO they are a cause for potential non-determinism in programs due to the way AsyncSequcens work. This is related to the above point and I assume that the AsyncSequence does hold an ObservationToken somewhere. Since AsyncSequences cannot be ~Copyable I would encourage the consideration of such a method signature:

extension NotificationCenter {
    public func observe<M: Message & Sendable), Failure: Error, Return>(
        messageType: M.Type,
        isolation: @isolated (any Actor)? = #isolation,
        _ body: (some AsyncSequence<M, Never>) async throws(Failure) -> Return
    ) async throws(Failure) -> Return
}

// This would then allow to observe messages like this

notificationCenter.observe(SomeMessage.self) { messages in
    for await message in messages {
      await process(message)
    }
} 

This way the lifetime of the observation is clearly delimited by the scope of the closure.

Generic type names

I also would like to bring up @dnadoba feedback again.

Can we please give the generic parameters names instead of letters?

    public func addObserver<I: MessageIdentifier, M: MainActorMessage>(of subject: M.Subject,
                                                                       for identifier: I,
                                                                       using observer: @escaping @MainActor (M) -> Void)
        -> ObservationToken where I.MessageType == M
1 Like

Good points!

Re: AsyncMessage protocol vs Sendable constraint on addObserver

(Note that this review is about the additions listed above, not the design of AsyncMessage but it makes sense to add this to Alternatives Considered.)

While Sendable connotes data race safety when moving between isolations, it doesn't connote that any calls made with the type will be performed asynchronously. The timing of message delivery is important in Notification Center, e.g. socketWillClose must be delivered before the socket closes, so it cannot be sent asynchronously. Signifying explicitly whether a type is delivered asynchronously is important. Message & Sendable isn't a 1:1 replacement of the purpose of AsyncMessage.

Re: ObservationToken

The tradeoff with ~Copyable is that such tokens are harder to work with using standard collection types. Single ownership probably makes sense for registration tokens but unfortunately I think it's a bit premature to ship ~Copyable tokens to the general developer audience.

Returning AsyncSequence APIs

The shape of .messages() is designed to match notifications(named:object:) | Apple Developer Documentation.

The lifetime of observation is tied to the lifetime of the AsyncIterator, so while not as explicit as a .observe() closure, observation in something like the follow will be limited to the for loop:

for await message in center.messages(of: someSubject, for: .someEvent) { ... }

Re: Generic type names

I'll get this cleaned up, as well as the indentation. Thanks for pointing it out.

I think that's an interesting point. There is nothing in the type system upholding this and it is purely a contractual thing. It would be good to call this out in the documentation.

I understand the concerns of adopting ~Copyable w.r.t. developer experience. However, using deinit-based clean-up is in my opinion way worse since this can become a security problem. With a ~Copyable type one can simply wrap this into a class Box<T> that gives back the deinit-based clean-up. In my opinion, for a new API that is expected to be adopted across platform and across use-cases we shouldn't introduce new deinit-based cleanups when we have the right tools (~Copyable and with-style methods) available.

I understand that it matches the current messages() API which has exactly the same problems i.e. deinit-based clean-up. In the server ecosystem, we have done exactly the same mistake in the beginning and had APIs return AsyncSequences; however, as we learned those APIs become sources of security risks and unpredictability. Hence, we entirely opted for with-style APIs where the AsyncSequence is provided to the closure to avoid this. Similar to the point above if we want this API to be usable across all platforms and use-cases I think we should provide a with-style API instead.

5 Likes

Is the security problem you're referencing the existence of the deinit itself (whether in a class-backed struct or ~Copyable), i.e. that de-scoping has a cost? Or is it that ~Copyable makes the existence and potential call to such deinit methods explicit and that is therefore safer?

Observation is held by individual iterators, so controlling the scope of the AsyncSequence isn't as important. The iterators contain buffers that are explicit and controllable.

Is the concern that the lifetime of the iterator (and thus the lifetime of observation) is copyable but with-style APIs act as protection to discourage copying the iterators outside their initial scopes?

Message.Subject has no conformance requirements in its protocol, but addObserver() and post() both refine Message.Subject to either conform to AnyObject or confirm to Identifiable where Identifiable.ID == ObjectIdentifier .

Is this identifier at any point converted to the object reference? ObjectIdentifier does not guarantee that it is an identifier of an object that is still alive. The following produces a valid ObjectIdentifier, but it cannot be converted back to the object:

func getID() -> ObjectIdentifier {
    return ObjectIdentifier(MyClass())
}

The messages() sequence uses a reasonably-sized buffer to reduce the likelihood of dropped messages caused by the interaction of synchronous and asynchronous code.

That does not sound reliable. Dropping message can lead to logical bugs. When prioritising between correctness and performance, I strongly prefer correctness first. I would prefer to follow the precedent of the AsyncStream.Continuation.BufferingPolicy. I.e. default is .unbounded, when enabling bounded buffer it is possible to choose which messages are kept and which are discarded.


struct EventDidOccur: NotificationCenter.Message {
    var foo: Foo
    ...

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

Where did the object come from? Is it part of the EventDidOccur? If so, then why is it not parsed in the makeMessage()?


public func post<Message: AsyncMessage>(_ message: Message, subject: Message.Subject)
        where Message.Subject: AnyObject
public func post<Message: AsyncMessage>(_ message: Message, subject: Message.Subject)
        where Message.Subject: Identifiable, Message.Subject.ID == ObjectIdentifier
public func post<Message: AsyncMessage>(_ message: Message, subject: Message.Subject.Type = Message.Subject.self)

Would be nice to be able to express in the Message declaration if it is posted with an instance or a type as a subject and check that post() is used correctly in the compile time.

One possible way to encode this in the type system would be something like this:

public protocol MessageSubject {}
public struct InstanceSubject<T: AnyObject> : MessageSubject {
    public init(_ instance: T)
}
public struct TypeSubject<T> : MessageSubject {
    public init()
}
public struct IdentifiableSubject<T: Identifiable> : MessageSubject where T.ID == ObjectIdentifier {
    public init(_ value: T)
}

public protocol Message {
        associatedtype Subject: MessageSubject
        ...
}

extension NotificationCenter {
    public func post<Message: AsyncMessage>(_ message: Message, subject: Message.Subject)
    public func post<Message: AsyncMessage, T>(_ message: Message, subject: T) where Message.Subject == InstanceSubject<T>
    public func post<Message: AsyncMessage, T>(_ message: Message, subject: T) where Message.Subject == CustomSubject<T>
    public func post<Message: AsyncMessage, T>(_ message: Message) where Message.Subject == ClassSubject<T>
}

These methods do not need to be implemented if all posters and observers are using NotificationCenter.Message .

IMO, this is fragile. This requires looking into implementation of the specific message to understand if it supports interoperability or not. And the fact that interoperability is optional was a surprise for me. Without reading the proposal text, just based on the API itself, I would not have guessed that this is something that I need to check.

I would prefer to either:

  1. Make interoperability required and provide something like EmptyMessage protocol providing default implementations for trivial cases only:

    protocol EmptyMessage: Message {
        init()
    }
    extension EmptyMessage {
        static func makeMessage(_ notification: Notification) -> Self? {
            return .some(.init())
        }
        static func makeNotification(_ message: Self) -> Notification {
            return Notification(name: Self.name)
        }
    }
    
  2. Make interoperability optional, but compile-time checked:

    protocol Message {
        associatedtype Subject
    }
    protocol NotificationConvertible: Message {
        static var name: Notification.Name { get }
        
        static func makeMessage(_ notification: Notification) -> Self?
        static func makeNotification(_ message: Self) -> Notification
    }
    

Why are observers of the MainActorMessage synchronous, but observers of the AsyncMessage are asynchronous? If there are multiple asynchronous observers, are they executed sequentially or concurrently?


If a Message author needs the subject to be delivered to the observer closure, they can do so by making it a property on their Message type.

Or simply capturing the subject in the observer closure.


When an ObservationToken goes out of scope, the corresponding observer will be removed from its center automatically if it is still registered. This behavior helps prevent memory leaks from tokens which are accidentally dropped by the user.

Would be nice to unify this with Combine.Cancellable - it has exactly the same usage pattern, the same operations available (if you consider center.removeObserver(token) to be equivalent to token.cancel()) and would be nice to use familiar store(in:) extension methods. But I do understand that those are two different frameworks, with different ownership models and evolution processes, so this is unlikely to happen.

Also alternative to consider - see enum Iteration in swift-evolution/proposals/0475-observed.md at main · swiftlang/swift-evolution · GitHub.

Appreciate your feedback Nickolas! Some of your feedback is on already accepted portions of the proposal but let me give you a full response:

Re: ObjectIdentifier

You bring up a good point that anyone using the Identifiable variants need to manage their observers, i.e. we have no weak reference to hook into to clean up observers of released objects.

Supporting Identifiable enables additional types to be subjects in Notification Center but is not a replacement for the AnyObject variant.

Re: Buffering

AsyncStream is a more generalized tool than NotificationCenter and may be used to implement a wide variety of sequences.

NotificationCenter tends to have use cases that arrive at 0-1 Hz and are well-served by a small buffer. Some notification use cases only require the latest message, e.g. NSWindow.didResizeNotification, and would work fine without a buffer at all in theory.

NotificationCenter also has an unknown number of observers relative to the poster, so it may not be the right tool for high-frequency, unbounded posting.

But since users can post() at whatever frequency they choose, the buffer size is adjustable, and dropped messages will be logged in case the buffer size is too small or posting occurs at a higher than expected frequency.

EventDidOccur

Good catch! object should not be there.

post() instance vs. type

Is the suggestion here to allow Message authors to control whether Message.Subject can be used with instances or not? If so, what's an application of that control?

Notification Interoperability

The goal is for entirely new messages to only support Message, not Notification, so we don't want to require interoperability. A NotificationConvertible would make it harder to forget the implementations of makeMessage and makeNotification, but fortunately this is only the responsibility of Message vendors, not Message users.

MainActorMessage vs. AsyncMessage

Existing NotificationCenter APIs are often synchronous but convey no concurrency information to the compiler, e.g. an observer may be executed on the main thread or not, and the compiler cannot help. If the poster and observer are in different isolations, there can be a data race risk.

Synchronous posting is only possible safely if both the poster and all observers are on the same isolation, e.g. the main actor. For this scenario, we have MainActorMessage, which is fairly common.

If the poster and observers are on different isolations, you can safely deliver a Sendable Message asynchronously, e.g. AsyncMessage.

A future proposal could introduce something like an IsolatedMessage, to enable synchronous delivery when the poster and observers exist on the same non-MainActor isolation (i.e. a custom actor), but that is not included in this proposal.

The proposal doesn't define how AsyncMessage observers are called.

I understand that. My concern is that usage of the ObjectIdentifier in the feature design will lead to memory unsafe implementation. I could not find implementation in the GitHub - swiftlang/swift-foundation: The Foundation project. Is it not implemented yet, or was I looking in there wrong place?

Does/will concurrency-safe API use NSNotificationCenter and/or CFNotificationCenter under the hood? If so, then it will be have to provide const void *object or object:(id)object at some point. My guess is that implementation will try to do it as unsafeBitCast(subject.id, to: UnsafeRawPointer.self) or unsafeBitCast(subject.id, to: AnyObject.self). I'm not sure about CFNotificationCenter, but NSNotificationCenter definitely attempts to retain the object, and this can be a source of memory errors.

If my guess is correct, then IMO ObjectIdentifier is not a suitable type for the Identifiable.ID. Instead it should be something that maintains strong reference to the object, e.g.:

public struct ObjectReference: Hashable {
    private let object: AnyObject
    public init(_ object: AnyObject) {
        self.object = object
    }
    public static func ==(lhs: Self, rhs: Self) -> Bool {
        lhs.object === rhs.object
    }
    public func hash(into hasher: inout Hasher) {
        hasher.combine(ObjectIdentifier(object) )
    }
}

All notification use cases I can think of either consistently post messages on behalf of an instance, or consistently post them without any instance. My mental model is that presence/absence of the instance is part of the contract. Using instances inconsistently is a bug.

I would like message definition to encode this part of the contract in the type system, and check usages of post() at compile-time.

Makes sense.

As a user, I would like to understand if vendor provides interoperability or not.

We still have a noticeable part of our code base in ObjC. We are migrating it incrementally, but if migration requires too much effort and does not fit into business schedule, sometimes we choose to maintain ObjC code instead.

If I would need to consume a message in an ObjC, I need to understand if using NSNotification API is an option, or subscriber code must be migrated to Swift first.

This can be reflected in the message documentation, but encoding this in the type system is even better.

That's exactly the part I'm interested in. If async observer starts a long operation (e.g. with networking), would it delay delivery of messages to other observers? I think this is important for understanding overall system behavior and needs to be specified.

1 Like

The implementation isn't available yet as swift-foundation first needs a NotificationCenter implementation.

NSNotificationCenter does not strongly hold the object passed in addObserver, though one may see behavior similar to this if the object is captured by the observer block.

Whether ObjectIdentifier or void *, NotificationCenter simply needs a unique (enough) value to filter upon. Address re-use is a concern with NotificationCenter object filtering, e.g.:

do {
    let myClass = MyClass()

    let observer = center.addObserver(forName: notifName, object: myClass, queue: nil) { _  in }
} // myClass deallocs at the end of the block but the observer closure does not

let myClassTwo = MyClass()

// This will incorrectly call the observer closure due to address re-use
center.post(name: notifName, object: myClassTwo, userInfo: nil)

This is probably true for many use cases but enforcing this in the Message definition could prove limiting to valid use cases.

This is similar to today's behavior where a synchronously called observer can block the execution of others, or an OperationQueue variant could block one of the concurrent executors. It's a good practice not to perform long-running work in an observer callback.

The current implementation executes these serially, but that is an implementation detail subject to change. We could also consider a future improvement to provide more control over this.

Thanks everyone for your feedback! The second review period for SF-0011: Concurrency-Safe Notifications is now closed.

The Foundation Workgroup will now take some time to review the feedbacks and the proposal to determine the next steps.

Thanks,
Charles, review manager

Appreciate you pushing on this point.

I think it may be unnecessary after all to include the Identifiable variants (and perhaps problematic since we cannot track the references as you point out).

I do still think moving the AnyObject requirement off the protocol and onto the various methods is a good change as it enables us to support other Subject conformances in the future.

2 Likes

Hello everyone,

The Foundation workgroup has reviewed the proposal and the feedback from the second review thread. They would like to accept this proposal with the following modification:

The API author should explore removing the ID == ObjectIdentifier constraint as it is not needed at this time

Thank you all for your feedback.

Charles
SF-0011 Review Manager

1 Like