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:
- Types that have value semantics are also
Sendable
, by definition, allowing them to be safely copied across different concurrency domains. -
@Sendable
closures are not permitted to capture local variables by reference.
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 let
s into var
s 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:
- 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, - The need for
nonisolated
for anything else is much less common, and folks wouldn't need to learn aboutnonisolated
until much later, and - 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., viapublic
). You can ignore access control until the point where you need to make somethingpublic
. - The implicit memberwise initializer of a
struct
isinternal
. You need to write apublic
initializer yourself to commit to allowing thatstruct
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