swiftswift-concurrencyswift6

Swift isolated(any Actor)? keyword usage


The sample code below demonstrates two implementations of class Pipeline(one is Actor and other is a class). It is being compiled under Swift 6 settings.

actor Pipeline {
    let updater = Updater()
    
    func startUpdates() {
        updater.startUpdates()
    }
    
    func stopUpdates() async {
        await updater.stopUpdates()
        await updater.stopUpdates2() //<--Build fails with error
        //Sending 'self'-isolated 'self.updater' to nonisolated instance method 'stopUpdates2()' risks causing data races between nonisolated and 'self'-isolated uses
    }
    
}

final class AnotherPipeline {
    let updater = Updater()
    
    func startUpdates() {
        updater.startUpdates()
    }
    
    func stopUpdates() async {
        await updater.stopUpdates()
        await updater.stopUpdates2()
    }
}

final class Updater {
    
    func startUpdates() {
        print("started updates")
    }
    
    func stopUpdates2() async {
        print("stopped updates 2")
    }
    
    func stopUpdates(_ isolation: isolated (any Actor)? = #isolation) async {
        print("stopped updates")
    }
}

As we can see, the build fails when calling stopUpdates2() from the Actor but stopUpdates() causes no problems. Is the isolated(any) keyword just suppressing the errors at compile time in stopUpdates() and fail at runtime when called on the wrong context, or there is more to it?

Sending 'self'-isolated 'self.updater' to nonisolated instance method 'stopUpdates2()' risks causing data races between nonisolated and 'self'-isolated uses

Finally, what exactly is this syntax, I can't find any documentation on the same.

  func stopUpdates(_ isolation: isolated (any Actor)? = #isolation) 

Solution

  • The isolated parameter modifier is documented in SE-0313. By adding an isolated parameter to an otherwise nonisolated function, you make that function isolated to the actor instance that is passed as the isolated parameter.

    To use an example from the SE proposal,

    actor BankAccount {
      let accountNumber: Int
      var balance: Double
    
      init(accountNumber: Int, initialDeposit: Double) {
        self.accountNumber = accountNumber
        self.balance = initialDeposit
      }
    }
    
    func deposit(amount: Double, to account: isolated BankAccount) {
      assert(amount >= 0)
      account.balance = account.balance + amount
    }
    

    deposit is isolated to account, so it can synchronously access account.balance.

    isolated (any Actor)? is just like the above example, but any actor instance can be passed to the parameter.

    isolation: isolated (any Actor)? = #isolation is a parameter that has a default value of #isolation. #isolation is a macro that expands to whatever actor the current context is isolated to (or nil if the current context is nonisolated).

    Macros used as default values of parameters expand on the caller's side. That means, when you call stopUpdates(), you are actually calling stopUpdates(#isolation). #isolation expands to whatever the current actor is. In other words, you are saying that stopUpdates should be isolated to the current actor.

    Without the isolation parameter, stopUpdates2 is always nonisolated. A nonisolated async method is always run on the cooperative thread pool, not isolated to any actor. By doing await updater.stopUpdates2(), you are sending the non-sendable updator from a context isolated to self (the Pipeline actor), to a nonisolated context.

    stopUpdates allows itself to be run in the same isolation context as before, so you are not sending the non-sendable updator to anywhere.