Nonisolated and isolated keywords: Understanding Actor isolation

SE-313 introduced the nonisolated and isolated keywords as part of adding actor isolation control. Actors are a new way of providing synchronization for shared mutable states with the new concurrency framework.

If you’re new to actors in Swift, I encourage you to read my article Actors in Swift: how to use and prevent data races describing them in detail. This article will explain how to control method and parameter isolation when working with actors in Swift.

Understanding the default behavior of actors

By default, each method of an actor becomes isolated, which means you’ll have to be in the context of an actor already or use await to wait for approved access to actor contained data.

You can learn more about async/await in my article Async await in Swift explained with code examples.

It’s typical to run into errors with actors like the ones below:

  • Actor-isolated property ‘balance’ can not be referenced from a non-isolated context
  • Expression is ‘async’ but is not marked with ‘await’

Both errors have the same root cause: actors isolate access to its properties to ensure mutually exclusive access.

Take the following bank account actor example:

actor BankAccountActor {
    enum BankError: Error {
        case insufficientFunds
    }
    
    var balance: Double
    
    init(initialDeposit: Double) {
        self.balance = initialDeposit
    }
    
    func transfer(amount: Double, to toAccount: BankAccountActor) async throws {
        guard balance >= amount else {
            throw BankError.insufficientFunds
        }
        balance -= amount
        await toAccount.deposit(amount: amount)
    }
    
    func deposit(amount: Double) {
        balance = balance + amount
    }
}

Actor methods are isolated by default but not explicitly marked as so. You could compare this to methods that are internal by default but not marked with an internal keyword. Under the hood, the code looks as follows:

isolated func transfer(amount: Double, to toAccount: BankAccountActor) async throws {
    guard balance >= amount else {
        throw BankError.insufficientFunds
    }
    balance -= amount
    await toAccount.deposit(amount: amount)
}

isolated func deposit(amount: Double) {
    balance = balance + amount
}

Though, marking methods explicitly with the isolated keyword like this example will result in the following error:

‘isolated’ may only be used on ‘parameter’ declarations

We can only use the isolated keyword with parameter declarations.

Marking actor parameters as isolated

Using the isolated keyword for parameters can be pretty nice to use less code for solving specific problems. The above code example introduced a deposit method to alter the balance of another bank account.

We could get rid of this extra method by marking the parameter as isolated instead and directly adjust the other bank account balance:

func transfer(amount: Double, to toAccount: isolated BankAccountActor) async throws {
    guard balance >= amount else {
        throw BankError.insufficientFunds
    }
    balance -= amount
    toAccount.balance += amount
}

The result is using less code which might make your code easier to read.

Multiple isolated parameters are prohibited but allowed by the compiler for now:

func transfer(amount: Double, from fromAccount: isolated BankAccountActor, to toAccount: isolated BankAccountActor) async throws {
    // ..
}

Though, the original proposal indicated this was not allowed, so a future release of Swift might require you to update this code.

Using the nonisolated keyword in actors

Marking methods or properties as nonisolated can be used to opt-out to the default isolation of actors. Opting out can be helpful in cases of accessing immutable values or when conforming to protocol requirements.

In the following example, we’ve added an account holder name to the actor:

actor BankAccountActor {
    
    let accountHolder: String

    // ...
}

The account holder is an immutable let and is therefore safe to access from a non-isolated environment. The compiler is smart enough to recognize this state, so there’s no need to mark this parameter as nonisolated explicitly.

However, if we introduce a computed property accessing an immutable property, we have to help the compiler a bit. Let’s take a look at the following example:

actor BankAccountActor {

    let accountHolder: String
    let bank: String

    var details: String {
        "Bank: \(bank) - Account holder: \(accountHolder)"
    }

    // ...
}

If we were to print out details right now, we would run into the following error:

Actor-isolated property ‘details’ can not be referenced from a non-isolated context

Both bank and accountHolder are immutable properties, so we can explicitly mark the computed property as nonisolated and solve the error:

actor BankAccountActor {

    let accountHolder: String
    let bank: String

    nonisolated var details: String {
        "Bank: \(bank) - Account holder: \(accountHolder)"
    }

    // ...
}

Solving protocol conformances with nonisolated

The same principle applies to adding protocol conformance in which you’re sure to access immutable state only. For example, we could replace the details property with the nicer CustomStringConvertible protocol:

extension BankAccountActor: CustomStringConvertible {
    var description: String {
        "Bank: \(bank) - Account holder: \(accountHolder)"
    }
}

Using the default recommended implementation from Xcode, we would run into the following error:

Actor-isolated property ‘description’ cannot be used to satisfy a protocol requirement

Which we can solve again by making use of the nonisolated keyword:

extension BankAccountActor: CustomStringConvertible {
    nonisolated var description: String {
        "Bank: \(bank) - Account holder: \(accountHolder)"
    }
}

The compiler is smart enough to warn us if we accidentally access isolated properties within a nonisolated environment:

Accessing isolated properties from a nonisolated environment will result in a compiler error.
Accessing isolated properties from a nonisolated environment will result in a compiler error.

Conclusion

Actors in Swift are a great way to synchronize access to a shared mutable state. In some cases, however, we want to control actor isolation as we might be sure immutable state is accessed only. By making use of the nonisolated and isolated keywords, we gain precise control over actor isolation.

If you like to learn more tips on Swift, check out the Swift category page. Feel free to contact me or tweet me on Twitter if you have any additional suggestions or feedback.

Thanks!