Quiz: Global-actor function types

I'm trying to get a feel of the community's current understanding of casting and executors to help me improve the language. So if you're familiar with Swift Concurrency, then I have a little pop quiz for you. Thanks in advance for your answers!

Without looking up the answer anywhere, which executor do you think will be used for the call to f?

func mystery() async { ... }

func doIt() async {
  let f: @MainActor () async -> Void = mystery
  await f()  // <- this call to `f`
}
  • the generic executor (an arbitrary thread)
  • the MainActor (the main thread)
  • the executor used by the caller of doIt

0 voters

6 Likes

Interesting one.

I'll spoiler my short discussion to avoid influencing other readers:

Click me to unfold: I voted for " the executor used by the caller of `doIt`" because here's what I thought reading this code sample.

My terminology might be completely off, so feel free to correct me.

I don't know anything about the executors in Swift's concurrency model. I know there exist some, but there is always been the talk that we need "custom executors". That makes a bit hard for me to properly describe what happens. doIt is executed somewhere, that "somewhere" is likely the executor that calls doIt, but at the same time this can be any generic executor (an arbitrary thread) so the 3rd option was very confusing to me. mystery function can be executed on any thread as it's not explicitly protected by an actor. Only when we assign the function to f we explicitly state that it should be protected by the MainActor. The call of f should happen on the doIt executor, but if that executor doesn't happen to be the main thread, it will have to wait until it can hop onto the main thread and proceed the body of mystery on the main thread, until finally it returns back to the doIt executor.

I hope that's more or less understandable. :laughing:

6 Likes

This is interesting indeed, which is why I have the need to also chime in here, so spoilers ahead:

Spoiler (though I'm not giving away the correct answer as I do not know it)

I feel like the example is a bit contrived, but that's a good thing in this context.

If I read this as "someone else's code" here's what I'd read into the intention of the author, starting with doIt: They apparently declare a variable for a closure/function, f. By annotating it with @MainActor they communicate that this function/closure is to be executed on the main actor.
Regardless of whether that actually happens, I think there's no other way to read that, every other interpretation would have to be reached by more detailed understanding of how actors, and specifically the main actor are implemented, I dare say. So I think that this is at least how it should behave when we try to "keep easy things easy" here.
After all, when @MainActor is put in front of a regular function that's pretty much what it means: "Regardless from where you call this, it'll be run by the main actor, so feel free to do e.g. UI stuff".

Now in regards to executors I think the main actor is special in that it guarantees to provide a custom executor that corresponds to the main thread, whereas other actors do not. Following my interpretation above that would imply that "the MainActor" is correct, though technically that phrasing does not say anything about executors (contrary to the other choices). Or to put it differently: while the call to f can occur from any executor (the one that doIt runs on), the execution of f (or basically "its body") runs on the main actor.

What strikes me as interesting is that the annotation is given by the local variable declaration inside of doIt. I said the example is (fittingly) contrived, because I assume that is an odd case, specifically for code that should, apparently, run on the main thread. After all, whether that is actually needed depends more on the implementation of f, which is the same as mystery (as reference types like closures/functions are "shared" in this context I muse they're the same thing in this case: f is just a local placeholder). Perhaps inside mystery there's actually things that should not run on the main thread, or it starts awaits something elsewhere anyway, so it's kind of weird that doIt tries to "force" it to run on the main actor with that annotation...

This makes me wonder if my choice in the poll is actually wrong and that @MainActor annotation ultimately does not influence on which executor f/mystery runs... However, this would mean the annotation is basically useless in this context and I guess it should raise at least a warning...?

Sorry for the long rant, but my limited use of async stuff so far in a real project so far made me cheer and welcome @MainActor (at least in front of my functions that update UI after longer asynchronous stuff like some data churning). I basically used it as a nice shorthand for DispatchQueue.main.async and if above example does not translate to DispatchQueue.main.async { f() } I'll have to unlearn that thinking sooner than later... and I don't think I am alone in this.

3 Likes

It was fun to read the spoilers above.

After reading the question:

carefully, I have changed my mind because @MainActor annotation is a trap. :slight_smile:

doIt's executor will call f but the body of f will be executed in the main actor. So I am now voting for: the executor used by the caller of doIt.

1 Like
Spoilers

I don't think Kavon intended the question as a semantic trick about what "used for the call to f" means—I interpreted the question to mean "on which executor will the body of f execute?".

I will admit that the fact this question was asked at all made me doubt my gut reaction, so I answered with what I would have expected if I saw this code in the wild. I don't know much about the implementation here—is the observed behavior due to the line

let f: @MainActor () async -> Void = mystery

doing an implicit conversion to something like:

let f: @MainActor () async -> Void = { await mystery() }

?
I see that this sort of conversion is noted in SE-0316 when dropping a global actor attribute, but I agree it's surprising for it to happen in the other direction. I'd expect the 'direct' conversion and the 'thunk' conversion above to differ from one another precisely in whether the body of f is executed on the main thread or not. ETA: if those semantics would be incompatible with SE-0338 or introduce a soundness hole somehow, then IMO the 'direct' conversion should warn that the non-actor-isolated function may not be run on the actor specified by the annotation. To silence, the user should have to write out the thunk explicitly.

7 Likes

While that's the interpretation that I meant, I intentionally avoided being precise with my question! I think only experts are that precise when discussing the code they're analyzing. So all interpretations of my question are correct.

So to be clear, this isn't a trick question. I just wanted people's gut-feelings about the code that isn't influenced by my opinion or a lengthy description of a particular issue. If I do this again in the future, maybe I should call it a survey. :slight_smile:

The "executor of the caller" means that doIt is guaranteed to use that one by the language, and that function will not impose its own desire for a different executor. So for this answer, if the caller of doIt used a custom executor then it will inherit that and will use that one by default.

4 Likes

Since I've gotten a lot of responses already, by tomorrow I'll close the poll and post my "answer" so that discussion can continue without using the "hide details" thing. :slight_smile:

Thanks for all the votes / discussion thus far!

6 Likes

Don't know the answer so no spoilers here :)

This aspect of async await implementation (swift only? or others as well?) is where async/await machinery is considerably harder to understand compared to promise based implementations, those are completely unaware of and orthogonal to threads/queues, and can work in either threaded or single threaded environments.

Please correct me if I'm wrong, but is there any differences between option 2 & 3?

According to SE-0338, doIt can only run on the generic executor in my opinion, isn't it?

Further thoughts

With the above argument in mind, I came to realize that it's the same for mystery.

So I'm totally confused. What does it mean when you assign an async function to a variable with a global-actor-qualified function type (and other similar situations like passing async-function-type arguments during calls). Please enlighten me...

1 Like

I think the question is invalid. Isolation attributes make no sense for async function types. Every async function is effectively nonisolated, and knows how switch to correct executor inside itself. Isolation attribute is part of the async function implementation, but not its type. I think given example should produce a compilation error.

I'm not so sure about that in principle. I mean, isn't a promise deep-down basically just a callback? Or rather isn't every form of asynchronously/concurrently running code ultimately a form of callbacks? Nothing would prevent the implementer of a promise to call its resolving code on a different thread. And if any objects/values involved are not threadsafe, but passed between these blocks of execution, you can encounter problems.

Or am I influenced too much by JS here? That's where I personally used promises myself first, and I immediately thought "oh, so it's a callback". The funny thing of course is that in JS you cannot run into these problems as there is only one thread in the first place. You can schedule/delay code with the runtime (the browser), but you cannot say "run this on a second thread".

Maybe I am wrong here, but with concurrency we can only try to make it easy to use and easier to (slowly) understand the issues, but we cannot fundamentally change that it is a complex thing. :smiley:

Can't wait for you to explain this, @kavon, it's a very important thing to discuss, I think. :+1:

It is!

If implementer wants so...
I mean it is possible to write this as a fully single threaded app using promises (pseudo code):

func foo() Promise<Int> {
    Promise { p in
        p.value = 123
    }
}

func bar() {
    foo().await { value in
        print(value)
    }
}

bar()

but if you try to do the same with swift's async/await it is inherently multithreaded:

import Foundation

func log(_ string: String) {
  let s = Thread.isMainThread ? "<Thread: main>" : "\(Thread.current)"
  print("\(string) \(s)")
}

func foo() async -> Int {
  return 123
}

func bar() {
  log("in bar")
  Task {
    dispatchPrecondition(condition: .notOnQueue(.main))
    log("before await foo")
    _ = await foo()
    log("after await foo")
  }
}

log("start")
bar()
sleep(3)
log("done")

outputs:

start <Thread: main>
in bar <Thread: main>
before await foo <Thread: 0x00007f0bfc0e2480>
after await foo <Thread: 0x00007f0bfc0e2480>
done <Thread: main>

I don't know if async/await implementations in other languages share the same behaviour.

1 Like

This is not technically accurate. Swift's implementation of async/await can run on a single thread. There is an environment variable useful for debugging to force this behavior. The intent is to stress-test your app to make sure you didn't create any deadlocks, and that your async code can always make forward progress.

7 Likes

SwiftWasm uses this for JavaScript apps since without creating any web workers, the JS event loop only uses one thread.

The short summary is that the cast that adds the global-actor @MainActor to an async function as in the example is currently permitted but will not influence the executor used by mystery. So I think the cast should be either disallowed or the implementation should change to match the expectations of readers. I wanted to have a little fun so I went with a poll to gauge the current reading of the code, whose results are as I expected.

Longer discussion:

The executor that will be used for the call to f (to be more precise, used by default in the body of f) in Swift 5.7+ is the generic executor as described in SE-338. Prior to that, async functions like mystery that are not otherwise isolated to an actor would "inherit" the executor of its caller. That's a subtle distinction that matters when considering type conversions that add actor isolation, such as the one in the example, with respect calling conventions.

If we had a synchronous function, then SE-316 permits the addition of a global actor through a cast:

func enigma() { ... }

func doIt() await {
  let g: @MainActor () -> Void = enigma
  await g()
}

Since enigma is an ordinary Swift function that cannot perform suspensions and may be totally unaware of concurrency, the calling convention is that we switch to the MainActor's executor prior to entering g. This means that ordinary, non-async functions have the policy of "inheriting" executors. That's totally fine and the natural way to go about it for these non-async functions. But, functions having no control over the executor they run on has been a source of problems. That's been solved now, because I can mark enigma as @MainActor and that becomes part of its type that can't be dropped, per SE-316.

For async functions, we started with that policy of inheriting executors too, but that led to the problems discussed in SE-338. Now, async functions will default to switching, if required, to a generic executor within the prologue of the underlying function (like mystery).

Thus, as of now the "standard" way to perform the conversion in the original question by wrapping it in a thunk is not enough, because if I were to rewrite await f() with this:

await { @MainActor () async in await mystery() }()

mystery won't inherit the executor of its caller. I'm still working to understand if there will be any issues banning the cast, but for now I am considering adding a warning about it.

I'd be interested to hear people's thoughts on this. Several of you already figured "it" out or were close, and so I'll go ahead and respond to a few points already made:

This is a really excellent way to describe the problem I'm trying to address. It's not so simple to say that the cast should continue to be allowed, because we can fall back into the trap of allowing the caller of a function to override the executor requirements of a callee. We don't want to go back to assert(Thread.isMainThread) everywhere!

Right now it has no effect beyond ensuring the parameter and result types of the function are @Sendable.

Agree, though the explicit thunk still won't influence the callee.

Async/await is just a building block for concurrency and used to implement actors and executors. As others have mentioned, you can use Swift concurrency in single-threaded mode for debugging using an environment variable or build the runtime system in that mode. Part of the goal of Swift concurrency is that you shouldn't have to worry about thread management for your async tasks. Let the runtime system's scheduler handle that for you. If you have a use case where this doesn't work for you, then custom executors may provide the flexibility you need. We haven't yet fleshed out what those will look like yet, so feedback will be appreciated in a separate discussion.

I've corrected your example to help show how you can have both task run on the main thread:

Details

Your example has two issues. The first is that you're using Thread.sleep, but that will block the thread and will not allow other tasks to be scheduled on it. I believe there are plans to emit a warning when using Thread.sleep in an async context to help prevent that. The second is that when the program exits, tasks are not implicitly awaited for their completion.

Here's a slightly modified version that uses the main actor as an executor that will be shared between the two tasks (one task is implicitly created for the top-level code to be executed, and another within bar).

import Foundation

func log(_ string: String) {
  let s = Thread.isMainThread ? "<Thread: main>" : "\(Thread.current)"
  print("\(string) \(s)")
}

func foo() async -> Int {
  return 123
}

func bar() -> Task<Void, Never> {
  log("in bar")
  let t = Task { @MainActor in
    dispatchPrecondition(condition: .onQueue(.main))
    log("before await foo")
    _ = await foo()
    log("after await foo")
  }
  return t
}

log("start")
let task = bar()
await Task.sleep(3)
print("finished sleeping. see if we need to await the task.")
_ = await task.result
log("done")

Since the main actor corresponds to the main thread, this is an example showing how thread suspension and sharing can happen for tasks. The code above can have two different outputs, depending on whether the scheduler decided to run bar's task before awaiting its result or after.

start <Thread: main>
in bar <Thread: main>
before await foo <Thread: main>
after await foo <Thread: main>
finished sleeping. see if we need to await the task.
done <Thread: main>

vs

start <Thread: main>
in bar <Thread: main>
finished sleeping. see if we need to await the task.
before await foo <Thread: main>
after await foo <Thread: main>
done <Thread: main>

The reason why the scheduler can run the task before awaiting its result is exactly because I used Task.sleep, which has an await there. That await means the task may be suspended, so another task can be run on that thread.

I think it's OK that isolation be described in a type. It allows for the valid conversions I mentioned earlier. Also, it allows you to write more generic code for actors, such as:

protocol Dancer: Actor {
  func moveHips(_: Int)
}

func getJiggyWithIt(_ dancer: isolated any Dancer) {
  dancer.moveHips(20)
  dancer.moveHips(10)
}

let theType: (isolated any Dancer) -> () = getJiggyWithIt

To call getJiggyWithIt, you have to pass in an actor instance conforming to that protocol. Whether it’s async only determines whether it can suspend or not (influencing the atomicity with respect to use of its executor). Bringing that back to the original topic, this basically means you're passing in the executor to be used as well! That's an interesting contrast to how global-actors work, because the executor is not "passed in" for functions isolated to those actors.

5 Likes

It is ok for isolating attributes to be part of the type of sync function. But for async function isolation is an attribute of the implementation, erased from the type.

As you mentioned yourself, sync and async functions have different calling convention in Gerard’s to who is responsible for switching. For sync functions it is the caller, so the caller must know which executor to use, and this is encoded in the function type. For async functions - it is the function prologue, and from the caller’s perspective there is no difference between functions isolated on different executors.

I think I have fallen into this with a piece of code I was writing the other day. Consider the following. Assuming a global actor is defined:

@globalActor actor MyActor {
	static let shared = MyActor()
}

And there is class, in which a couple of methods that need to execute within this context:

class MyClass {

    @MyActor func doSomething(undoManager: UndoManager) {

        // Do something here
   
        undoManager?.registerUndo(withTarget: self) { 
            $0.reverseSomething(undoManager: UndoManager)
        }
    }

    @MyActor func reverseSomething(undoManager: UndoManager) {

        // Do the reverse of something here

        print(\(Thread.isMainThread) /// Prints true when called from undo stack
   
        undoManager?.registerUndo(withTarget: self) { 
            $0.doSomething(undoManager: UndoManager)
        }
    }
}

Assume the code gets called from a SwiftUI view:

struct MyView: View {
   @Environment(\.undoManager) private var undoManager: UndoManager?
   let myObject: MyClass

   var body: some View {
        Button("Do something") { myObject.doSomething(undoManager: undoManager) }
   }
}

Note that when the action is undone the 'reversing' func it is called on the MainThread.

It didn't quite line up my, perhaps unwarranted, expectation that the compiler would have issued a warning that the reversing func needed to be called from a MyActor context.

Sorry not to add more to the debate of the pros / cons of the situation - I am not qualified. But I just wanted to give an example of how an average, likely below average programmer, was caught out by the current convention. Happily the Thread Analyser had my back covered!

Right, but I think it’s still very helpful to be able to ‘see’ in the additional await the fact that @MainActor is applying to the thunk and not the underlying async function.

That said, is there fundamental soundness issue with this conversion in all circumstances, or is it just a pattern that we would want to discourage/disallow? I don’t immediately see something strictly wrong with allowing this conversion when the parameter/result types are Sendable and guaranteeing that the execution of (say) mystery would at least begin on the main actor. The reasons given for changing the default behavior in SE-0338 would still be addressed: the ‘overhang’ isn’t really applicable anymore (since the directive to run on the global actor is explicit and the actor will be given up at the very next default async function call), and the isolation issues are addressed by being (perhaps overly) conservative and disallowing any non-Sendable accesses.

I can’t think of any legitimate use cases for guaranteeing the actor on which an async function will start executing, though, so perhaps disallowing/warning about the confusion here is better.