Search code examples
swiftactorcombinepublisher

Best way to call GlobalActor function from NotificationCenter Publisher


I have a GlobalActor with some methods on it that I'm using throughout my app. I'd like to be able to call a function from the actor when I receive a Notification from NotificationCenter, but can't call async functions from sink.

This is what I'm doing now:

class MyClass {
    private var cancellables: [AnyCancellable] = []

    init() {
        NotificationCenter.default.publisher(for: NotificationName)
            .receive(on: DispatchQueue.global(qos: .utility))
            .compactMap { $0 as? SomeType }
            .sink { [weak self] val in
                Task { [weak self] in
                    await self?.someCallToActor(val)
                }
            }.store(in: &cancellables)
    }
    
    @SomeGlobalActor
    func someCallToActor(_ val: String) async {
        await SomeGlobalActor.shared.actorMethod(val)
    }
}

...

@globalActor
actor SomeGlobalActor {
    static var shared = SomeGlobalActor()

    func actorMethod(_ val: String) async {
        ...
    }
}

Calling Task within a closure here feels wrong and potentially race-condition-y. Is this the best way to accomplish what I'm trying to? I've tried receiving the notifications inside of the actor itself but it doesn't change much. The issue is the closure provided to sink is meant to be synchronous so I can't await inside of it.


Solution

  • The only way to get the Actor to do something is to put a message in its mail queue. The actor handles messages one at a time, in the order received. Every message that goes into the queue gets a response. Code can only put a message in the queue if it's willing, and able, to wait around for the response. The sink function can't wait around, it has other things to do (i.e. handle the next incoming messages from a Publisher). It needs an intermediary to do the waiting for it. The Task is that intermediary.

    Note that the actor only prevents race conditions on the actor's state. As your intuition suggests, you could still have the "high-level" race condition of two messengers (two Tasks) racing to see who puts their item in Actor's mail queue first. But within the actor, there will be a strict ordering to the changes made by the two messages. (preventing low-level data races on the Actor's state)

    Unfortunately the order of execution of independent Tasks, like the individual tasks created by your sink, is arbitrary. Your code could process notifications out-of-order.

    To solve the problems you need to serialize the order in which the notifications are received and then delivered to the actor. To do that you need one Task, one messenger, doing both jobs – receiving the notifications and passing them on to the actor.

    NotificationCenter allows you to receive the notifications as an AsyncSequence. So instead of getting messages as a publisher, you could get them from a sequence. Something like this:

    class MyClass {
        let notificationTask: Task<Void, Never>
    
        init() {
            notificationTask = Task {
                for await notification in NotificationCenter.default.notifications(named: interestingNotification) {
                    guard !Task.isCancelled else { return }
                    if let value = notification.userInfo?[0] as? String {
                        await someActor.actorMethod(value)
                    }
                }
            }
        }
    }
    

    Here the Task waits to receive a message from the notification center. When it gets one, it does some transformations on it (pulling values out of userInfo in this case) then it hands the transformed message over to the actor. The notifications arrival is serialized by the async sequence and the task makes sure that they arrive to the actor in the same order.