Actor memory isolation for "global" state

Hi all,

In addition to ensuring that references aren't accidentally passed between actors in async message sends, actor isolation requires us to deal with global variables (incl. static properties) in some way. This is its own non-trivial sub-problem, so I wrote up another whitepaper exploring the issues and making a recommendation on one way to address the problem: "Actor isolation for Global State".

I'd appreciate any thoughts or feedback,

-Chris

9 Likes

I’m not an expert, but this looks like a reasonable model to me. It would be a huge step forward in safety from where we currently are. I agree with your analysis of pure functions. I want to see something happen in that area someday but don’t think it should be tied to the problem you’re solving here.

This is a clever (in the good sense of the word) way to define away the issue I mentioned in the previous thread. I’m generally supportive, but I think we should consider one more category of global variable locality:

While you cover things like thread local in your discussion of prior art, it seems to be left out of the design portion. In my view “thread local” and “actor local” are both valuable, and Swift should support both. Maybe the attribute should be @ownedBy(process/executor/actor), with process values never being destroyed (the same semantics as current globals), executor values (really meaning thread values, but using the new terminology) being destroyed when an executor is destroyed (whatever that means on your system), and actor values exhibiting the semantics described in your proposal (actor would also be the default for unqualified globals).
Note, one could argue that we can add @ownedBy(executor) later but I think it will have a meaningful impact on the design of how these values are accessed and how executors are defined (the latter is part of the structured concurrency portion of the roadmap).

The trick here is that we want to thread the needle between Swift having a first class concurrency model, but being library-extensible and compatible with the wild world that Swift apps live within. Since we're standardizing a notion of actor, so it makes sense to have a language level way to bind variables to their scope.

However, there are many different threading models (queues, posix threads, fibers, user space threads, ........) so I don't think there is a useful/reasonable way to make the language work with arbitrary threading models: those models should be defined in a library, and potentially use property wrappers to make them convenient.

Maybe I'm missing what you mean here, do you have something specific in mind? How would it work?

-Chris

@UIActor   
var someState: SomeState = someStuff()
  1. Would non actor code be able talk to someState synchronous?

  2. Would actor code be able to read the “global” someState unsafely?

No, there is no such thing as non-actor code in general. Global functions can be called from actors after all. The async rendevous with the @UIActor is required to make sure the @UIActor is synchronized w.r.t. the access, not anything in particular about the accessor.

No. What do you have in mind here?

Nothing in particular, just wanted to clarify my mental model. Thanks!

The whitepaper suggests in several places that the initial access of a lazy var is thread safe. For example:

However, lazy evaluation has its own tradeoff: the “get” access to a global value may be run at any time, across threads, and programmers are not generally used to thinking about that. Because of this, Swift provides an “atomic” guarantee: the initialization logic executes with a mutex held (typically implemented with pthread_once or dispatch_once) to make sure the initializers are not reentrant and two threads touching a global at the same time do not race with each other.

Currently, BuilderContext will be lazily created on the first reference to context, but subsequent accesses to context are not thread safe. (which suggests that the initial access is thread-safe)

However, The Swift Programming Language guide suggests that the initial access of a lazy var is not guaranteed to be thread safe:

If a property marked with the lazy modifier is accessed by multiple threads simultaneously and the property has not yet been initialized, there’s no guarantee that the property will be initialized only once.

Are these quotes referring to related-but-different behaviors? Or perhaps is this currently thread safe in the compiler but not thread safe according to the language contract?

Are these quotes referring to related-but-different behaviors? Or perhaps is this currently thread safe in the compiler but not thread safe according to the language contract?

They are related-but-different. Global and static property initialization is thread-safe (using something like dispatch_once), lazy var is not. Both are lazy.

Here's some more information on that: Swift globals and static members are atomic and lazily computed · Jesse Squires

2 Likes

You may be right about this. I guess the core semantic I'm interested in is "the largest scope where the system can guarantee the value is never accessed concurrently, though it may be accessed from different actor contexts" (after re-reading the structured concurrency proposal, I realized executor is the wrong word for this since a single executor can dispatch to multiple threads). For threads, this would be thread-local and other implementations of an executor can choose how to implement (maybe even falling back to per-actor isolation in the trivial case). I'm starting to think this is too esoteric a concern to solve in a general way.

The core recommendation made to solve the major problem discussed in this whitepaper appears to be (with my own added emphasis):

  1. The first time [global state declared with let or var] is touched by within actor context, it is lazily initialized and stored in a per-actor dictionary, and subsequent accesses retrieve this. This makes globals syntactic sugar for being defined as instance variables on the actor themselves. These values are destroyed when the actor is destroyed.
  2. The first time [that same global declaration] is touched by something outside an actor context, an actually-global instance is created and maintained in process global state just as it is today.

I think this creates a new area of confusion for mutable state: the exact same global var or let declaration simultaneously embodies two completely separate kinds of state based on the context of the declaration's uses. It functions either as an ordinary global or actor-instance member.

The familiar concept of stored members of classes / structs being unique per-instance is tied to the scope in which those members are defined, not based on their use sites. That is, per-instance storage is explicitly scoped to the class definition to make things clear to programmers. The whitepaper's recommendation breaks that convention and allows this code:

private var context = BuilderContext()

public func createThing(size: Int) -> Thing {
  // reference to standard global `context`
  return context.createThing(size)
}

actor class Actor {
  func getThing() -> Thing {
    // does *not* refer to the same `context` as in `createThing`,
    // `context` is silently an instance variable of Actor!
    return context.createThing(size)
  }
}

The only language I can think of that has such a subtle use-site treatment for variables is Python's global vs local distinction, where an assignment to a global within a function, without the global designation, changes that variable into a local for that function. While surprising for newcomers at first, having such a rule makes sense for Python because it resolves an ambiguity: an assignment is the only way to introduce a function-local variable, but the chosen name may clash with one already in global scope and thus could also be interpreted as an accidental mutation of that global.

For us to create such a surprising functionality in Swift when it comes to uses of globals from actor-isolated state vs non-isolated state, I think we need to have some very solid motivations.


The whitepaper doesn't appear to discuss the current implementation of the Swift concurrency proposal, so I wanted to lay out the details here. Currently, there is checking for mutable stored globals (aka global properties declared with var) that works like so:

  1. If the global is marked with @actorIndependent(unsafe), then the global can be freely accessed from actor-isolated contexts without any warning or error.
  2. The global cannot be marked with @actorIndependent because it's not possible to guarantee safety.
  3. Otherwise, for accesses to the global from an actor-isolated context, a warning is emitted for each access since it is possibly unsafe.
3 Likes

Thanks for bringing this up. A couple of thoughts, as always...

First of all, I'm not sure if you realized this in writing our document, but two of the three parts of our "Proposed Solution" are already a part of the actors pitch:

  • Your proposed "global actor variables" are already part of global actors already. The bit about accessing global state asynchronously would follow from the change to allow non-async actor declarations to be accessed asynchronously, a suggestion that will come in a revised version of the asynchronous functions pitches.
  • Your proposed "process global variables" are already part of the actors pitch, spelled @actorIndependent(unsafe).

So the fundamental contribution of this proposal is to have global variables that aren't marked with one of the above implicitly become lazily-initialized actor instance members. While I agree that this is a source-compatible change in the sense that all code is non-actor code today and won't change behavior, I have major concerns with this proposal.

I think the end result of this proposal isn't where we want Swift to be. If we wanted to add a feature where one could add lazily-initialized instance data to an actor (or to any class, a la associated objects), I cannot imagine us doing so by declaring the variable at module scope. As Kavon noted, this is a confusion of global vs. local. If we were to add this feature, it would allow stored properties to be declared with initializers in an extension

extension SomeReferenceType {
  var myCounter: Int = 0
}

A much better end state would be to have every global variable marked as either being part of a global actor or being marked as @actorIndependent(unsafe). I think we can get there in two phases: require one of these annotations for all global variables that are referenced from asynchronous code (therefore, only new code is affected, and source compatibility is maintained) in "phase 1", and have "phase 2" require such annotations for all global variables.

Doug

3 Likes

You make a good case that “global” variables scoped to actors might be a bit weird for folks to grasp.

One thing I’m curious about is where the would the "phase 1" error be in this example I brought up earlier, or even something a tiny bit more complex:

protocol Foo {
  var foo: Int { get }
}
enum Bar: Foo {
  static var staticFoo = 42
  var foo: Int { Self.staticFoo }
}
actor Baz<T: Foo> {
  let t: T
  var foo: Int { t.foo }
}
let baz = Baz<Bar>(t: Bar())

What annotations would I need to provide to make this work?

Daniel is right here. I added a parenthetical to clarify that the behavior being discussed isn't related to the lazy keyword. I can see how this is a common point of confusion.

I personally don't think about it this way - each "global" decl is scoped to an actor or the process, the closest per the call stack it is touched by. I don't think of it as having a different behavior - you get a process scoped global when you're not in an actor context. As I mentioned, this is consistent with existing behavior because we don't have actors.

Yes, I'm aware, addressing this is the point of the proposal. To me, the /most important/ part of globals vs actors is "what happens without annotations". This matters a LOT for progressive disclosure of complexity and matters for source compatibility with existing code, and the base actor proposal didn't seem to address this in an appealing way, so I wrote up these thoughts.

Sure, this would also be a nice thing to have (and people have asked for that with classes as well) but this is a different feature. The difference between this and the semantics I'm describing is that your idea requires that you have a specific actor you want to extend. Your idea here doesn't solve for the builder use case, which is actor-local state for any actor you use the functionality with.

I completely disagree with this. This is a very ugly end state for normal Swift code, and because it is unacceptable for scripts and playgrounds, it would lead to a fragmentation of the model.

-Chris

1 Like

Responding to this in a separate post to explore the issues specifically:

I incorporate my opinion about this into a new "Break source compatibility by requiring pervasive annotations" subsection in alternatives considered. I'm posting the current draft inline for convenience:

Break source compatibility by requiring pervasive annotations

One approach to handling global state is to eventually require that all global state be annotated with an unsafe attribute or be attributed to a global actor. This end state is problematic for a few reasons:

  1. This doesn’t provide a solution for the actor equivalent of “thread local” variables. This means we have no way of modeling side-channel state that comes up in builder patterns, which are not associated with one specific actor.
  2. This is an ugly end state, because all global variables (including static stored members) end up having attributes on them.
  3. This isn’t acceptable for top level variables in playgrounds and scripts, which mean we’d have to give them a forked and inconsistent model.

Furthermore, getting to this end state is a problem, because existing Swift code already has global state without these annotations. The apparent idea is to roll this out in a two step approach where the first step is a memory unsafe model (see the problems with that above) in “Actors 1.0” and then roll out a massive source break in Actors 2.0.

This seems like a suboptimal approach given that we have multiple straight-forward options to roll out a memory safe model in Actors 1.0, and don’t need an “Actors 2.0” with a massive source break.

-Chris

1 Like

I think the reccomendation's "syntactic sugar" definitely introduces a new variable scoping behavior for Swift that is close to dynamic scoping, since we cannot point to the separate process-scoped and actor-scoped declarations. It's only not "different behavior" in the sense that it is designed to not change the behavior of existing code, but it does violate the programmer's expectations about lexical scoping in any new code using actors.

If the recommendation were to just ban access to globals from actor contexts, then the programmer can get by just fine without the reccomendation's syntactic sugar using the normal scoping rules:

private var context = BuilderContext()  // process-scoped `context`

actor class Actor {
  private lazy var context = BuilderContext() // actor-scoped `context`
  func getThing() -> Thing {
    return context.createThing(size)  // `context` here always means self.context
  }
}

Sure, we now need to duplicate the global's initializer in each actor that needs one, but making that explicit in the program, to me, is much clearer than what was initially recommended.

1 Like

Fair point @kavon, but this still doesn't fix the expressivity problem. It is reasonable to enable builder-style APIs that need state but don't pass around explicitly, e.g.: in the style of SwiftUI.

One very common use of "thread local" global state today is for APIs like this:

func doStuff() -> Widget {
  let a = createThing()
  let b = createAnotherThing()
  return createMerge(a, b)
}

Note that doStuff is neither async nor attributed to an actor. In fact it may be called from multiple actors. One random example of this is in a compiler that wants to refer to an LLVMContext for the things that are built.

Your post does make a good point though, one alternate model is to make it a dynamic error to access a global from an actor. This would be source compatible with existing code and would achieve memory safety, the cost is expressivity of patterns like the above and difficulty adopting those APIs into new actor code. That said, it is a credible model, so I added it to alternatives considered, thanks!

-Chris

1 Like