Skip to content

Latest commit

 

History

History
363 lines (271 loc) · 18.4 KB

0313-actor-isolation-control.md

File metadata and controls

363 lines (271 loc) · 18.4 KB

Improved control over actor isolation

Table of Contents

Introduction

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.

Motivation

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 make BankAccount conform to the Hashable protocol.

Proposed design

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.

Actor-isolated parameters

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:)'
    // ...
  }
}

Non-isolated declarations

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?`
}

Protocol conformances

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())"
  }
}

Pre-async asynchronous protocols

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.

Source compatibility

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.

Effect on ABI stability

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.

Effect on API resilience

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.

Future Directions

Multiple isolated parameters

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.

Isolated protocol conformances

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.

Alternatives Considered

Isolated or sync actor types

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 to f differs significantly whether you're in the closure or not. A recent discussion on the forums about narrowing types showed resistence 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 to MyActor, 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 to Sendable. A non-@sync actor type conforms to Sendable (it's safe to share it across concurrency domains), but its corresponding @sync subtype does not conform to Sendable. This is why in the prior example's call to acceptSendable, the implicit conversion from @sync MyActor to MyActor is required.

Revision history

  • Changes in the accepted version of this proposal:
    • Removed isolated captures.
    • Prohibit multiple isolated parameters.