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.