- Proposal: SE-0313
- Authors: Doug Gregor, Chris Lattner
- Review Manager: Ted Kremenek
- Status: Implemented (Swift 5.5)
- Previous revision: 1
- Implementation: Partially available in recent
main
snapshots behind the flag-Xfrontend -enable-experimental-concurrency
- Introduction
- Motivation
- Proposed design
- Source compatibility
- Effect on ABI stability
- Effect on API resilience
- Future Directions
- Alternatives Considered
- Revision history
The Swift actors proposal introduces the notion of actor-isolated declarations, which are declarations that can safely access an actor's isolated state. In that proposal, all instance methods, instance properties, and instance subscripts on an actor type are actor-isolated, and they can synchronously use those declarations on self
. This proposal generalizes the notion of actor isolation to allow better control, including the ability to have actor-isolated declarations that aren't part of an actor type (e.g., they can be non-member functions) and have non-isolated declarations that are instance members of an actor type (e.g., because they are based on immutable, non-isolated actor state). This allows better abstraction of the use of actors, additional actor operations that are otherwise not expressible safely in the system, and enables some conformances to existing, synchronous protocols.
The actors proposal uses a simple actor BankAccount
, which has some immutable and some mutable state in it:
actor BankAccount {
let accountNumber: Int
var balance: Double
init(accountNumber: Int, initialDeposit: Double) {
self.accountNumber = accountNumber
self.balance = initialDeposit
}
func deposit(amount: Double) {
assert(amount >= 0)
balance = balance + amount
}
}
There are a few seemingly obvious things that one cannot do with this actor:
- We can't extract an operation like
deposit(amount:)
into a global function; it can only be written as a member of the actor. - We can't write a computed property that provides a convenient display name for a bank account instance that's usable synchronously from outside the actor.
- We can't create a
Set<BankAccount>
because there is no way to makeBankAccount
conform to theHashable
protocol.
All of the limitations described above stem from the fact that instance methods (and properties, and subscripts) on an actor type are always actor-isolated; no other functions can be actor-isolated and there is no way to make an instance method (etc.) not be isolated. This proposal generalizes the notion of actor-isolated functions such that any function can choose to be actor-isolated by indicating which of its actor parameters is isolated, as well as making an instance declaration on an actor not be actor-isolated at all.
A function can become actor-isolated by indicating that one of its parameters is isolated
. For example, the deposit(amount:)
operation can now be expressed as a module-scope function as follows:
func deposit(amount: Double, to account: isolated BankAccount) {
assert(amount >= 0)
account.balance = account.balance + amount
}
Because the account
parameter is isolated, deposit(amount:to:)
is actor-isolated (to its account
parameter) and can access actor-isolated state directly on that parameter. The same actor-isolation rules apply:
extension BankAccount {
func giveSomeGetSome(amount: Double, friend: BankAccount) async {
deposit(amount: amount, to: self) // okay to call synchronously, because self is isolated
await deposit(amount: amount, to: friend) // must call asynchronously, because friend is not isolated
}
}
This makes instance methods on actor types less special, because now they are expressible in terms of a general feature: they are methods for which the self
parameter is isolated
, which one can see when referencing the method's curried type:
let fn = BankAccount.deposit(amount:) // type of fn is (isolated BankAccount) -> (Double) -> Void
A given function cannot have multiple isolated
parameters:
func f(a: isolated BankAccount, b: isolated BankAccount) { // error: multiple isolated parameters in function `f(a:b:)`.
// ...
}
extension BankAccount {
func quickTransfer(amount: Double, to other: isolated BankAccount) { // error: multiple isolated parameters in function 'quickTransfer(amount:to:)'
// ...
}
}
Instance declarations on an actor type implicitly have an isolated self
. However, one can disable this implicit behavior using the nonisolated
keyword:
actor BankAccount {
nonisolated let accountNumber: Int
var balance: Double
// ...
}
extension BankAccount {
// Produce an account number string with all but the last digits replaced with "X", which
// is safe to put on documents.
nonisolated func safeAccountNumberDisplayString() -> String {
let digits = String(accountNumber) // okay, because accountNumber is also nonisolated
return String(repeating: "X", count: digits.count - 4) + String(digits.suffix(4))
}
}
let fn2 = BankAccount.safeAccountNumberDisplayString // type of fn is (BankAccount) -> () -> String
Note that, because self
is not actor-isolated, safeAccountNumberDisplayString
can only refer to non-isolated data on the actor. An attempt to refer to any actor-isolated declaration will produce an error or require asynchronous access, as appropriate:
extension BankAccount {
nonisolated func steal(amount: Double) {
balance -= amount // error: actor-isolated property 'balance' can not be referenced on non-isolated parameter 'self'
}
}
The types involved in a non-isolated declaration must all be Sendable
, because a non-isolated declaration can be used from any actor or concurrently-executing code. For example, one could not return a non-Sendable
class from a nonisolated
function:
class SomeClass { } // not Sendable
extension BankAccount {
nonisolated func f() -> SomeClass? { nil } // error: `nonisolated` declaration returns non-Sendable type `SomeClass?`
}
The actors proposal describes the rule that an actor-isolated function cannot satisfy a protocol requirement that is neither actor-isolated nor asynchronous, because doing so would allow synchronous access to actor state. However, non-isolated functions don't have access to actor state, so they are free to satisfy synchronous protocol requirements of any kind. For example, we can make BankAccount
conform to Hashable
by basing the hashing on the account number:
extension BankAccount: Hashable {
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(accountNumber)
}
}
let fn = BankAccount.hash(into:) // type is (BankAccount) -> (inout Hasher) -> Void
Similarly, one can use a nonisolated
computed property to conform to, e.g. CustomStringConvertible
:
extension BankAccount: CustomStringConvertible {
nonisolated var description: String {
"Bank account #\(safeAccountNumberDisplayString())"
}
}
Non-isolated declarations are particularly useful for adapting existing asynchronous protocols, expressed using completion handlers, to actors. For example, consider an existing simple "server" protocol that uses a completion handler:
protocol OldServer {
func send<Message: MessageType>(
message: Message,
completionHandler: (Result<Message.Reply>) -> Void
)
}
Over time, this protocol should evolve to provide async
requirements. However, one can make an actor type conform to this protocol using a non-isolated declaration that launches a detached task:
actor MyActorServer {
func send<Message: MessageType>(message: Message) async throws -> Message.Reply { ... } // this is the "real" asynchronous implementation we want
}
extension MyActorServer : OldServer {
nonisolated func send<Message: MessageType>(
message: Message,
completionHandler: (Result<Message.Reply>) -> Void
) {
detach {
do {
let reply = try await send(message: message)
completionHandler(.success(reply))
} catch {
completionHandler(.failure(error))
}
}
}
}
This allows actors to more smoothly integrate into existing code bases, without having to first adopt async
throughout.
This proposal is additive, extending the grammar in a space where new contextual keywords are commonly introduced (declaration modifiers), so it will not affect source compatibility.
This is purely additive to the ABI. Function parameters can be marked isolated
, which will be captured as part of the function type. However, this (like other modifiers on a function parameter) is an additive change that won't affect existing ABI.
Nearly all changes in actor isolation are breaking changes, because the actor isolation rules require consistency between a declaration. Therefore, a parameter cannot be changed between isolated
and non-isolated
(either directly, or indirectly via nonisolated
) without breaking the API.
This proposal prohibits a function declaration that has more than one isolated
parameter. We could lift this restriction in the future, to allow code such as:
func f(a: isolated BankAccount, b: isolated BankAccount) {
// ...
}
However, there are very few ways to call such a function in base actors proposal, because one can only run on a single actor at a time. Therefore, the only way to safely call f
is to pass the same actor twice:
extension BankAccount {
func g() {
f(a: self, b: self)
}
func h(other: BankAccount) async {
await f(a: self, b: other) // error: isolated parameters `a` and `b` passed values with potentially-different actors
}
}
There are unsafe mechanisms (e.g., unsafe casting of pointer types) that could be used to pass two different actors that are both isolated. The custom executors proposal provides control over the concurrency domains in which actors execute, which could be used to dynamically ensure that two actors execute in the same concurrency domain. That proposal could be modified or extended to guarantee statically that some set of actors share a concurrency domain to make functions with more than one isolated
parameter more useful in the future.
The conformance of an actor type to a protocol assumes that the client of the protocol is outside of the actor's isolation domain. Therefore, protocol conformances require either the protocol to have async
requirements or the actor to use non-isolated members to establish protocol conformance. The Type System Considerations for Actor Protocol pitch argues that actor types should be able to conform to protocols with the assumption that the conformance is only used within the actor's isolation context. That pitch provides the following example:
public protocol DataProcessible {
var data: Data { get }
}
extension DataProcessible {
func compressData() -> Data {
use(data)
/// details omitted
}
}
actor MyDataActor : DataProcessible {
// error: cannot fulfill sync requirement with isolated actor member.
var data: Data
func doThing() {
// All sync, no problem!
let compressed = compressData()
}
}
That pitch suggests that the conformance of MyDataActor : DataProcessible
be permitted, and introduces the notion of a @sync
actor type to describe the actor when in its own isolation domain. Specifically, the type @sync MyDataActor
conforms to DataProcessible
but the type @async MyDataActor
(which represents the actor outside of its isolation domain) does not.
This proposal does not separate isolated from non-isolated actor types, and instead uses an isolated
parameter to describe the actor that the code is executing on. The same notion can be extended to introduce isolated protocol conformances, which are conformances that can only be used with isolated values. For example, the conformance itself could have isolated
applied to it to mark it as an isolated conformance:
actor MyDataActor : isolated DataProcessible {
var data: Data // okay: satisfies "data" requirement
func doThing() {
// okay, because self is isolated
let compressed = compressData()
}
nonisolated failToDoTheThing() {
// error: isolated conformance MyDataActor : DataProcessible cannot be used when non-isolated
// value of type MyDataActor is passed to the generic function.
let compressed = compressData()
}
}
The use of isolated protocol conformances would require a number of other restrictions to ensure that the protocol conformance cannot be used on non-isolated instances of the actor. For example, this means that a non-isolated conformance can never be used along with Sendable
on the same type, because that would permit a non-isolated instance of the actor to be passed outside of the actor's isolation domain along with a protocol conformance that assumes it is within the actor's isolation domain.
The notion of "isolated" parameters grew out of a proposal that generalized the notion of actor isolation from something that only made sense on self
to one that made sense for any parameter. That proposal modeled isolation directly in the type system by introducing a new kind of type: @sync
actor types were used for values that have synchronous access to the actors they describe. Therefore, instead of saying that self
is an isolated
parameter of type MyActor
, the proposal would say that self
has the type @sync MyActor
. The "isolated conformances" described in the future directions above are similar to (and directly influenced by) the notion that @sync
actor types can conform to (synchronous) protocols as described in that proposal.
At a high level, isolated parameters and isolated conformances are similar to parameters of @sync
type and conformances of @sync
types to protocols, and can address similar sets of use cases. This proposal chose to treat isolated
as a parameter modifier rather than as a type because it provides a simpler, value-centric model that aligns more closely with the behavior of a similarly-constrained construct, inout
. There are several inconsistencies to the @sync
type approach that made it less desirable:
-
The type of an actor's
self
can change within nested contexts, such as closures, between@sync
and non-@sync
:func f<T>(_: T) { } actor MyActor { func g() { f(self) // T = @sync MyActor asyncDetached { f(self) // T = MyActor } } }
Generally speaking, a variable in Swift has the same type when it's captured in a nested context as it does in its enclosing context, which provides a level of predictability that would be lost with
@sync
types. In the example above, type inference for the call tof
differs significantly whether you're in the closure or not. A recent discussion on the forums about narrowing types showed resistance to the idea of changing the type of a variable in a nested context, even when doing so could eliminate additional boilerplate. -
The design relies heavily on the implicit conversion from
@sync MyActor
toMyActor
, e.g.,func acceptActor(_: MyActor) { } func acceptSendable<T: Sendable>(_: T) { } extension MyActor { func h() { acceptActor(h) // okay, requires conversion of @sync MyActor to MyActor acceptSendable(h) // okay, requires T=MyActor and conversion of @sync MyActor to MyActor } }
-
Conformance to
Sendable
doesn't follow the normal subtyping rules. Per the conversion above, a@sync
actor type is a subtype of the corresponding (non-@sync
) actor type. By definition, a subtype has all of the conformances of its supertype, and may of course add more capabilities. This is a general principle of type system design, and shows up in Swift in a number of places, e.g., with subclassing:protocol P { } class C: P { } class D: C { } func test(c: C, d: D) { let _: P = c // okay, C conforms to P let _: P = d // okay, D conforms to P because it is a subtype of C, which itself conforms to P }
However,
@sync
types don't behave this way with respect toSendable
. A non-@sync
actor type conforms toSendable
(it's safe to share it across concurrency domains), but its corresponding@sync
subtype does not conform toSendable
. This is why in the prior example's call toacceptSendable
, the implicit conversion from@sync MyActor
toMyActor
is required.
- Changes in the accepted version of this proposal:
- Removed
isolated
captures. - Prohibit multiple
isolated
parameters.
- Removed