[Pitch] Control default actor isolation inference

Hello, Swift evolution!

I wrote up a pitch for controlling default actor isolation within a module, which allows specifying MainActor isolation by default for single-threaded code. Note that this feature is part of the prospective vision for improving the approachability of data-race safety, which is still under Language Steering Group review. Some reviewers noted in the vision discussion that it'd be helpful to see the details fleshed out in a proposal, so here it is!

Please leave design feedback in this pitch thread, and leave editorial feedback on the swift-evolution PR at Add a proposal for configuring default actor isolation per module. by hborla · Pull Request #2667 · swiftlang/swift-evolution · GitHub.

I look forward to your questions, thoughts, and other constructive feedback!

-Holly

17 Likes

I have been totally convinced that there is a place for @MainActor-by-default in Swift's new concurrency story. I also do think there is a role for some sort of module-level control, and I totally support the principle behind the revision that we're not tying this to libraries versus executables.

However, I'm wary of how much this design will ultimately advance our goal (or alternatively set us back) in terms of approachability now that it's purely an opt-in switch:

For authors who are new to Swift concurrency, they'd have to understand the concept of actor isolation in the first place in order to know this switch exists to turn on a new dialect. For readers who are still learning Swift concurrency, we've taken the hit of creating a dialect with no sigil in source that totally changes their understanding of what the code does, another setback on the already winding path...

9 Likes

I like the concept of this, except for the inferred part. I would rather that it requires (via compiler error) you to manually annotate each type with @MainActor. This way if you ever copy/move a type out of such a module, it's behavior doesn't change wildly. Also when others share their code online, we'd never be able to know if their code is inferred @MainActor or if it was nonisolated.

3 Likes

I wonder if controlling this with a command line flag/build setting is hiding important information too much. Different build systems will have to devise different ways to express this (or just let the user pass raw compiler flags), but those can often become distant from the code itself, making it hard for the user to track down why certain behavior is happening the way that it is.

Similarly, I'm not sure this alternative/possible future direction could work:

extension SwiftSetting {
  @available(_PackageDescription, introduced: 6.2)
  public static func defaultIsolation(
    _ globalActor: (any GlobalActor.Type)?,
    _ condition: BuildSettingCondition? = nil
  ) -> SwiftSetting
}

SwiftSetting.defaultIsolation(MyGlobalActor.self)

...because the package manifest would have to depend on the client code that defines the global actor type. MainActor.Type works here serendipitously because it's in the standard library.

You mention as another future direction a way to specify arbitrary compiler settings in a source file, but what if we could avoid that for this feature by baking it into existing language semantics? Similar to how you can overwrite defaults like IntegerLiteralType in a file, we could give the user the option of defining something in their module:

typealias DefaultActorIsolation = MainActor

And the compiler could attempt to resolve that within the module being compiled when deciding which isolation to use. If the typealias is undefined, it would default to nonisolated behavior. And if we wanted to support arbitrary actors in the future, that becomes straightforward.

(We could even draw a distinction between a fileprivate alias and an internal alias if we wanted to support per-file vs. per-module isolation, but I'm also not sure if that's desirable.)

Would that be a feasible approach? I think it would also be helpful from a tooling perspective to be able to see the default isolation directly expressed in the source file, instead of only being accessible through compilation command lines.

8 Likes

I was thinking along these lines as well. We could even ape the naming more completely and call the alias DefaultActorIsolationType, and—following the design pitched here for SwiftPM—somehow let users write nil to indicate the nonisolated default (although I'm realizing there may be shenanigans for type inference reasons if we want that).

3 Likes

As I live and breathe Swift Testing… I think this is a feature that would be of interest to us, but we would probably not be able to actually adopt it as pitched. Presumably, we'd want to adopt it conditionally based on whether or not the code under test has adopted it. We have some internal knobs and levers that allow us to opt synchronous test functions in/out of isolation-by-default at runtime, but we wouldn't be able to pull those levers if we couldn't ask the runtime/compiler "what's the default?" during either macro expansion or later execution.

Testing is an odd duck because everybody needs to write tests, but nobody ships test code to customers (hopefully.) We want to make it trivially easy to write your first unit test, and in general there should be no surprises when you do so if we can avoid them.

It may be the case that we don't need to worry about it and can just say "Swift Testing is nonisolated (or @MainActor-isolated) by default; to opt out, mark a test @MainActor or make it a member of an actor (or mark it nonisolated)." However I am not sure if that is the best way to serve the overall goal of improving approachability.

2 Likes

Spitballing here, but we could define this in the standard library:

public typealias DefaultActionIsolationType = Never

(It's Never isolated by default; i.e., nonisolated.)

And then individual modules could replace it with their own typealias, or they could still write Never in their own code if they wanted to be explicit about the default.

5 Likes

I will make this clear in the future directions section, but I think the option should be set by default for new project creation in some cases. I didn't include that in this pitch because I think where this mode should be enabled by default is a separate discussion. It has to be opt-in for existing projects for source compatibility reasons, but we can also make the option more discoverable via compiler diagnostics, e.g. as an alternative when someone gets a concurrency error that suggests adding @MainActor to resolve it.

From my perspective, that's the point. There are many programmers who don't (yet) need to understand this setting at all. Moving toward a model where the default MainActor is always explicitly specified in source code doesn't actually avoid programmers needing to confront the concept of the main actor early on.

I think this is a solvable problem. The compiler and SourceKit can already show inferred actor isolation for any declaration. The compiler also keeps track of the source of actor isolation inference, and it'd be very straightforward to show that too. That's extremely useful independent of this pitch - for example, it can be difficult to determine where @MainActor isolation is inferred from when it comes from a protocol conformance that you didn't conform to directly.

Hah, you're right! We could still make this work with an overload that accepts a string. I'm still against pursuing this direction for the reasons outlined in the section, though.

I think this is feasible, but I don't think this is a better design. It's more difficult to provide actionable diagnostics if someone makes a mistake in specifying the default, e.g. misspelling the typealias name is still perfectly valid Swift code. We'd also need to invent a way to specify nonisolated as the default, either by using a fake type to represent it or by somehow allowing nil to be used on the right side.

I do also worry about this being prone to circularity in the type checker if type checking anything in the default global actor type needs the default actor isolation. static let shared must always be nonisolated, but there's nothing stopping people from writing a more complicated initializer expression that uses other parts of the type that might have the default isolation rules applied (there's no requirement that a global actor type itself be an actor).

2 Likes

Would actor types continue to be nonisolated, even if the default actor isolation is MainActor? Are there other places in the language that wouldn’t be main actor-isolated without explicitly saying nonisolated besides actors and closures?

1 Like

I believe this is covered in the document, but isolation rules inside an actor do not change when the default isolation is MainActor. Actor instance methods will continue to be self-isolated, actor init and deinit will continue to be nonisolated, and static methods / properties in an actor will continue to be nonisolated.

The list of exceptions is in this section: swift-evolution/proposals/NNNN-control-default-actor-isolation.md at control-default-actor-isolation · hborla/swift-evolution · GitHub.

1 Like

Could you please add a real example, maybe a simple executable package, where one runs into the addressed problem, in the motivation section?

One common example is singletons:

class NonSendable {
	// error: static property 'global' is not concurrency-safe because non-'Sendable' type 'NonSendable' may have shared mutable state
	static let global = NonSendable()
}
1 Like

There's an extended motivation section which includes two code examples in the corresponding vision document in the section on Single-threaded code and its challenges under Swift 6. That section is effectively the motivation for this pitch. I didn't copy the whole thing because I was trying to avoid having to make edits in both places.

I think it's worth reading the full vision document to understand not only the motivation, but also how this piece fits into the broader story of improving the approachability of data-race safety. This piece alone does not solve all of the problems that people are facing.

1 Like

Would it make sense to allow for the default executor to be controlled more generally, so that it could be something other than the default global executor or MainActor? We also have a pitch to allow for hooking the implementation of these two actors dynamically as a single shared runtime resource, which may be necessary in some cases, but this sounds like an interesting mechanism that modules could use when they want the local syntactic benefits of sending their work to an alternative executor locally by default without affecting the entire runtime for the rest of the program.

1 Like

I haven't been keeping track or anything, but if I had to guess, I'd say that this change alone would have prevented ~ 50% of the confusion I see around using Swift concurrency on Apple platforms. That is, if @MainActor were applied by default.

Now, I guess making MainActor a universal default would be annoying because it would require considerable amounts nonisolated annotations. But, the current design demands considerable amounts of @MainActor today. I also think that an accidental @MainActor is much easier to catch at compile time than an accidental nonisolated. I'm pretty sure it is also safer.

I'd have to think harder on both of these though.

Are we 100% sure the benefits of non-universal-default MainActor are worth the cost?

5 Likes

Oh and another question!

Should the default executor apply to explicitly or implicitly Sendable types like structs?

3 Likes

Instead of introducing a separate mode for configuring default actor isolation inference, the default isolation could be changed to be MainActor under an upcoming feature that is enabled by default in a future Swift language mode. The upcoming feature approach was not taken because MainActor isolation is the wrong default for many kinds of modules, including libraries that offer APIs that can be used from any isolation domain, and highly-concurrent server applications.

But it would be even truer to say that nonisolated is the wrong default for the vast majority of modules that will ever be written in Swift (i.e. iOS/Mac app code).

Breaking changes are hard, but consider who the actual audience is for Swift.

I’m 100% on board with this. I know it’s out of scope for evolution, but if Xcode enables this by default for new app targets this would go a very long way towards making swift concurrency more approachable.

Yes! 100% +1!!

1 Like

I'd love to try this out on my course, once an implementation is available!

1 Like