Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

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.

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 BankAccount {
    enum BankError: Error {
        case insufficientFunds
    }
    
    var balance: Double
    
    init(initialDeposit: Double) {
        self.balance = initialDeposit
    }
    
    func withdraw(amount: Double) throws {
        guard balance >= amount else {
            throw BankError.insufficientFunds
        }
        balance -= 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 withdraw(amount: Double) throws {
    guard balance >= amount else {
        throw BankError.insufficientFunds
    }
    balance -= 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. However, as mentioned, the default for actor methods is isolated, so there’s no need to use this keyword explicitly.

The Essential Swift Concurrency Course for a Seamless Swift 6 Migration.

Learn all about Swift Concurrency in my flagship course offering 57+ lessons, videos, code examples, and an official certificate of completion.

Marking actor parameters as isolated

Using the isolated keyword for parameters can be pretty useful for preventing unnecessary suspensions points. In other words, you’ll need fewer await statements for the same result.

To illustrate this, we could introduce a new Charger type that charges money from a bank account:

struct Charger {
    static func charge(amount: Double, from bankAccount: BankAccount) async throws -> Double {
        try await bankAccount.withdraw(amount: amount)
        let newBalance = await bankAccount.balance
        return newBalance
    }
}

As you can see, we have two different suspension points:

  • One to withdraw the given amount
  • Another one to read the new balance

We could optimize this in several ways, one including to add a new method to BankAccount. However, sometimes you’re not in control of the actor internals. In this case, we can use the isolated keyword in front of the parameter:

/// Due to using the `isolated` keyword, we only need to await at the caller side.
static func charge(amount: Double, from bankAccount: isolated BankAccount) async throws -> Double {
    try bankAccount.withdraw(amount: amount)
    let newBalance = bankAccount.balance
    return newBalance
}

By using the isolated parameter, we basically instruct the whole method to be isolated to the given actor. In this case, we instruct the whole method to be isolated to the BankAccount actor. Since there can only be one isolation at the same time, you can only use one isolated parameter. Obviously, you can also only use the isolated keyword with an Actor type.

Isolated Closure Parameters

Isolated parameters can be used inside closures as well. This can be useful if you want to perform constant operations around a given dynamic action. A common example is a database transaction:

actor Database {
    func beginTransaction() {
        // ...
    }
    
    func commitTransaction() {
        // ...
    }
    
    func rollbackTransaction() {
        // ...
    }
    
    /// By using an isolated `Database` parameter inside the closure, we can access `Database`-actor isolation from anywhere
    /// allowing us to perform multiple database queries with just one `await`.
    func transaction<Result>(_ transaction: @Sendable (_ database: isolated Database) throws -> Result) throws -> Result {
        do {
            beginTransaction()
            let result = try transaction(self)
            commitTransaction()
            
            return result
        } catch {
            rollbackTransaction()
            throw error
        }
    }
}

The transaction closure contains an isolated parameter using the Database actor. This results in a way for us to perform multiple database queries from outside the Database while only having to await once:

let database = Database()
try await database.transaction { database in
    database.insert("<some entity>")
    database.insert("<some entity>")
    database.insert("<some entity>")
}

Adding a generic isolated closure for any actor

We can take this one step further by adding an extension for any Actor type:

extension Actor {
    /// Adds a general `perform` method for any actor to access its isolation domain to perform
    /// multiple operations in one go using the closure.
    @discardableResult
    func performInIsolation<T: Sendable>(_ block: @Sendable (_ actor: isolated Self) throws -> T) async rethrows -> T {
        try block(self)
    }
}

This would allow us to access the isolation domain of any actor and perform multiple operations on it in one go. For example, we could rewrite the earlier bank charging without the extra Charger instance method:

let bankAccount = BankAccount(initialDeposit: 200)
try await bankAccount.performInIsolation { bankAccount in
    try bankAccount.withdraw(amount: 20)
    print("New balance is \(bankAccount.balance)")
}

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 BankAccount {
    
    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 BankAccount {

    let accountHolder: String

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

    // ...
}

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

Even though we access immutable data, the compiler enforces isolated access.

The accountHolder property is immutable, so we can explicitly mark the computed property as nonisolated and solve the error:

actor BankAccount {

    let accountHolder: String

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

    // ...
}

The nonisolated keyword is a way to opt-out of the actor isolation, removing the need to access the value using await.

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 BankAccount: CustomStringConvertible {
    var description: String {
        "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 BankAccount: CustomStringConvertible {
    nonisolated var description: String {
        "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.

Continuing your journey into Swift Concurrency

The concurrency changes are more than just async-await and include many new features that you can benefit from in your code. So while you’re at it, why not dive into other concurrency features?

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!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.

Are you ready to

Turn your side projects into independence?

Learn my proven steps to transform your passion into profit.