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

I can't shake the thought that perhaps ideally any @MainActor function should be allowed to have await keywords directly in its body despite not being itself marked as async - with the assumption that doing so would simply run the operation on the main actor and suspend the main actor for other actors as-required.

    @MainActor
    override func viewDidLoad() {
        super.viewDidLoad()

        self.textField.text = await self.model.name
    }

This would make it so much easier to integrate asynchronous functions into existing synchronous paradigms; where there are common requirements that the synchronous function be suspended until data lookups complete in order to populate the view correctly before rendering.

I'd love to learn what, if any, issues exist that prevent us from adding this to the language.

I am also rewriting some UIKit code to use async/await instead of callbacks, and I am running into this problem. Again, this is not about waiting for asynchronous results, it's about allowing the initial synchronous part of a Task to run synchronously up until the first suspension point.

Basically, to illustrate, this is well-defined:

print("1")
await printTwo()
print("3")

func test() async {
    print("2")
    await something()
    whatever()
}

It will print 1, 2 and then 3. However, if we are in a synchronous context, we need to add a Task:

print("1")
Task { await printTwo() }
print("3")

func test() async {
    print("2")
    await something()
    whatever()
}

This can print either 1, 2, 3 or 1, 3, 2, depending on scheduling. It would be extremely nice if both of them were defined to always print 1, 2, 3.

To illustrate why this is so useful in UIKit:

@objc func buttonPressed() {
    imageView.image = nil
    Task { imageView.image = try await loadImage() }
}

func loadImage() async -> UIImage {
    if let image = cache.getImage() {
        return image
    } else {
        return try await loadImageFromNetwork()
    }
}

What we want here is that if the image is in fact available in the fast synchronous cache, imageView.image should be set to nil and then synchronously set to a new image. What actually happens is that imageView.image is set to nil, then the loading task is enqueued, and buttonPressed() exits, and on a later run loop, imageView.image is set to the cached image. This is visible to the user as a quick flicker of the image.

5 Likes

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

Reviving an old thread again :) Using @kabiroberai 's code above I created this as an example. Tested on Xcode 16.2b3/iOS 18.2 simulator.

Using Task to start the callback processing, you'll notice a quick flicker produced by the fact that Task will hop and startAnimating() and stopAnimating() will get called in different main queue layout passes, even though minimal, it's enough to see them for a frame.

Using Task.startOnMainActor I can avoid the loadingIndicator flickers, since by the time we check the status of isLoading, even though callback is async, if it didn't actually suspend, isLoading is going to be false, so I can avoid calling startAnimating() entirely.

AFAIK there is no way to get this behavior in Swift without some sort of context being passed into callback where the API client can signal "hey, start a loader because I'll do some async work". This becomes automatic based on runtime signals on whether the callback suspended.

Not even reasync can help here, since to invoke the reasync call, we'll need to start a new Task which will hop.

Is there any chance startOnMainActor can be made public API?

Yes, I think a general API for starting a task synchronously until the first suspension point should be proposed for the Concurrency library. I don't think there's any reason it needs to be specific to the main actor, and I also don't think startOnMainActor is the best name for this API even if it remained main actor isolated, but those details will need to be fleshed out in the proposal.

10 Likes

@etcwilde any thoughts?

Nothing that Holly hasn’t already said. I’m not currently focused on concurrency and don’t have time to put together a proposal or implementation at the moment though.

Yeah, wasn't expecting you to, just wondering if you had any thoughts regarding this topic. Pretty busy over here as well, but I'll try to work on something during the summer, or if anyone else wants to hack at it, feel free :)

@ktoso has already been thinking about this API and some use cases, and I think the implementation is fairly straightforward because it's a generalization of Task.startOnMainActor. We're anticipating writing a proposal soon. If you (or anyone) would like to collaborate on the proposal with us, feel free to message me! If not, no worries :slightly_smiling_face:

8 Likes