SE-0343: Concurrency in Top-level Code

The review of SE-0343: Concurrency in Top-level Code begins now and runs through March 4, 2022.

The proposal is authored by @etcwilde.

Reviews are an important part of the Swift evolution process. All review feedback should either be on this forum thread or, if you would like to keep your feedback private, directly to the review manager by DM. When messaging the review manager directly, please keep the proposal link at the top of the message and the evolution identifier in the subject line.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at https://github.com/apple/swift-evolution/blob/master/process.md.

Thanks for helping review this proposal!

Saleem Abdulrasool
Review Manager

18 Likes

This looks like a great, maybe even essential, extension to structured concurrency.

I share the proposal's concern

changing whether a context is synchronous or asynchronous has an impact on how function overloads are resolved, so simply flipping a switch could result in some nasty hidden semantic changes

and I think that it actually applies to the proposed solution itself. :slight_smile: Simply inferring an asynchronous context like this seems a bit too subtle.

An await nested within a function declaration or a closure will not trigger the behavior.

I think that requiring a declaration of intent to use structured concurrency at the top of the file (a special import maybe; or any other appropriate spelling) would be extremely helpful for readability and clarity of understanding. This could then be deprecated and no longer required when the default behavior flips in Swift 6.

  • What is your evaluation of the proposal?

Oh this will be amazingly awesome! It will not only help scripts but also help folks learning and also folks handling bug reports. What used to be snippets that needed some machinery to wrap up into some sort of async context is now immediately runnable in quick to create and existing templates.

  • Is the problem being addressed significant enough to warrant a change to Swift?;

Absolutely. It makes the language feel more approachable in my opinion.

  • Does this proposal fit well with the feel and direction of Swift?

Yes, 100%. It brings a sense of consistency with the rest of async/await in the language.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

It definitely fits with other async/await systems.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I read over the proposal with some attention to it, but also have been using the work-arounds in a day to day basis for a good while now. This hits the edges as well as the main themes quite well in my opinion.

2 Likes

I could be persuaded to require an import to indicate that the context should be an async context. I've been going through various approaches, from a compiler flag to the explicit import _Concurrency and I will say, I do like that this parallels the behavior of closures.

Now, the mechanism for triggering the switch from the synchronous context to async context will need to stay, even in Swift 6, for the same reason we need to keep a synchronous main function around.
The asynchronous main function doesn't provide a mechanism for specifying a runloop to drive the concurrency model, instead calling out to _asyncMainDrainQueue() from the runtime, and programs terminate when the main function returns. Folks who need to specify a different runloop than whatever _asyncMainDrainQueue chooses, or folks who need to write a daemon where the enqueued task doesn't call exit immediately should be able to do so. For those cases to work correctly, we would need to maintain the same semantics and same code-generation that exists today for the synchronous top-level code or we get into trouble with nesting our runloops.

Making things a little more concrete, the following is a close approximation in Swift for how the main function gets kicked off

func asyncMain() async {
  // main code goes here
}

func _main() {
  let _ Task.detached { @MainActor in
    await asyncMain()
    exit(0)  // You'd remove this for a daemon
  }
  _asyncMainDrainQueue() // opaquely choose a runloop
}

Note the exit(0) inside of the task that is responsible for running the asyncMain(). That allows the program to halt when asyncMain returns. Without it, the program wouldn't stop unless your asyncMain() function explicitly called exit, which is weird when considering many scripts and programs. Unfortunately, this is exactly wrong for daemons where we want things to keep listening in the background. Since _main is implicitly generated, programmers can't just reach in and change those properties at the moment. Maybe this is an argument for something stronger than the pretense of an await in the code though? Whatever the mechanism is, it will have to stick around for the foreseeable future to allow folks to implement their own customized entry points to concurrency.

+1

It seems that this will also address the long standing bug with variables in script mode being global while in async context. Looking forward to this getting fixed for non async contexts in a future language version Compiler bug or feature? - #12 by John_McCall

Is there a way to turn on this feature with out having to create and calling a dummy async function?

Yes, ish. The actor-ness will make it concurrency-safe, but I believe you can still shoot yourself in the foot with the uninitialized memory. Not addressing the issue was intentional to avoid source breaks as much as possible. :slight_smile: I'm hoping to get something in to fix the memory hole for Swift 6 though.

No, you do have to call an asynchronous function in order for the async context to kick in. This is to avoid any possible source breaks and to allow folks to write daemons or use different runloops than what the concurrency runtimes currently supports.

After Swift 6, full actor-isolation checking will take place. The usage of a in bar will result in an error due to bar not being isolated to the MainActor . In Swift 5, this will compile without errors.

In swift 6, is this saying that top code will not be marked @MainActor? It makes sense if code is not longer marked @preconcurrency but I was assuming that implicit main actor behavior will be kept.

1 Like

Variables declared in the top-level are implicitly @MainActor in both Swift 5 and 6. Functions are not implicitly @MainActor since they may need to, and can somewhat safely, be called from other non-main-actor functions. Due to the pre-concurrency attribute, synchronous non-@MainActor functions in Swift 5 won't emit an error if you use the implicitly @MainActor variables, but asynchronous functions will emit an error in Swift 5. In Swift 6, the preconcurrency effect drops, so you will get an error when you use the implicitly @MainActor variables as global variables in both synchronous and asynchronous non-@MainActor functions to button up the data-races.

In Swift 6, if you need to use the variable as a global variable, you'll need to annotate the synchronous function with @MainActor so that you can use the variable locally without an await. Alternatively, you can pass the variable as a parameter into the function. This ensures that the proper sendability-checking is done and that you're not going to hurt yourself.

1 Like

Is there a way to tell the compiler that a top level variable should not be marked @MainActor?

No, not when the top-level is in concurrency mode. It is way too easy to shoot yourself in the foot without it.

Accesses to the variables in the top-level are synchronous since the top-level context is also on the main-actor, so there are no awaits. With the @preconcurrency in Swift 5, you're still free to shoot yourself in the foot by poking at the variable through a synchronous function. With the more restrictive checking, it will complain if you're using the variable synchronously from non-@MainActor synchronous functions.

It is also an error to explicitly specify a global actor on top-level variables so that we can eventually move them to behave like local variables, which cannot have global actors specified. If you really needed to specify different global actors or nothing at all, you could define a class/struct/enum with static variables that have different global-actors.

print(Globals.monkeys)

enum Globals {
  static var monkeys = 10
}

That has the added benefit of plugging the memory-safety hole since Globals.monkeys will be initialized lazily like normal global variables.

I'm sorry but this is not quite clear to me. What memory-safety hole is this plugging and how?

Top-level variables are a hybrid of local and global variable. They are in the global symbol table, so you can use them from anywhere, but they are initialized sequentially like local variables.
e.g.

print(integer)     // Prints '0' because `integer` is default-initialized to 0
let integer = 15
print(integer)    // Prints '15' because integer is now initialized to 15

print(arr) // crashes because `arr` is default-initialized to 0, so we dereference a nullptr
let arr = [1, 2, 3]

This affects any function that uses the top-level global as a normal global anywhere in the module. If you accidentally call one of these functions before you've declared the variable, you'll compute the wrong results (or just crash). Since the use of globals isn't explicit at the call-site, it's a rather sneaky foot-gun.

The static variables in the Globals enum are initialized lazily, like normal global variables. When you go to access monkeys, it will initialize the value to 10 at the site of first use. If instead of a primitive, you had a reference-type, or something that contained a reference-type, it would do the initialization before trying to use it.

After some discussions, the most reasonable fix for this seems to be to make the variables local to the implicit main function. That direction would necessitate disallowing explicit global actors on top-level variables. An alternative would be to say that they behave like normal global variables. Then we could allow global actor annotations on them. This isn't the right proposal to make this call though, but I'm not ready to lock one in over the other behind a source break.

2 Likes

Hmm, chatting with @compnerd, maybe I misunderstood the direction you were pushing for.
Did you want a way to opt into the concurrency-mode restrictions without needing to call an async function from the top-level, or did you want to have to use the import _Concurrency marker explicitly before flipping over to the concurrent top-level?

There have been a few questions asking about the requirement to call an async function.
The implicit main-actor'ing of the top-level variables seems to piquing some interest, without necessarily having to call an async function from the top-level.

Having just run into this in Vapor, I love this proposal.

2 Likes

Proposal SE-0343 was accepted.

There was a minor change requested to enable the implicit main-actor protection of top-level variables without needing to call an asynchronous function from the top-level. This is done by passing -warn-concurrency to the compiler invocation.

Please note that this does not transition the top-level context to an asynchronous context, so no runloops are spun up and the overload resolution does not change.
The feature is intended to provide diagnostics, alerting you to the presence of potential data-races. Additionally, this flag transitions the top-level code context to run under main-actor protection so that reading and writing to top-level variables is synchronous.

4 Likes

The latter. My thought is that I would like something right at the beginning of the file that says "elements below are subject to actor isolation and other concurrency restrictions". Rather than reading, seeing an await 2/3 of the through, and having to reconsider everything I've already mentally parsed. Just as, to use Data, I need to import Foundation. Except this is more serious, because the isolation of a variables is a significant semantic change.

Say I am reading through this snippet from the proposal. Everything here looks fine:

var a = 10

func bar() {
  print(a)
}

bar()

and then I read the last line:

await something()

Oops! I have to update my mental model of the code: turns out bar is not fine! Obviously for such a small snippet this is trivial, but for real life code it would not be, necessarily.

This is only important in the current world, where a file may or may not be top-level async. When Swift 6 comes and top-level async always applies, I don't think it is strictly necessary to have the explicit marker.

1 Like

Of course I didn't realize this was already accepted before writing that :sweat_smile: . Apologies for not replying sooner.