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

Hello,

I could never start a Task on the @MainActor in a synchronous way, even if the task is started from the main queue/thread:

func calledOnTheMainThread() /* non async */ {
  let task = Task { @MainActor in
    // This always run after a hop
    print("started")
  }
  // Here "started" is never printed
}

This is sometimes undesired.

As an illustration, consider this tweet thread: https://twitter.com/Catfish_Man/status/1401617226168369152. It discusses how cool it is to have async functions that can run synchronously. For example, the async function can immediately return a cached value, without introducing any suspension point.

Here I am asking for an extension of this coolness: being able to profit from such optimization not only from an asynchronous context, but also when a task is started from a synchronous context.

I'm not sure this is as sexy as the various optimizations the compiler can bring in a context that is already asynchronous. But it quite sexy from the point of view of the language user, who does not have to deal with transient missing values when such values are readily available (nobody likes flashes of missing content in a GUI, for example).

The feature I look after is possible with various asynchronous frameworks such as LibDispatch, Combine, or RxSwift: they all allow the app developer to make sure that some important code paths are sync, and this is a boon. In LibDispatch for example, one can test if the current queue is the main queue by testing for the presence of a DispatchSpecificKey, and avoid an undesired hop in this case.

This topic was already raised there: SE-0316: Global Actors - #13 by gwendal.roue

Did I miss anything? @Joe_Groff, what do you think?

For the context, I'm currently working on introducing async/await in GRDB, and I'm adapting the demo app for async/await. This allows me to use the new async apis in order to achieve classic tasks, and check the library ergonomics from the point of view of an application developer.

This demo app displays a list of players that come from the database. Each time the database changes, the list updates. This is very classic. To this end, the library provides database observation apis. The async version of these apis is an asynchronous sequence of database changes.

Ideally, the list of players appears on screen already populated from the current database content. This means that it is possible to perform a synchronous fetch of the initial list of players at the time the list is "instantiated". This is the single-responsibility principle in action: the list is responsible for its content.

This is very doable (and highly ranked in the list of required feature set) with raw GRDB apis and its convenience Combine publishers: those can deliver the initial value synchronously when database observation starts. This fits the constraints of both UIKit and SwiftUI, and just makes app developers' life so much easier.

The iterator of the async sequence I'm working on can also emit its initial value immediately. But this advantage is lost whenever this async sequence is started from synchronous app code, because the Task that wraps the sequence iteration always starts too late.

Result: I am unable to have my list appear on screen already populated. There is always this "flash of missing content".

If someone has already met this problem with async/await (I don't think this is GRDB-specific at all), how do you solve it?

I've tried to run the current RunLoop until the Task gets its first value, and this grants me with a "synchronous" Task start. However, while the runloop is waiting, SwiftUI does stuff and ends up in an invalid state. Eventually this ends with a non-working application :sob:

// In order to work around https://forums.swift.org/t/52862, we'll
// block the runloop until the first value is acquired.
// The technique is inspired by RxBlocking.
let currentRunLoop = CFRunLoopGetCurrent()
task = Task { @MainActor in
    var needStop = true
    for try await value in sequence {
        if needStop {
            needStop = false
            CFRunLoopPerformBlock(currentRunLoop, CFRunLoopMode.defaultMode.rawValue) {
                CFRunLoopStop(currentRunLoop)
            }
            CFRunLoopWakeUp(currentRunLoop)
        }
        
        ...  // Handle value
    }
}
CFRunLoopRun()
// <- Success! Here the first value was handled
// But SwiftUI has been broken.

Hi @gwendal.roue! Is this what you're looking for?

await MainActor.run {
  // this code executes on the main actor.
  print("Hello, main actor!")
}
1 Like

Thanks @grynspan! I was using await Task { @MainActor in ... }, and I have to learn the difference between the two variants — oh yes, MainActor.run accepts a sync function.

Yet this won't do it yet, because your suggestion can only be called from an asynchronous context (inside an async function). In order to launch it from a synchronous context (such as in a UIKit or SwiftUI callback, in my specific case), some async Task must be created. And there lies the behavior I'd like to avoid: even if the task is created from the main thread, it won't "start" immediately.


The more I think about it, the more I start to understand that my request is quite unlikely to be ever fulfilled:

// Ideally, the "parallel stuff" only starts as soon as
// the iterator yields execution:
Task { @MainActor in
  for try await value in myAsyncSequence {
    ...
  }
}
// <- here all values that were produced before
// the first suspension point are already handled.

We can imagine that with enough information, the compiler could embed the beginning of the task right into the caller. Yet this will never happen unless the sequence and its iterator are inlinable, and there exists some explicit annotation in the source code that opts in for this scheduling optimization.

Yet I deeply regret that this precise scheduling is almost certainly out of reach, considering this use case has a long history of conscious and explicit support in existing async frameworks (I quoted LibDispatch, Combine and RxSwift before). Really all I can hope for is that a designer of Swift concurrency happens to read this thread, chimes in, and expresses something about the topic - a regret, a hope, a workaround, an acknowledgment.

If there was a way to query “current actor / executor” then what you want is:

  • if on actor “main”
  • run this now
  • else
  • run this later (“main actor”.run {})

That capability could appear along with custom executors I suppose.

Thank you @ktoso. Maybe custom executors will help defining precise interactions with @MainActor, yes. I hope this topic will come back in the design discussions!

We should document the behaviour of MainActor.run() when called from the main actor. :slight_smile:

Maybe MainActor will change its behavior, and start running synchronously when it can, yes.

But if I understand @ktoso well, at the time custom executors become public, we'd become able to write, outside of the stdlib, in our own application or library code, a custom global actor that does what we need:

@MainActor // (sic)
func someSynchronousUIKitOrSwiftUICallback() {
  Task { @UIActor in // (sic) a custom global actor
    for await value in someAsyncSequence {
      // handle value
    }
  }
  // <- Here values that were produced synchronously
  // are already handled, and UIKit/SwiftUI has been
  // correctly instructed.
}

This custom UIActor would play exactly the same role as those existing tools:

They exist for a well identified reason: they help developers program asynchronously within the constraints of frameworks like UIKit or SwiftUI.

In UIKit or SwiftUI, a missed opportunity from one of their synchronous callbacks usually means a degraded experience for the app user or the developer, from a blank frame on screen, to a need to explicitly code against double-taps.

Combine and RxSwift exist independently of UIKit or SwiftUI, and yet they provide the sharp and necessary tools for a seamless integration. I'm very eager to see Swift concurrency fill the gap with custom executors.

I’m not saying anything like any magic treatment of submitting a task. Submitting a task means just that, you’ve enqueued it, at some point it’ll run.

I literarily mean that what wrote out up there in the bullet points as code that anyone could write, if there was this “get actor” and equality check on them:

extension MainActor { 
  static func runNowIfPossibleOrLater(_ body: <<same annotations as Task>> () -> ()) {
    if <<is current actor main actor>> { 
      body() 
    } else { 
      MainActor.run { body() }
    }
  }
}

I’ve not thought this through but this sounds like that you’re asking for — is that right? Tho again, all this has to be discussed and debated a bit more during Swift Evolution. I personally think it’d be reasonable to allow such “get actor” and based on this, we could implement such, and other interesting APIs.

No it is not, because I discuss about tasks started from a sync function. In a very specific fashion: this sync function is a UIKit or SwiftUI callback that does not behave as desired as soon as a thread hop happens (because that's how they work).

Since MainActor.run is declared as an async function, the aforementioned sync function has to start a task, and it is already too late if starting a task always performs a thread hop.

at some point it’ll run

The goal is to turn this "at some point" to "right now because I instructed the program to do so".

For a description of one undesired consequence of this mandatory thread hop, see the above comment.

If thread hopping remains unavoidable in the transition from a sync context to an async context, then Swift concurrency will remain unsuitable for some UIKit/SwiftUI interactions. That's not that bad, since it's not as if we missed alternative tooling. But that's quite a missed opportunity.

It is intentional that Swift Structured Concurrency does not define a mechanism allowing a synchronous function to invoke work on an actor and block until it is complete.

As I recall (and I may be recalling incorrectly) such an operation would require some scheduling mechanism that could block the calling thread on the actor's execution context. That mechanism might be a semaphore but isn't necessarily, depending on whether the actor is bound to a specific thread/queue or not.

For the main actor on Apple's platforms (i.e. where UIKit lives), the main actor is bound to the main thread/queue, and a semaphore is needed to gain the desired effect. Using a semaphore in this fashion is an anti-pattern on Apple's platforms because it leads to priority inversions, deadlocks, and other unpleasant side effects.

It is intentional that Swift Structured Concurrency does not define a mechanism allowing a synchronous function to invoke work on an actor and block until it is complete.

Absolutely nobody is asking for that, and all the awful consequences you rightfully enumerate.

In the previous sample code, it is not written that one waits for Asynchronously produced values. No. It has always been about Synchronously produced values that are currently thrown in with the bath water because all tasks induce a thread hop in the transition from a sync function to an asynchronous context.

Maybe one has to experience the pain, one has to understand why there exists so many solutions to this problem in other async frameworks, in order to see what I'm talking about.

This can be a fun game: please try to feed a SwiftUI view from an async sequence, with two constraints:

  1. The iteration of the async sequence must start from that view (so that the view is standalone).
  2. The first rendering is performed with the first sequence element. In other words, we avoid an initial "blank" rendering, as the view is waiting for the first element of the sequence. (This avoids undesired rendering glitches)

This can be done with LibDispatch, with Combine, with RxSwift, nearly all async frameworks out there, when one takes care of building a sequence that produces its initial value synchronously. But this can not be done, as far as I know, with Swift concurrency and async sequences. I regret it, and if I'm the only one discussing it in these forums, I'm certainly not alone regretting it.

1 Like

Perhaps I'm not understanding the problem correctly? It seems like you want the Standard Library to supply something like @ktoso's runNowIfPossibleOrLater() function so that SwiftUI can avoid presenting a blank view before the data becomes available?

I don't think that such a function is the right approach when working with SwiftUI, but there's nothing stopping you from using runNowIfPossibleOrLater() as Konrad wrote it.

The "right" approach is probably to have SwiftUI work seamlessly with Structured Concurrency. My understanding is that View.task(id:_:) is the right way to attach asynchronous work to a view:

@State var favoriteFood: String = "Fetching..."

var body: some View {
  VStack {
    Text(favoriteFood)
      .task(id: favoriteFood) {
        favoriteFood = await getFavoriteFood()
      }
    Button("Set Food") {
      Task {
        await setFavoriteFood(...)
      }
    }
}

Yes there is a misunderstanding - surely because I can't explain myself correctly.

When a view starts a task, as in your above example, the view currently MUST display a transient state, even if the task completes very quickly (and could complete synchronously if the runtime would allow it).

In this transient state lies many bad things:

  • Visible flashes of missing content. QA reports: "When the user profile appears, the user avatar picture does not show up immediately". Developer thinks: "but the avatar is cached: we should be able to display it immediately! All right, let's ask my coworker to replace this async function with a callback-based api, or a Combine publisher, so that I can fix this ticket.")
  • Undesired animations QA reports: "List contents should not animate in the initial appearance of the screen". Developer thinks: "I could handle make a special case of the initial appearance, but the real problem is that I can't immediately setup my view with its initial content, so that animations only trigger for future updates. All right, let's ask my coworker to replace this async sequence with a callback-based api, or a Combine publisher, so that I can fix this ticket."
  • Poor-quality SwiftUI previews. Developer 1 reports "Xcode preview is empty, even though the data should be readily available. When I hit the run button the content appears, though, but this is slow and ruins the experience". Developer 2 thinks: "I'm fed up with those async tasks. The language is not production-ready yet and we switched to async/await much too early. What a pity @groue was not listened to with more attention at the time he was reporting all this pain, with a luxury of details, of the Swift forums".
  • Etc
3 Likes
Terms of Service

Privacy Policy

Cookie Policy