[Review] SF-0011: Concurrency-Safe Notifications

Hello Swift community,

The review of SF-0011 Concurrency-Safe Notifications begins now and runs through Nov. 19, 2024.

Pitch Thread

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.

What goes into a review?

The goal of the review process is to improve the proposal under review

through constructive criticism and, eventually, determine the direction of

Swift-Foundation. When writing your review, here are some questions you might want to

answer in your review:

  • What is your evaluation of the proposal?

  • Does this proposal fit well with the feel and direction of Swift-Foundation?

  • If you have used other languages or libraries with a similar

feature, how do you feel that this proposal compares to those?

  • How much effort did you put into your review? A glance, a quick

reading, or an in-depth study?

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

13 Likes

Yes, I like this API better! Excited to use it.

This is more of a meta-review, but to be honest I find this code style very difficult to read, especially on smaller screens. There's lots of horizontal scrolling required.

extension NotificationCenter {
    public func addObserver<I: MessageIdentifier, M: AsyncMessage>(of subject: M.Subject,
                                                                   for identifier: I,
                                                                   using observer: @escaping @Sendable (M) async -> Void)
        -> ObservationToken where I.MessageType == M
}

I would much prefer if it were written like this, or some other way that made better use of horizontal whitespace.

extension NotificationCenter {
    public func addObserver<I: MessageIdentifier, M: AsyncMessage>(
        of subject: M.Subject,
        for identifier: I,
        using observer: @escaping @Sendable (M) async -> Void
    ) -> ObservationToken where I.MessageType == M
}

or hell, I could even go for

extension NotificationCenter {
    public func addObserver<
        I: MessageIdentifier, 
        M: AsyncMessage
    >(
        of subject: M.Subject,
        for identifier: I,
        using observer: @escaping @Sendable (M) async -> Void
    ) -> ObservationToken 
    where I.MessageType == M
}
27 Likes

+1 looks great. I like that you simplified the actor story.

2 Likes

Excited to see these APIs overhauled to fit better into a Concurrency world.

I have some feedback on the updated APIs.

Protocol hierarchy

The goal that we want to achieve here is that certain Messages must be received on the MainActor while others can be received on any isolation domain. To ensure this the proposal creates three protocols:

  • Message
  • MainActorMessage: Message
  • AsyncMessage: Message, Sendable

and two different different shapes of addObserver methods. One that takes MainActorMessages and the other that takes AsyncMessages.

I understand the need for MainActorMessage to be able to constrain the addObserver API and only allow @MainActor isolated observer closures. I am wondering if the MainActorMessage protocol should be constrained to @MainActor as well. The observation is already constrained to the MainActor so do we ever expect MainActorMessages that are constructed off the MainActor but must be observed on the MainActor?

While both post() methods are called synchronously, only the MainActorMessage overload delivers synchronously

However, the above sentence implies that MainActorMessages are delivered synchronously. This can only be the case if they are posted from a MainActor isolated context. So can we please clarify from which isolation we expect MainActorMessages to be constructed and posted and if the above statement is then true in all cases.

Edit:

How is it actually possible that we don't constrain the post method for MainActorMessage to @MainActor? In the end, that method has to synchronously invoke the observe closures that are @MainActor. If the post method isn't itself constrained to the @MainActor how are you calling the closures? Are you jumping through a DispatchQueue.main.sync?

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.

Asynchronous message processing

extension NotificationCenter {
    public func addObserver<I: MessageIdentifier, M: AsyncMessage>(
        of subject: M.Subject,
        for identifier: I,
        using observer: @escaping @Sendable (M) async -> Void
    ) -> ObservationToken where I.MessageType == M
}

The various proposed APIs that observe an AsyncMessage are taking an async observer closure. This seems problematic to me. On what Task is this closure executed? Neither the addObserver method nor the post methods are async so the implementation must spawn an unstructured Task somewhere to run this closure. Implicitly spawned unstructured tasks are very problematic since creating and destroying tasks is not a cheap operation. If this happens on a path that can be triggered by a remote then this can open up a service to denial of service attacks.

IMO these APIs try to accomplish two orthogonal goals

  1. Observation of Messages that are posted on a different isolation then the one which registered the observer
  2. Asynchronous message processing

The former can be achieved by just marking the observer closure as @Sendable and still synchronously invoking the registered observers when post is called. The latter IMO should use a different API shape that uses the current task to run the observer closures. Something like this:

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 API shape has two benefits. First, it removes the need for spawning an unstructured task to run the async closures since the caller is now responsible for providing the async context. This still allows the caller to spawn an unstructured task if they want to but also allows them to use structured primitives instead. Secondly, it removes the need for an ObservationToken since it leverages the scopes that structured concurrency provides.

ObservationToken

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?

11 Likes

Glad to see the proposal is moving forward! Some questions I had with current proposal:

  1. There's no definition of NotificationCenter.MessageIdentifier and NotificationCenter.BaseMessageIdentifier in the "Detailed design" section, although I can somehow know they should be something like below from other example code in the proposal. I think add them to the proposal explicitly should be better.
extension NotificationCenter {
  public protocol MessageIdentifier {
    associatedtype MessageType: Message
  }

  public struct BaseMessageIdentifier<MessageType: Message>: MessageIdentifier {
    public init() {}
  }
}
  1. The proposal is not clearly explain how NotificationCenter.post(_:with:) works. It mentioned "the MainActorMessage overload" but doesn't give definition in code, will this overload annotate with @MainActor? Also, how exactly asynchronous delivery of AsyncMessage works? Since post(_:with:) is called synchronously, it needs create Task for async calls. I think the proposal should clarify how all these things work.

While both post() methods are called synchronously, only the MainActorMessage overload delivers synchronously. Posting an AsyncMessage , as suggested by the name, will result in asynchronous delivery.

  1. Is it possible that users can create their own synchronously delivering message with arbitrary actors? I like the previous design which allows you do this easily, and I don't see how can I achieve the goal in current design.
4 Likes

Is it possible to observe notifications synchronously when posted on an actor that is not the main actor? I'm quite worried about the async variant of the API, it's an endless pit of bugs because the state that is being observed may be already stale by the time it is handled asynchronously. Do we really want this?

3 Likes

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

Also, M isn't needed. This can be rewritten as where I.MessageType: MainActorMessage

6 Likes

Thanks @JuneBash, that is easier to read. @libei made a PR to clean this up and we'll incorporate it in our next revision.

2 Likes

You bring up an important point. Synchronous timing is critical for certain notifications, e.g. the will/did pattern.

No, at the moment, this proposal doesn't include a way to synchronously post/observe with a Message not on @MainActor, though many existing synchronous notifications in frameworks like AppKit and UIKit are on the main actor.

A previous version of this proposal included a generic actor parameter on Message along with overloads of post() and addObserver() that took that generic parameter along with the isolated keyword. This would enable arbitrary actors, both global actors and instance actors, to use this API. However, it ran into some ergonomic issues:

  1. The addObserver() closure had to take an isolated parameter that the caller otherwise did not need.
  2. Calls to post() would have to specify the isolation value even though the correct value is already captured as a generic parameter, e.g. center.post(myMessage, isolated: myMessage.isolation) which also didn't feel great.

Here's a link to that revision: swift-foundation/Proposals/NNNN-concurrency-safe-notifications.md at 3205afdfa1c17a3649cc88a9fc536eee74449a9a · cthielen/swift-foundation · GitHub

We can revisit the need for arbitrary, synchronous isolation in the future as an expansion of this API.

1 Like
  1. The 3 addObserver overloads feel very inconsistent to me. The 2 MessageIdentifier overloads using M.Subject and M.Subject.Type to differ from all objects or specific object observer, but addObserver(_:subject:using:) is still using M.Subject? to do the job. Also they have different naming and parameter order, addObserver(of:for:using:) / addObserver(_:subject:using:), which seems an unnecessary difference. Maybe they can be uniform to same naming.
1 Like

Would notificationCenter.observe(_:) need to be async itself in this case? If not, how does the async body parameter get invoked? How does this suggestion address your concern about an unstructured Task being spawned somewhere?

1 Like

Yes you are right, thanks! I missed adding async throws(Failure) to the return of the observe method.

1 Like

In that case, this looks like it's more similar to the existing .notifications() API that returns an async sequence of notifications:

for await note in center.notifications(named: .someName, object: someObject) {
 // Handle notification
}

No closure needed, and it seems like a type-safe API could be proposed that's more in line with the existing method:

// Hypothetical API:
for await m in center.messages(SomeMessage.self) {
  // Handle notification
}

But I believe (and might be wrong!) that using an AsyncSequence doesn't address one of the motivations of the original proposal, which is synchronous delivery of MainActor notifications.

1 Like

That seems quite a big omission. This means developer using this new API will be forced to use asynchronous delivery for non main actor posted notifications. I wish this was considered right from the start because it seems like the current API will inevitably cause bugs when adopted (not for the main use case, I agree, but still).

4 Likes

+1

This would prevent several existing notifications from adopting this API. For example, notifications posted by NSManagedObjectContext or AVAsset.

In addition, the restriction that the Message.Subject be AnyObject is somewhat limiting. Not all existing notifications have an object, for example UIAccessibility.voiceOverStatusDidChangeNotification. Is there a way to construct a Message where Subject is Void?

4 Likes
  1. Good catch. This will be added after the review. Your example is essentially correct.
  2. Yes, post() should be annotated @MainActor for the MainActorMessage variant. This will be updated as well after the review. My apologies for any confusion from the omission.
  3. This scenario isn't supported by this revision of the proposal. I'll include it as a possible "Future Direction". We believe most uses of Notification today can be mapped to either MainActorMessage or AsyncMessage.
1 Like

I think you may be right. We'll give it a try, e.g.:

public func addObserver<M: MainActorMessage>(
    of subject: M.Subject? = nil,
    for messageType: M.Type,
    using observer: @escaping @MainActor (M) -> Void
)
1 Like

Responding to a few different points of feedback here. Thank you everyone for the thoughtful review so far:

Requiring Subject
Subject enables the compiler to type check that a subject and message match, improving overall API safety. Combined with MessageIdentifier, the use of Subject also improves the discoverability of messages. For messages where there is no logical object, Subject can still be set to some namespace type to help maintain organization.

AsyncMessage vs 'Message & Sendable'
Sendable is often auto-synthesized based on the properties of a type, so it's possible a developer with a MainActorMessage that happens to be Sendable will inadvertently use the wrong overloads of addObserver. AsyncMessage makes this an explicit choice by the Message vendor.

AsyncMessage inheriting isolation from the addObserver site
I'm tempted by this one as well for ergonomic reasons but unfortunately it may be problematic. AsyncMessage can be used to translate an existing Notification posted on a background thread to a Message type. When post() calls the observer closure on a background thread, we would have no way to tell the concurrency runtime to hop back to the inherited isolation. Better to mark the AsyncMessage closure as being in a Sendable, async context, but make no guarantees as to which isolation it is on.

Streaming AsyncMessage
This is an interesting API direction (e.g. center.messages()) and perhaps something we should offer in the future, but is out of scope for this proposal.

Should ObservationToken be non-copyable?
No, we don't need to limit the storage of these tokens. There's nothing in NotificationCenter that requires we limit how they are stored and copied.

Again, thank you everyone for the feedback so far!

1 Like

I think you are bringing up a good point. However, by default the compiler will choose the more specific overload i.e. the one constrained to MainActorMessage. I guess the question that we have to answer in the end is if we want to allow users to consume MainActorMessages that are also Sendable on a different isolation than the MainActor. If we expect that to be a reasonable use-case then I would argue having both MainActorMessage and AsyncMessage is not the right approach.

On a side note, the auto-synthesised Sendable conformance is only inside a single Swift module so I don't expect this synthesised conformance to affect overload resolution much but rather if authors of MainActorMessages also make them Sendable.

If post and addObserver for MainActorMessage are both constrained to @MainActor to guarantee the synchronous delivery then doesn't it make sense to also constrain the protocol? Really the only thing that we keep open is for a message that is constructed off the MainActor but must be posted and observed on the MainActor. Does that really exist?

@cthielen you haven't responded to this part of my review yet and I wanted to highlight it again. Why is the closure for the AsyncMessage addObserver API async? I don't see a good reason for why that is the case and why it isn't sync similar to the MainActorMessage. Making it async means that there is no way to synchronously observe AsyncMessages which IMO is a totally valid thing to do and secondly it forces you to spawn an unstructured task somewhere in the implementation.

2 Likes

AsyncMessage vs 'Message & Sendable'
That's a good question.

One of our design goals is interoperability with existing uses of Notification, which are usually posted on either the main actor or an arbitrary background thread (i.e. whatever thread the poster is on).

When post() is called, the default behavior is for the observer closures to be executed without hopping threads. So, if a framework vends a MainActorMessage, it's expected that the observer closure is executed on the main actor because the post() that ultimately triggers the closure was called from main actor.

That's also the reason MainActorMessage isn't marked as Sendable - it doesn't need to be. Posting non-Sendable contents is fine if one stays on the same isolation.

If I understand correctly, if we allowed a MainActorMessage observer to execute off the main actor, there'd be a mismatch between what was occurring at runtime (they are indeed on the main actor by way of the post()) and what the compiler determined at compile-time, e.g. it would think you're in a different isolation with a Sendable, synchronous closure.

Appreciate the side note, that's a good point.

Constraining the MainActorMessage type to @MainActor
My understanding is that @MainActor on a type only has an effect on where its methods are called. I think you may be right that it won't be common for a developer to construct these types off the main actor (and subsequently use one of its methods), but does that mean we should limit such type methods to only run on @MainActor in scenarios where that may not be the case?

Async Closure on AsyncMessage
Your performance concern is a good one, and we need to update the proposal to document this aspect: NotificationCenter maintains an arbitrary isolation that it uses to call all AsyncMessage observers in serial, so we're able to call these observers without the expense of an unstructured task per observer call.

AsyncMessage is designed for cases where the post() is on a background thread or other isolation. We internally execute the observer within an async context but do not guarantee which isolation it is. This matches the behavior today where a Notification observer runs on an arbitrary background thread, but now AsyncMessage makes this contract explicit, requiring the developer to await if they need to hop back to MainActor or another isolation.

I'm curious if there's another use case you have in mind where an AsyncMessage should be able to be called synchronously, or if the concern is that there isn't a way using MainActorMessage nor AsyncMessage to synchronously call observers that are arbitrarily isolated?