[pitch] Strict concurrency for global variables

Introduction

This proposal defines options for the usage of global variables free of data races. Within this proposal, global variables encompass any storage of static duration: lets and stored vars that are either declared at global scope or as static member variables.

Motivation

Global state poses a challenge within concurrency because it is memory that can be accessed from any program context. Global variables are of particular concern in data isolation checking because they defy other attempts to enforce isolation. Variables that are local and un-captured can only be accessed from that local context, which implicitly isolates them. Stored properties of value types are already isolated by the exclusivity rules. Stored properties of reference types can be isolated by isolating their containing object with sendability enforcement or using actor restrictions. But global variables can be accessed from anywhere, so these tools do not work.

Proposed solution

Under strict concurrency checking, require every global variable to either be isolated to a global actor or be both:

  1. immutable
  2. of Sendable type

Global variables that are immutable and Sendable can be safely accessed from any context, and otherwise, isolation is required.

Detailed design

These requirements can be enforced in the type checker at declaration time.

Source compatibility

Due to the addition of restrictions, this could require changes to some type declaration when strict concurrency checking is in use. Such source changes however would still be backwards compatible to any version of Swift with concurrency features.

ABI compatibility

This proposal does not add or affect ABI in and of itself, however type declaration changes that it may instigate upon an adopting project could impact that project's ABI.

Implications on adoption

Some global variable types may need to be modified in a project adopting strict concurrency checking.

Alternatives considered

For isolation, rather than requiring a global actor, we could implicitly lock around accesses of the variable. While providing memory safety, this can be problematic for thread safety, because developers can easily write non-atomic use patterns:

// value of global may concurrently change between
// the read for the multiplication expression
// and the write for the assignment
global = global * 2

Though we could consider implicit locking if we needed to do something source-compatible in old language modes, generally our approach has just been to say that old language modes are concurrency-unsafe. It also would not work for non-Sendable types unless we force the value to remain isolated while accessing it. We potentially could accomplish that with the proposed Safely sending non-Sendable values across isolation domains feature, but that is probably too advanced a feature to push as a solution for such a basic problem.

We could default all global variables that require isolation to @MainActor. It is arguably better to make developers think about the choice (e.g. perhaps it should just be a let constant).

Access control is theoretically useful here: for example, we could know that a global variable is concurrency-safe because it is private to a file and all of the accesses in that file are from within a single global actor context, or because it is never mutated. That is a more global analysis than we usually want to do in the compiler, though; we would have to check everything in the context, and then it might be hard for the developer to understand why it works.

Future directions

We do not necessarily need to require isolation to a global actor to be explicit; there is room for inferring the right global actor. A global mutable variable of global-actor-constrained type could be inferred to be constrained to that global actor (though unnecessary if the variable is immutable, since global-actor-constrained class types are Sendable).

18 Likes

+1. An important direction and safety fix.

Speaking broadly, I prefer that unsafe code be identified iff it occurs, rather than requiring manual labour to declare code safe. Re. some of the topics mentioned in the pitch, e.g. whether to allow globals with no declared actor association if it's apparent to the compiler that their actual use is safe (e.g. all accesses are from the same actor).

I think that better supports Swift's goal of progressive disclosure (by not burdening humans with decisions and associated syntax, like which actor to declare on a global variable and how to express that, when there's no actual problem).

It still gives people the option, if they are strongly opinionated otherwise, of enforcing rules like "all globals must be explicitly annotated with a global actor" with a linter.

2 Likes

Thank you for the input. I presume you are referring to the final paragraph about access control in the "alternatives considered" section? Generally agreed on your points, however the second half of that paragraph does present counter-arguments, about which I wonder whether you have opinions?

This is technically possible in some cases, but it's by nature a global analysis: to do it, there has to be a single compiler invocation that processes literally all of the code that can possibly use the variable. So it's impossible for public vars, questionably possible for package vars, and a serious build-performance problem even for internal vars. It only seems remotely feasible for fileprivate or private, and at that point, I don't know if having a special-case rule is worthwhile.

(Technically, we could invent some novel communication mechanism to efficiently check for consistent use between translation units, but that's a big ask for one feature which, honestly, ought to be fairly corner-case.)

3 Likes

+1

However, what is the escape hatch if I have a mutable global that I manually guard using a mutex?

This isn't the same as global actor isolation -- the mutable global state is not isolated to any actor -- but I also want to assert that it is safe because it is only accessed in a serialised context. I don't mind how we do that, but I think we need the ability in some form.

6 Likes

Anything exposed from a package has to be explicitly annotated (w.r.t. thread safety) since that's part of the API. Arguably this is true of modules specifically. It's the module-internal cases where there's an apparent option.

Is use of whole-module-optimisation (WMO) not commonplace? I guess I have no real intuition for whatever the world at large uses, but I use it on my stuff. Is that not where this analysis could be performed?

Though I guess WMO's not typically used in debug builds.

So you make a fair point that this might be impractical even though technically possible.

Though what you alluded to re. link-time checking seems promising - e.g. that in every compilation unit the compiler records which concurrency context(s) each symbol is accessed from, and then during linking it can merge those sets and ultimately emit diagnostics as appropriate (e.g. when the set contains more than one item, or contains an "indeterminate" sentinel item meaning the context can't be determined conclusively). It doesn't have to be perfect (much like type inference isn't perfect, but as long as it doesn't fail and force you to refactor your code too often, it's not a big deal).

I'm thinking in particular of usage domains like playgrounds, 'shell scripts', etc, where you typically do have just one file and you want the convenience of just declaring some global var and using it fluently, without a bunch of boilerplate (but, of course, without losing the safety benefits of the compiler ensuring thread-unsafe usage is diagnosed).

I think that's why the recommended lock APIs hide the actual data inside the lock, isn't it? e.g. OSAllocatedUnfairLock.

I'm thinking in particular of usage domains like playgrounds, 'shell scripts', etc, where you typically do have just one file and you want the convenience of just declaring some global var and using it fluently, without a bunch of boilerplate (but, of course, without losing the safety benefits of the compiler ensuring thread-unsafe usage is diagnosed).

I should note that top-level globals already implicitly are isolated to @MainActor, so I think this covers this sort of concern.

I think the potential issues you've raised (and that @John_McCall emphasised) are valid concerns. They're just not conclusive - it's not self-evident that sufficiently "global" analysis can't be done performantly (nor that it can, of course).

Re. developer understanding, I agree with the implicit premise - that code needs to be readable, meaning self-explanatory - but I'm just not sure if that'd prove to be a problem in this case. At least in simple examples, e.g.:

var callCount = 0

@MainActor
func doThings() {
    …
    callCount += 1
}

…it's quite apparent to a reader what the intention is, and if the reader knows the compiler will catch if callCount is used elsewhere in an unsafe way, then they're satisfied; they don't need to actually see @MainActor on callCount.

This is actually similar to how @MainActor propagates implicitly already, e.g. if you declare a protocol member method as @MainActor you don't need to explicitly declare that on any actual implementations of that method (nor do you need to explicitly mark them as async, even though they are!). Though relevantly this does seem to be an occasional source of confusion, e.g. Method marked with globalActor modifying property in class.

In more complicated examples (where the behaviour might not be as obvious), or in any case at the author's discretion, they can always choose to add explicit annotations. Again, type inference is the exemplar here - one can argue that implicitly-typed variables are confusing (or make compilation too slow), but one doesn't have to use them, which lets type inference exist for those that people don't find it confusing (nor too slow).

But in any case, I think what matters more is the question of whether developers want to care. They just want code that works. They want the compiler to get in their way only if the compiler can point out a genuine reason their code doesn't work [reliably]. And of course they want the compiler to be very good at determining if their code does or does not work. And the less they have to do to help it, the better.

That's the principle, of course. That's all I was alluding to in my post. Pragmatically the compiler might need some help, whether for compilation performance or implementation difficulty or whatever. I was just expressing a preference; I'd just like to see a little more elaboration on why the author has to do more to help the compiler, if that turns out to be the case.

Noncopyable types should eventually allow for a mutex-guarded value to be presented as an "immutable" type which can be stored in a let property, which then wouldn't require a global actor annotation to be safely accessible. There's a prototype example of this here:

3 Likes

Ah, okay. I probably should have remembered that anyway, but I inferred from the pitch's introduction that this isn't the case today, since you specifically call out "declared at global scope" as within the problem space. Can you clarify, then, how they relate to this pitch if they're already, implicitly bound to the main actor?

I should explicitly mention top-level code in the proposal to clarify this, you are right. If have a single-file compilation, then all globals are top-level and only the static members part of this pitch is relevant. Once other source files are part of the project, then the non-main sources have the globals that need to be addressed.

1 Like

Ah, that makes sense. And actually might explain a lot of confusing behaviour I've seen in this regard over the years - I was assuming the inconsistencies were due to changes between Swift compiler versions, but maybe they were actually just between 1 file and N file cases!

Is it an explicit goal of this pitch to normalise the behaviour between these cases? It'd be great to not have special behaviour for single-file projects nor the "main" file, in this regard. It's clearly been a source of significant confusion for me, at least. :sweat_smile:

Sorry, I still don’t get it. So, in my example, Method marked with globalActor modifying property in class
The class has become on MainActor?

I would say that this proposal does not seek to normalize these cases, since this proposal offers more flexibility to allow options other than exclusively @MainActor isolation. But yes, there is some complexity and knowledge prerequisite in all of these cases.

I don't have more elaboration to add on the complexity/performance concerns than what @John_McCall shared. But I hope you see that the "future directions" is specifically about more implicit deductions, which is always a direction for possible exploration for usability improvements.

While the long-term vision is definitely what @Joe_Groff said above, you're right that we need a short-term unsafe opt-out, too. Maybe we should bring back nonisolated(unsafe) from SE-0306 / SE-0313?

7 Likes

There are no global variables in this example.

How this change will handle globals declared somewhere else, not in Swift (e.g. errno) ?

Just to chime in on the errno question.

errno specifically is quite a weird case, since it is using thread locals which are very risky in combination with concurrency, so we're thinking of ways to provide some specific API that would make it safe to use. For example:

// good
cStuff()
errno // ok

but this is really bad™:

// super bad
cStuff()
await ...
errno // super bad, could be anything

Even worse, if there was enough code between the cStuff that sets an error such that Swift may decide to need to malloc between those lines, it'd squander the value of errno with the one set by malloc... So we're in search of a pattern that would make errno actually safe -- the global variable is pretty unsafe with async code.

3 Likes