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:
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?
- MainActor usage in Swift explained to dispatch to the main thread
- How to Use URLSession with Async/Await for Network Requests in Swift
- Async await in Swift explained with code examples
- Swift 6: Incrementally migrate your Xcode projects and packages
- Concurrency-safe global variables to prevent data races
- Unit testing async/await Swift code
- Thread dispatching and Actors: understanding execution
- @preconcurrency: Incremental migration to concurrency checking
- Detached Tasks in Swift explained with code examples
- Task Groups in Swift explained with code examples
- Sendable and @Sendable closures explained with code examples
- AsyncSequence explained with Code Examples
- AsyncThrowingStream and AsyncStream explained with code examples
- Tasks in Swift explained with code examples
- Nonisolated and isolated keywords: Understanding Actor isolation
- Async let explained: call async functions in parallel
- Actors in Swift: how to use and prevent data races
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!