[Actors] Revisiting "nonisolated let" with more implementation, usage, and teaching experience

Hi all,

The last round of the review of SE-0306 "Actors" focused on the ability to refer to immutable data of an actor synchronously. The end result of that review was to require nonisolated on let declarations in an actor to enable synchronous access from outside the actor.

After implementing this change, trying it out in actor code, and working out how to teach actors this way, I've come to the conclusion that we've made a significant mistake. In search of one kind of consistent message--actors protect their state and all access from the outside is asynchronous--we sidelined a more important, longstanding narrative about shared mutable state. We made actors harder to learn.

I'll briefly describe the problems we encountered and provide an alternative solution that addresses the primary technical concerns brought up in the discussion as well as these problems.

Summary of the nonisolated let change

The change made in response to the last round of review of the Actors proposal makes the following code ill-formed:

actor BankAccount {
  let accountNumber: Int
  var balance: Double

  static func ==(lhs: BankAccount, rhs: BankAccount) -> Bool {
    lhs.accountNumber == rhs.accountNumber     // error: cannot synchronously access 'accountNumber' from outside the actor
  }
}

The fix here is to make accountNumber explicitly nonisolated:

actor BankAccount {
  nonisolated let accountNumber: Int
  var balance: Double

  static func ==(lhs: BankAccount, rhs: BankAccount) -> Bool {
    lhs.accountNumber == rhs.accountNumber
  }
}

If you don't know what nonisolated is, there is an ongoing review of SE-0313, which adds this feature on top of actors. In short, it allows immutable actor state to be accessed synchronously from outside the actor.

Shared mutable state

Shared mutable state causes surprising behavior. We've mostly been talking about shared mutable state in the context of concurrency, where any shared mutable state is subject to data races unless it is properly synchronized. But the problems with shared mutable state don't even need concurrency: having shared mutable state means that you cannot apply local reasoning to understand the effects of a mutation. A write in one part of your program can have effects almost anywhere else in your program, making it harder to reason about.

Swift has been actively working to address the problems caused by shared mutable state since its inception, because local reasoning makes it easier to write correct programs. Swift tackles this by independently addressing both the "shared" and "mutable" aspects:

  • Value semantics eliminate sharing, so that mutations can be reasoned about locally,
  • Immutability (via let) eliminates mutations entirely,

Value semantics and immutability are fundamental to the way we teach Swift. We tell folks to compose structs and enums to maintain value semantics, use protocols for abstraction, and avoid classes unless we absolutely need them for their shared mutable state.

Eliminating shared mutable state is good for concurrency

Everything we have been teaching about using value semantics and preferring immutability where possible has helped pave the way for data-race free concurrency. Other aspects of the concurrency effort have embraced value semantics and immutability to eliminate data races. SE-0302 "Sendable and @Sendable closures relies on both:

For the second, let's consider a little example:

func greet(name: String?) {
  var greeting = "Hello!"
  if let name = name {
    greeting = "Hello, \(name)!"
  }
 
  detach {
    print(greeting)  // error: cannot capture mutable local variable in @Sendable closure
  }

  // ...
}

Captured local variables are a form of shared mutable state. SE-0302 prevents you from forming that shared mutable state to prevent data races. How do we fix this error? By embracing immutability, of course:

func greet(name: String?) {
  let greeting: String
  if let name = name {
    greeting = "Hello, \(name)!"
  } else {
    greeting = "Hello!"
  }
 
  detach {
    print(greeting)  // okay! reference to immutable variables of Sendable type are data-race free
  }

  // ...
}

Actors

The goal of actors is to provide a mechanism for describing shared mutable state that does not incur data races. Actors do that by ensuring that all access to their mutable state goes through a synchronization mechanism that prohibits concurrent access. So for those cases where we've accepted shared mutability as a necessity, we can at least prevent data races as well.

With the accepted form of the actors proposal, the above statement is no longer precise. Actors don't just protect their mutable state, they protect all of their state, even the immutable state that doesn't need any data-race protection. This sounds like a win for consistency, because all actor state is treated consistently.

However, it undermines the more foundational understanding of the importance of immutability for data-race-free concurrent code. Immutable classes are safe to use concurrently without synchronization; immutable local variables can be captured in closures that are used concurrently without synchronization; immutable global variables and static variables are safe to use concurrently without synchronization. Only actors require you to go through a synchronization mechanism to access their immutable state.

We've traded away a consistent view of the role of immutability in concurrent programming that we've built over the years for a much more narrow definition of consistency around actors.

What is the technical reason for nonisolated?

Given that nonisolated isn't needed to ensure data-race safety on let properties, what is the technical reason to require nonisolated let? The primary technical reason is that it preserves the ability to refactor a let into a var without breaking client code. This is a freedom that is specifically called out in Swift's library evolution story, because it preserves freedom for the author of a library. For example, I can start by vending a public API like this:

public let bestLanguageVersion: Int = 5.4

but then later make this mutable, either for myself or by everyone:

public private(set) var bestLanguageVersion: Int = 5.4

This is useful! It means you aren't likely to pain yourself in a corner by making something immutable early on. Let's make this concrete for actors specifically, and say the first version of our bank account library looks like this:

// BankActors library version 1
public actor BankAccount {
  public let accountNumber: Int
  public let owners: [String]
  public var balance: Double
}

And then some client code gets written based on this version,

import BankActors

func displayAccount(account: BankAccount) {
  print("Account #\(account.accountNumber) owned by \(account.owners.joined(separator: ", "))")
}

The client is accessing only immutable state, so the synchronous access is okay. But now if we change our BankAccount actor to allow changing ownership of the account:

// BankActors library version 2
public actor BankAccount {
  public let accountNumber: Int
  public var owners: [String]
  public var balance: Double
}

we have a problem: the client code will now fail to compile, because owners needs to be accessed asynchronously. Worse, if the client code isn't recompiled but runs against an updated version of the library, we'll have a silent data race.

This is a problem worth solving, and nonisolated let as a concept is the right way to do it: it's explicitly promise to the client that a let will always be nonisolated even if, say, it later gets refactored into a computed property.

So... just always require nonisolated let for synchronous access?

That's where SE-0306 landed. The technical issue with being unable to evolve actor lets into vars without breaking clients is a serious one that needs to be solved, and nonisolated let solves that. Philosophically, we convinced ourselves that it was simpler to describe the behavior of actors as protecting all of their state, and only allowing synchronous access to instance members explicitly marked as nonisolated.

Having implemented this behavior and tried it out both in code and discussions, I learned a couple of things:

  1. The need for nonisolated let is more common than I had anticipated, and comes up very early in the process of learning to use actors with the first examples one tries,
  2. The need for nonisolated for anything else is much less common, and folks wouldn't need to learn about nonisolated until much later, and
  3. The whole narrative about shared mutable state discussed above is really important for understanding what's safe in concurrent Swift, and nonisolated let seriously undercuts that message.

Proposal: Synchronous access to actor let properties is allowed within the current module

My proposal is simple: allow synchronous access to actor let properties within the module that defines the actor. Outside of that module, one must interact with the actor let property asynchronously (to leave the actor author the freedom to make it a var) or the actor author must explicitly give up that freedom by marking the let as nonisolated.

This approach of reducing boilerplate, supporting progressive disclosure, and flattening the learning curve within a module is heavily precedented in Swift. Some examples include:

  • Access control defaults to internal, so you can use a declaration across your whole module but have to explicitly opt in to making it available outside your module (e.g., via public). You can ignore access control until the point where you need to make something public.
  • The implicit memberwise initializer of a struct is internal. You need to write a public initializer yourself to commit to allowing that struct to be initialized with exactly that set of parameters.
  • Inheritance from a class is permitted by default when the superclass is in the same module. To inherit from a superclass defined in a different module, that superclass must be explicitly marked open.
  • Overriding a declaration in a class is permitted by default when the overridden declaration is in the same module. To override from a declaration in a different module, that overrides declaration must be explicitly marked open.

This proposal addresses the technical problem that motivated nonisolated let, that of accidentally over-promising to clients, while maintaining the simpler, more teachable narrative to what actors do and why.

For the actual impact on the proposal text, see my pull request.

Doug

30 Likes

While I agree with the proposed change, I think we need to look deeper at what is the meaning, implications and role of immutable state in an actor (whose primary purpose is to bundle a bunch of related mutable state).

To clarify how I see it, let's have a brief look at what we referred to as distributed actor and quickly put aside as being out of scope. Distributed actor is a special case of an indirect actor. An indirect actor is an actor where you don't have its true reference (address). What you have is a handle to it (something like a witness or proxy). This indirection can be used for many purposes besides distributing the actual mutable state to some other process or host. For example, it can be used to implement actors that can crash and be re-instantiated almost transparently. (Only the client that triggered the crash will notice something happened)

There are many other use cases and if we integrate indirect actors as deeply as regular actors into the language it will be a huge win. Lets look at another example: Ensuring a unique instance will represent some external source of truth (like persistent data stored in a database), or lazily instantiating an expensive mutable object graph (Is anybody here old enough to remember Enterprise Objects Framework?)

Note that if the actor has immutable state, it can always be safely cached in the handle (which is a client-side entity) and be available for synchronous local access by the client without having to await anything.

Now consider this question: Can a truly immutable state of an actor be anything other than part of its identity?

At the code level, the identity of the actor is its reference or memory address. At the problem domain level, an actor (or class) may have some domain level identity. Look at account number in the case of our bank account example. It is a let property because it is the domain-level identity of the account. We model these identifying attributes with let properties.

I believe we need to come up with a way to make such identifying properties of actors (and classes) explicit and part of the API contract. This is a a higher-level concept and beyond nonisolated. Actually, I think marking them nonisolated can be conceptually misleading.

There is a lot more to discuss and I have many ideas around it but I don't have time to write more now.

Let me know if you are interested in continuing this discussion, maybe on another thread.

4 Likes

The issue raised here is very critical, but I suggest we first have SE-0313 (SE-0313: Improved control over actor isolation) finalized and accepted, then we can look back at how to solve the synchronous accessing of nonisolated let issue.

The main reason is that currently we have two approaches towards actor isolation:

  • Using nonisolated let as value-directed way (which is described in the main body of SE-0313).
  • Using @sync MyActor and @async MyActor as a type-directed way (which is mentioned in the alternatives considered section of SE-0313, see Exploration: Type System Considerations for Actor Proposal).

While I have replied in SE-0313 that we should discuss more about the pros and cons of these two approaches, I find it quite easy to solve the problem raised in this proposal with the type-directed way (@sync MyActor and @async MyActor):

This is the free function that mentioned in this proposal

Which is equivalent to the following definition using type-directed way:

func displayAccount(account: @sync BankAccount) {
 // Same synchronous accessing to BackAccount actors
}

So with the type-directed approach, no matter how we change between the let and var for property declarations, we can always access these from an actor, as long as it is a @sync BackAccount.

Actually the issue raised in this proposal convinced me that the type-directed approach (@sync MyActor and @async MyActor) is a better way to solve those issues related to actor isolation, because we use the type system to handle the cases in a uniformed way instead of creating complex syntax rules just for fixing holes.

Was it ever considered to default all actor members to nonisolated, requiring an explicit isolated to all actor stored var properties and isolated functions? I couldn't quickly find it mentioned in the actors proposal at least.

In that model, the BankAccount example would look something like this:

actor BankAccount {
  // immutable stored properties are allowed:
  let accountNumber: Int

  // mutable local state is special and needs `isolated` annotation:
  isolated var balance: Double
  var bad: Bool // error: stored mutable property on actor must be isolated

  // computed nonisolated properties can access nonisolated (immutable) state
  var checksum: Int { accountNumber % 9 }

  // functions are nonisolated by default:
  static func ==(lhs: BankAccount, rhs: BankAccount) -> Bool {
    lhs.accountNumber == rhs.accountNumber
  }

  // only an isolated member can access its own isolated state synchronously:
  isolated func transfer(amount: Double, to other: BankAccount) async throws {
    assert(amount > 0)
    if amount > balance {
      throw BankError.insufficientFunds
    }
    balance = balance - amount
    await other.deposit(amount: amount)
  }
}

So, we have been thinking about distributed actors and have a pretty solid idea of a workable model (with a pull request that implements them). I'll lean on that for my answers.

It can be part of the handle, yes. However, in the distributed case, that might not be desirable, because creating a remote proxy for an actor then requires ping-ponging with the machine that has the real actor, so you can replicate the extra information.

Whatever state makes up the identity has to be immutable and replicated, yes. In an indirect or distributed actor case, it's something more than the address of the local object. In the pull request I referenced earlier, identity is the (network) address of the actor + the transport used to talk to it.

I don't understand this last bit about nonisolated being misleading. If something is part of the identity, you're going to need to be able to access it synchronously (hence, nonisolated) and for the distributed case, you also need it to be replicated so it's available on a remote proxy for the actor.

We're getting a bit far afield of nonisolated let, so it's probably best to go to another thread to dig further into distributed (or indirect) actors. We were planning on starting a discussion of distributed actors in a couple of weeks, once more of the non-distributed actors dust settles.

Hmm. Whether isolated is handled as a value-directed notion or a type-directed one should be orthogonal to nonisolated. Either way, a nonisolated method in an actor will alter some aspect of its self parameter, making it either non-isolated (in the value-directed design) or making it @async (in the type-directed design).

Your description of the semantics of the type-directed approach is correct. However, your second displayAccount is not equivalent to the example I used. The type-directed equivalent to the example I used is:

func displayAccount(account: @async BankAccount) {
 print("Account #\(account.accountNumber) owned by \(account.owners.joined(separator: ", "))")
}

The value-directed equivalent to your @sync version is this:

func displayAccount(account: isolated BankAccount) {
 // Same synchronous accessing to BackAccount actors
}

There are certainly differences between the type-directed and value-directed approaches, and we should continue to discuss those over in the review of SE-0313, but they should not have any impact on nonisolated.

Based on the above, I think you're misunderstanding the differences between the approaches. There are definitely differences to discuss, but both approaches can model what you're discussing quite directly.

We haven't seriously considered it, no. Outside of nonisolated let, use of nonisolated in actors is actually quite rare. That's part of the reason I'm making this correct proposal, because (if you put nonisolated let aside) you can make great use of actors without having to learn about nonisolated for quite a while. If we switch the polarity like you mention, there will be a lot of isolated and relatively few things in an actor that aren't labeled as isolated.

Doug

2 Likes

This change seems reasonable to me, but I do have a question about how it relates to protocol conformance. If an actor adopts CustomStringConvertible (for example) and satisfies its requirement using let description, would that need to be marked nonisolated?

2 Likes

I am not at all certain, but if the conformance and implementation lives in the module of the actor then I assume that you don’t need nonisolated.

If you then change the variable to be a var rather than let, then your conformance implementation wouldn’t compile anymore.

If the actor itself is internal, then it should not need to mark description explicitly as nonisolated within the logic of what's proposed here. However, if it's a public actor, then as with all public types conforming to public protocols, all requirements must be satisfied publicly. This would mean that description must be explicitly nonisolated.


Overall, I appreciate the effort to take a progressive disclosure approach so that users don't have to learn about nonisolated up front. However, I do worry that this adds yet another point of complexity that does require learning later. In the end, I do think this is a sensible compromise that addresses the let-to-gettable var issue appropriately with more benefits than drawbacks.

6 Likes

Thank you - guess I’d never spotted the public/internal conformance thing for protocols before, so the progressive disclosure of that part certainly worked for me!

I think if there were a compiler fixit for the case where a protocol witness was only invalid because it wasn’t marked nonisolated, that would be clear enough. I’m thinking of the case where a previously internal type (where the conformance ‘just worked’) was made public

Okey, here we go again... :wink:

:+1: Overall, I'm supportive of this but at least I'll write down some thoughts.

Yeah that is fair to say.

The general allow "immutable let" access

I think I said this before every time the topic came up: I see this "allow Sendable let things to be accessed" as generally fine... It's presence by default or not (so one can do it anyway via adding nonisolated let) doesn't really change much, the feature exists anyway and is necessary.

The proposal change is a bit hand-wavy around non-Sendable let-properties, can we clarify the plan there? The proposal now has:

actor MyActor {
  let name: String
  var counter: Int = 0
  func f()
}
 extension MyActor {
   func g(other: MyActor) async {
     print(name)          // okay, name is non-isolated
     print(other.name)    // okay, name is non-isolated
     print(counter)       // okay, g() is isolated to MyActor
     print(other.counter) // error: g() is isolated to "self", not "other"
     f()                  // okay, g() is isolated to MyActor
     await other.f()      // okay, other is not isolated to "self" but asynchronous access is permitted

I would really like to make sure the expectation is that:

class Terrible { var x: Int } // NOT Sendable

actor MyActor { 
  let ohNo: Terrible
}

extension MyActor {
  func g(other: MyActor) async {
    other.ohNo // error: ?

Would the diagnosis be that the property must be accessed via await, or that it cannot be accessed at all because it is not Sendable?

Can we some specific examples what to expect here? Or is this out of scope entirely because lack of Sendable enforcement? If so, can we at least allude to what the future will potentially look here?

I can see two ways this could be diagnosed: either it'd force an await on other.ohNo (weird), or error due to not-Sendable cross actor access (that's what I'm expecting, am I right?).

Incomplete isolation

The only piece I actually wonder about if this will cause issues in the future is if it won't make it hard to implement "crash + restart just the actor rather than process" in those plain normal actors, as those synchronous accesses would be problematic for it.

On the other hand, perhaps such pattern will not really fit our local actors so well and rather it would take form of "panic" handling?

I have used and implemented numerous actor runtimes with supervision (akka, akka typed, some implementations in swift), and actor isolation does indeed allow for "restart supervision" but at the same time, it not always is quite enough and it becomes a more complex feature than just "replace the instance". I.e. we'd want to replace instances on an exponential backoff timer etc... Those more complex patterns perhaps we could attempt to solve in library land, using proxies, rather than in the core of the actor itself.

I guess what I'm saying is: the change of default isolated/nonisolated for those let properties does not change our general ability to look into crash handling better in the future, because nonisolated exists anyway.

Diff within/outside module behavior

At first I was very worried about the "differently within module / outside module" but your list of already existing behaviors which are similar to this (open / internal etc) convinced me this can be fine.

It is becoming a lot of "axis" to think about: isolation, access control, sendability, and distribution... but I guess this is a pretty precise model of the domain :thinking:


nonisolated and distributed actors

@hooman, regarding distributed actor -- this proposal change does not affect them much.

I can speak a bit more to that, but diving deep into distributed actors should likely be their own discussion which I can't wait to kick off soon. To not side-track this review, lemme fold it up here, and I'll be more than happy to discuss distribution in a separate thread very soon :slight_smile:

Discussion of `nonisoalted`, isolation in general and `distributed actor`

Distributed actors are identifier by their (network) address, which may happen to be a local address. This address property would in any case be nonisolated let, regardless of this proposal change.

Other properties of a distributed actor type may be nonisolated (i.e. they'd adhere to the same rules as this proposal ends up with), however they cannot be accessible directly in any case, only distributed functions are accessible on a "potentially remote actor" this is an important piece of the design. It is not realistically possible to magically replicate "just the nonisolated pieces" of an instance from a remote peer during spawning of an actor. It's not what we (or any other distributed actor runtime) are interested in solving.

A nonisolated property on a distributed actor can therefore be accessed without awaiting, only on the same process where the actor is hosted. We can check this using some actor.whenLocal { a in a.prop } function, which "peels off" the distributed-ness of the actor, and only runs the closure if the actor indeed was local. This is the only place where nonisolated shows up in distributed actors pretty much.

There are exactly two "special" distributed actor properties such as: address and transport are indeed actor nonisolated, but also "distributed actor nonisolated". This is however a very privileged mechanism and is not available to user code, because it affects how we create, allocate, and store the actors if they are remote "proxy" references.


Nitpicks

Oh boy the first sentence reads terribly here; I checked the proposal and we avoided such unfortunate wording there so all is good. But I wanted to call this out as to not give other readers the idea to repeat this wording: actors do not describe shared mutable state. The mechanism you describe is exactly what makes the state not shared after all.

I know you know that and are familiar with the details, just the wording there is very unfortunate and I'd hate for it to be replicated elsewhere.

6 Likes

I am asking for a syntax to indicate: "This property is constant as long as the program is running." For example, being able to express this API:

protocol BankAccount {
    const accountNumber: UInt64 // Straw-man syntax
    var accountHolder: String { get }
    var currentBalance: Double { get }
    // ...
}

If we had something like this, we could say such const property is not mutable, hence does not need actor isolation and await without the need for nonisolated modifier. If we had const we would rarely need nonisolated, if at all.

I think the alternative possibility of using nonisolated for this purpose does not clearly communicate the intent and is not making any guarantees about mutability and is meaningless outside actor-related APIs. Example:

protocol BankAccount {
    nonisolated var accountNumber: String { get } // What does this convey?
    var accountHolder: String { get }
    var currentBalance: Double { get }
    // ...
}

It would also help with properties that participate in the identity of something. For example, we could write Identifiable protocol like this:

public protocol Identifiable {
    associatedtype ID : Hashable
    const id: Self.ID
}

Adding this const feature without actors might not carry its weight, but now it seems more appealing. Besides creating new complier optimization opportunities, I have other uses for const in mind that can help us with things like fixed-sized arrays and generic value parameters and how to actually write them in a non-convoluted way.

Also note that let would appear as const in module public API (instead of var {get}) and could satisfy const protocol requirements, while we would be able to have compiler verifiable computed const declarations, distinct from var {get} declarations. This would completely define away module API evolution argument that lead to all this.

I know a new const feature is off topic, but I think it is relevant to note a more expressive alternative to nonisolated let can exist one day.

1 Like