[Pitch] Clarify the execution of non-actor-isolated `async` functions

SE-0296 (async/await) never specified where non-actor-isolated async functions run; the proposal was already quite large without specifying those details. SE-0306 (actors) necessarily gave some rules about how actor-isolated async functions run, but it never quite specified the interaction with non-actor-isolated async functions. There's a pitch for custom executors for actors, but that's also only about actor-isolated functions. SE-0302 (Sendable) implies some rules about how these functions execute if we want to avoid data races, but it's not clear that those implied rules are even implementable, much less what we actually want. And there are known bugs which arise because of the current implementation, in which such functions merely continue executing on whatever executor that happen to be on. These bugs include the potential for data races due to the interaction with Sendable checking. Clearly, something here needs to be fixed.

I propose that we specify that non-actor-isolated functions do not inherit the executor of their caller. Their bodies will instead run on a "generic" executor unassociated with any actor. This switch will be optimizable (statically and dynamically) just like switches between actors are. This rule has the distinct benefit that it is always statically known what executor a particular async function will run on.

This new execution rule implies that async call arguments must be Sendable unless the callee is known to have the same actor isolation as the caller (which includes neither being actor-isolated). This is a change to the current rule, in which async call arguments must be Sendable only if the callee is known to be isolated to a specific actor that the caller is not isolated to. This change is necessary in order to eliminate data races which can otherwise happen if a non-Sendable value that is isolated to an actor is passed to a function that (dynamically) suspends and then uses the value after the suspension, potentially concurrently with the actor.

Here is the concrete proposal. As proposed, this is fixable without ABI changes; however, it does require async function bodies to be recompiled to avoid the possible data races. (We do not think there are any such races with system-provided async functions on Apple OSes, where recompilation would not be possible.)

23 Likes

Thanks John. This seems like a common-sense solution to a subtle hole in the current spec. +1.

1 Like

I'm still digesting the proposal, but is there a reason why functions that aren't explicitly actor isolated shouldn't just run on the MainActor? Most code today implicitly runs on the main queue anyway, so it doesn't seem like a big change, it would match most users expectations, and it would be compatible with existing synchronous code that's moved into an async function.

There's basically two camps on the subject of "should the main thread be the default". One says it's safe and familiar so we should do it, and the other says that it's the only thread in the app where running stuff on it directly impacts user responsiveness, so it's the worst choice for any code that doesn't have to be there.

Both camps kinda have a point.

12 Likes

Yeah, I think moving all async functions onto the main actor by default would be really destructive. It would end up serializing almost everything in the program. I understand how you might get there from a Cocoa background, but it's not the right approach.

13 Likes

I mostly agree, except we've already taken the concept of the main queue and applied it to Swift's concurrency feature in the form of MainActor. While MainActor has a specific purpose in apps where it's used to serialize UI functionality, that's not necessarily the case for all possible UI frameworks (many would rather Apple stop putting so much of their frameworks only on the main queue, but I digress), and it's certainly not the case for non-UI programs, where it seems the main actor and the proposed default context are virtually indistinguishable (as far as purpose goes). So perhaps I'm just asking for more clarity around the MainActor vs. default executor vs. other global actors. Or just a way to explain it to users who aren't already familiar with the differences.

On a related note, do we want to expose this new default executor directly somehow? I'm not sure where it would be useful, but perhaps users might want to allow their actors to enqueue work off of the actor executor? I'd say it could be exposed as another global actor, but it's specifically described as a "generic executor associated with no actor", so perhaps we'd need the executor proposal to properly expose it to users.

I'm glad that this has been raised. A couple of weeks ago I spent a bunch of time trying to understand what exactly was inherited and when, and understand how the "executor" was inherited was hard to grasp. I have to admit that my expectation from reading all the async proposals up to date was that the context was fully inherited, like what the alternative proposed mentions. I was surprised to realise that was not the case. (this makes the use of @MainActor quite tricky btw, I've already seen some misuses in the wild. Some people expected it to be inherited and was surprised to see code running on the global thread pool)

I then of course would probably prefer that model, but the arguments against it seem compiling. I wonder if this will uncover more edge cases with actor reentrancy tho.

And about the last alternative considered I don't think I like it. One thing that matters to me is consistency, as it helps learning the system. The proposed solution seems consistent enough which makes it better for learning.

I say it seems because the third rule "when the function returns from an internal suspension" confuses me. It seems to say that nothing is guaranteed? I don't think it matters that much if executors are freed or not during calls, but what it feels that should be guaranteed is in which context the code will run. :thinking: As in if my function is in an actor it should run in the actor executor, if it's not it should run on the "global" one.

Forcing execution on the Main thread is already doable and quite easy thanks to @MainActor. So no need to make it the default thread.

No longer than 2 days ago, I had to reread all the async proposals to find the answer to this basic question:


func longComputation() async {
  // CPU intensive code that take longer than a single event loop and will freeze UI, but does not block on IO or call other async function.
}

// Is there something today that guarantee that this code will not block the main thread ?
Task {
 longComputation()
}

Hopefully, this new change will make it possible.

2 Likes

The proposal says that non-actor-isolated functions will run on a generic executor, not associated with any actor. I'm still getting used to the formal terminology (e.g. "executor") used to describe concurrency, so I wanted to clarify:

If all free-floating async functions are assigned to a single global executor, is that similar to saying that there will only ever be one free-floating async function executing at a time? For example:

func slow1() -> Int {
    for _ in 0..<Int32.max {}
    return 3
}

func slow2() -> Int {
    for _ in 0..<Int32.max {}
    return 7
}

func test() async {

    async let x = slow1()
    async let y = slow2()

    let z = await x + y
    print(z)
}

Will slow1() and slow2() ever run in parallel on two separate threads under the proposal here? If not, is there a supported way that a developer make them run in parallel?

2 Likes

slow1() and slow2() are running in different tasks, so they run concurrently. If your global executor has more than one thread at its disposal, they will likely run in parallel.

I think the confusion here is about the two kinds of executors. The global executor is a concurrent one, and can run multiple tasks in parallel. A serial executor, as one uses for an actor, serializes execution so only one task is running at a time.

Doug

15 Likes

Like some others in this thread, I recently tried to find documentation on which executor would actually run an async func and resorted to testing several scenarios out. I was somewhat surprised that non-actor-isolated async functions would continue running on MainActor executor, but could later switch to the global executor at certain points (i.e. after Task.yield()).

I think it would be easier to understand the behavior if the non-actor-isolated async func ran on the global executor in all cases. Whichever direction is chosen, the documentation should clarify the behavior because developers want to have a clear understanding of how their code will run.

3 Likes

Question on a scenario that is contrived and possibly uninteresting. Do the comments in the code sample sound correct according to the proposal? Just want to ensure I understand how a function can be actor-isolated based on Context Inheritance

@Sendable func foo() async {
    ...
}

actor TestActor {
    func foobar() {
        Task {
            await foo() // foo is NOT actor-isolated, formally runs on global executor
        }
        Task(operation: foo) // foo is actor-isolated, formally runs on TestActor executor
    }
}

foo is not considered actor-isolated in any case. Under this proposal, it would not run on TestActor, regardless of how the Task is constructed.

But the closure in the first Task.init would run in that actor isolation context because the init has a special annotation that makes it inherit it right? That is still valid?

That’s right, so basically semantically that’s what one shall expect:

func foo() async {} // always on global executor

actor TestActor {
    func foobar() {
        // on TestActor…
        Task {
            // on TestActor…
            await foo() // foo is NOT actor-isolated, formally runs on global executor
            // back to TestActor…
        }
}
3 Likes

Thanks for clarifying and makes sense to me ^^

(Side note: that task init should be documented as doing such thing. Xcode source preview doesn’t show the annotation so is super confusing how the behaviour works without looking at the swift source code)

The bit I’m still not comfortable with is the “formally”. I’m not sure what are the implications of that.

1 Like

Ty for clarifying, I was thinking a global func was considered a closure, but infer that only Task.init with inline closures inherit the actor context.

Clearly outside of the scope of this proposal, but it would be extremely helpful if the Xcode debugger showed the name of current executor when in an async context. Right now, you need a certain amount of faith in the runtime and yourself understanding the async rules (which clearly have some nuance). I'm thinking of something like GCD queue labels for executors: com.apple.MainActorExecutor (serial), com.apple.TestActorExecutor (serial), com.apple.GlobalExecutor (concurrent). I imagine there are already proposals about adding better async diagnostics in Xcode.

2 Likes