Async/Await: is it possible to start a Task on @MainActor synchronously?

I'd usually avoid reviving an old thread but I wanted to share a solution here given that I'm probably not the only one who found this page while searching the web for this problem.

As several people pointed out, you can't always run an async task synchronously because it may suspend, during which time blocking the synchronous thread would violate the forward progress invariant. However it IS legal within the rules of Concurrency to start a task synchronously; that is, to execute the first Job immediately, assuming you're already on the correct executor. The only hurdle is that by default, Task.init uses the global executor to enqueue new Jobs, which always causes the job to run on the next tick (NB: not 100% sure of the terminology here so if someone more knowledgeable finds that I mixed up a few terms please correct me.) Eventually Swift will probably have a kosher solution for solving this in the form of Task Executors:

Luckily the Swift runtime already includes a tool to override the global enqueue implementation: https://github.com/apple/swift/blob/98e65d015979c7b5a58a6ecf2d8598a6f7c85794/stdlib/public/Concurrency/GlobalExecutor.cpp#L107

EDIT: In the meanwhile, as it so happens, there's already SPI that does exactly this for us, though the usual warnings around private API usage apply.

With some tinkering, you can build something that utilizes this: see Run tasks synchronously on the Main Actor: https://forums.swift.org/t/async-await-is-it-possible-to-start-a-task-on-mainactor-synchronously/52862/23 · GitHub.

And accordingly, the original problem can be solved:

func calledOnTheMainThread() /* non async */ {
  let task = Task.startOnMainActor {
    print("started")
    try? await Task.sleep(for: .seconds(5))
    print("ended")
  }
  // "started" is printed synchronously!
}
11 Likes