Pitch: Revisit the semantics of async main

Swift evolution proposal 0304 Structured Concurrency gives a very short description of how the asynchronous main function should work.

Semantically, Swift will create a new task that will execute main(). Once that task completes, the program terminates.

The runtime function _runAsyncMain currently implements this behaviour, wrapping the call to the user-specified main in a detached task, inside of which we do the try await on the main function. Following the task creation, it calls out to _asyncMainDrainQueue, which sets up a runloop to drain the main queue, executing the enqueued asynchronous main function task in the process.

There are some issues with this design that do not totally encompass all use-cases. Here are the ones I'm aware of:

  • Objective-C initializers may enqueue tasks onto queues before the main function runs. This means that the user-specified main function won't be enqueued first, so it will be run after the initializer tasks. This is confusing and can lead to unexpected behaviour. Instead, I propose that we run the main function up to the first suspension point synchronously and enqueue the remaining continuation. Once we hit the suspension point, we should kick off the runloop to drive the rest of the program. This provides a space where the main function will be run like a normal main function for setup purposes, before any enqueued tasks get executed. Once we hit the suspension point, any tasks enqueued are allowed to run in the order specified by the queue. This is consistent with the messaging around how await works; suspension points allow the runloop to tick. I have an implementation of the proposed behaviour posted on GitHub here: PR 38604.

  • We don't have a consistent runloop mechanism available on all platforms. dispatch_main is available everywhere, but is not always sufficient. CFRunloopRun provides more functionality, but isn't available everywhere. For the time being, I tried to balance this by checking to see if CFRunloopRun has been linked into the process and using that if it has, but otherwise falling back on dispatch_main. This check is only done on Darwin. We should extend the model to allow folks to specify their own main runloop mechanism. I would like to make the main function run on the MainActor, so I think it would make sense to make this an optional function on the main actor. Then the _asyncMainDrainQueue could check to see if it's set and call that. Otherwise it probably makes sense to fall back on the current behaviour as a default. As a change to the MainActor struct, this would need to be pushed off to a future release since I think the MainActor layout is locked down for this version.

  • As a matter of cleanup, the main function should be run on the main actor/main thread. Right now we don't really make this guarantee, but it seems like it would be logical that the main function would run on the main thread.

  • Another matter of cleanup, the main task is initialized with a Default priority. It should instead pull the thread priority using a call to getCurrentThreadPriority from the runtime. On Darwin, this will pick up the QoS from the context that the process is launched from. It will return UserInitiated on other platforms since we are running on the main thread. EDIT: This originally suggested hard-coding it to UserInitiated for all cases, but this will likely be wrong for most processes.

This changes proposed in this pitch are succinctly stated as follows:

  • The main function should run synchronously up to the first suspension point.
  • The main function should be run on the main actor
  • MainActor should provide a user-specifiable alternative to the default runloop behaviour.
  • Make the main task pull the priority from getCurrentThreadPriority instead of a hard-coded Default priority.

I look forward to hearing your thoughts on the matter, in addition to issues that I have not thought of.
Thanks!

13 Likes

The runloop dilemma reminds me a lot of XPC services, which ended up just saying "yeah tell us what runloop you want to use in the configuration plist". So there's existing precedent for that being configurable, and that configurability working out decently well.

5 Likes

This all sounds great to me, but I want to dig in on this bullet because it might imply a change in semantics. Is the main function implicitly @MainActor? Let's say I have this program:

@MainActor func f() { }

@main
struct X {
  static func main() async {
    f() // if main() is implicitly @MainActor, this call can be synchronous
  }
}

I suspect the answer is "yes", it should implicitly be @MainActor, which would also allow one to interact with other @MainActor code prior to the first await and be guaranteed that the run loop hasn't turned yet. So, in addition to a runtime guarantee, the main-actor-ness of main is a semantic guarantee in the static type system.

It's too late to add any new functionality for Swift 5.5, but this seems like something we could add later on.

Doug

6 Likes

Yeah, I think it makes sense to implicitly make the function main @MainActor.
I can't seem to edit this pitch anymore, so I'll put this change in the actual proposal.

Still can't edit the pitch, but I have the proposal here:

1 Like

Your updated pitch looks very good. I had one clarifying question. When you say this:

  • Make the main function implicitly MainActor protected.

All of the examples in the proposal use an async main function, but I think this statement also means that a synchronous main is considered to be on the main actor. That makes sense, because it means an async main with no await in it is equivalent to a synchronous main, and we know that main code is always running on the main actor.

Doug

Yeah, I think that it makes sense to put it on the MainActor, practically speaking.
That said, it is currently possible to write a program that calls the main function recursively from different threads. While this is technically true for the async main, there should be far fewer source-breaking instances given the relative age of the feature. Thoughts?

1 Like

I suspect that such programs are rare enough in practice that it's fine to break them in Swift 6. If you try to call main directly and you aren't on the main actor, the compiler would complain that you need to do so asynchronously.

Doug

1 Like

Heads-up for folks watching the thread, we'll be starting a formal review of this proposal on Monday.

Doug

5 Likes