How does swift annotate that DispatchQueue.main.async runs on @MainActor but DispatchQueue.background.async doesn't

I haven't found any definitive documentation around this - so please point me at that if it's available!

Somehow, the compiler 'knows' that

        DispatchQueue.background.async {
            //this does not run on the main-actor
        }

        DispatchQueue.main.async {
            //this does run on the main-actor
        }

I can't see any clues in the headers as to how the two cases are annotated differently. They both just show as DispatchQueue.

I have a similar case in my own API where the user can set the queue for a notification to callback.
I'd like to mark that the block runs on @MainActor if the queue is .main - but I can't see how to do that.

e.g.

func register(on queue:DispatchQueue,callback:<if queue is .main @MainActor> ()->Void)

2 Likes

What is DispatchQueue.background and what exactly do you mean by "compiler 'knows'"?

sorry - I'm so used to using my own extensions!

public extension DispatchQueue {
    static var background:DispatchQueue {
        get {
            return DispatchQueue.global(qos: .background)
        }
    }

By 'knows', I mean that it emits warnings if the block runs code that requires @MainActor access, and has been run by .background

I have the following flags turned on:
OTHER_SWIFT_FLAGS = -Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks

the header gives me the following for async:

public func async(group: DispatchGroup? = nil , qos: DispatchQoS = .unspecified, flags: DispatchWorkItemFlags = , execute work: @escaping @convention (block) () -> Void)

but when used with .main, it is as if the method is:

public func async(group: DispatchGroup? = nil , qos: DispatchQoS = .unspecified, flags: DispatchWorkItemFlags = , execute work: @escaping @convention @MainActor (block) () -> Void)

There's indeed some magic inside. It's not very deep/thorough though, probably some simple heuristic in there:

func foo() {
    DispatchQueue.main.async {
        _ = UIApplication.shared.applicationState // ok
    }
    let mainQueue = DispatchQueue.main
    mainQueue.async {
        _ = UIApplication.shared.applicationState // error
        // Class property 'shared' isolated to global actor 'MainActor' can not be referenced from a non-isolated synchronous context
    }
}

Edit:
Or even this:

    enum DispatchQueue { // let's cheat
        static let main = Foundation.DispatchQueue.global()
    }
    DispatchQueue.main.async { // not real main
        _ = UIApplication.shared.applicationState // ok 😀
    }

so you think 'special case compiler magic' rather than 'using the @MainActor' annotation (or similar) in a way that any API designer can use?

Looks so. I'd prefer some annotation system like @convention(mainActor) to make the check thorough but apparently it's much harder to do. You can still rely on runtime checks though.

You can never rely on runtime checks ;)

I asked myself the same question and today wrote a blog post about it: How the Swift compiler knows that DispatchQueue.main implies @MainActor

tl;dr: it's a hardcoded syntax check in the compiler that looks for DispatchQueue.main.async (and some related APIs). It's not something we can replicate with an annotation.

5 Likes

We've thought about ways to generalize this. For your API, we'd need an annotation (or a heuristic, but since this is a safety property...) to say that the parameter function will always be run on the parameter queue. That would be enough to let us at least use the same hard-coded knowledge that DispatchQueue.main isolates MainActor. That could be generalized to other global actors with some sort of annotation that we could put on any function that says that the return value is an executor that isolates a particular global actor. If we wanted to generalize that to other actors, that gets really tricky.

3 Likes

@ole - love that you dug into this. Thanks for sharing.

I really hate how this is hidden special-casing.
I don't know what the answer is - but I do wish it was explicit.

The main place I see this is in combine. There are a bunch of ways that combine works where I know that stuff will happen on MainActor, but the compiler doesn't

e.g.
publisher.receive(on: RunLoop.main)
.sink {
//this is MA
}

@John_McCall

The only way I can see this working is if there is some kind of actor type passing

func receive<S#SchedulerActor>(
on scheduler: S,
options: S.SchedulerOptions? = nil
) -> Publishers.ReceiveOn<Self, S> where S : Scheduler#SchedulerActor

where specifically Runloop.main is S:MainActor

and that passes on to

.sink {
@SchedulerActor
}

with obviously a massive ammount of handwaving indicating that this is far from thought through...

1 Like

Yeah, the Combine pattern would be very hard for Swift to recognize because it relies on hidden data flow. The easiest way to make it work would be for sink to take the queue to receive events on, or even simply take an @isolated(any) function (a feature I'm currently working on). Or, I suppose, we could special-case it in the compiler, although I think you can imagine why we'd be reluctant to extend that to more and more framework-specific code patterns. The DispatchQueue.main hack, as discussed above, is fairly justifiable as an instance of a pattern that we could reasonably hope to implement in more generality. There's not really a path to that for this Combine pattern.

2 Likes

PLEASE don't special case it in the compiler!

Waay to much of that going on already :slight_smile:

But, a new sink function that combined recieve_on with sink and made the the destination explicit would be a great combine addition.

1 Like