An Introduction to Swift Actors

Structured concurrency in Swift provides a powerful platform for performing multiple tasks at the same time, greatly increasing app performance and responsiveness. One of the downsides of concurrency is that it can lead to problems when multiple tasks access the same data concurrently, and that access includes a mix of reading and writing operations. This type of problem is referred to as a data race and can lead to intermittent crashes and unpredictable app behavior.

In the previous chapter, we looked at a solution to this problem that involved sequentially processing the results from multiple concurrent tasks. Unfortunately, that solution only really works when the tasks involved all belong to the same task group. A more flexible solution and one that works regardless of where the concurrent tasks are launched involves the use of Swift actors.

An Overview of Actors

Actors are a Swift type that controls asynchronous access to internal mutable state so that only one task at a time can access data. Actors are much like classes in that they are a reference type and contain properties, initializers, and methods. Like classes, actors can also conform to protocols and be enhanced using extensions. The primary difference when declaring an actor is that the word “actor” is used in place of “class”.

Declaring an Actor

A simple Swift class that contains a property and a method might be declared as follows:

class BuildMessage {
    
    var message: String = ""
    let greeting = "Hello"
    
    func setName(name: String) {
        self.message = "\(greeting) \(name)"
    }
}

To make the class into an actor we just need to change the type declaration from “class” to “actor”:

 

You are reading a sample chapter from SwiftUI Essentials – iOS 15 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 59 chapters and over 520 pages of in-depth information).

Learn more.

Preview  Buy eBook  Buy Print

 

actor BuildMessage {
    
    var message: String = ""
    let greeting = "Hello"
    
    func setName(name: String) {
        self.message = "\(greeting) \(name)"
    }
}

Once declared, actor instances are created in the same way as classes, for example:

let hello = BuildMessage()

A key difference between classes and actors, however, is that actors can only be created and accessed from within an asynchronous context, such as within an async function or Task closure. Also, when calling an actor method or accessing a property, the await keyword must be used, for example:

func someFunction() async {
    let builder = BuildMessage()
    await builder.setName(name: "Jane Smith")
    let message = await builder.message
    print(message)
}

Understanding Data Isolation

The data contained in an actor instance is said to be isolated from the rest of the code in the app. This isolation is imposed in part by ensuring that when a method that changes the instance data (in this case, changing the name variable) is called, the method is executed to completion before the method can be called from anywhere else in the code. This prevents multiple tasks from concurrently attempting to change the data. This, of course, means that method calls and property accesses may have to wait until a previous task has been handled, hence the need for the await statement.

Isolation also prevents code from directly changing the mutable internal properties of an actor. Consider, for example, the following code to directly assign a new value to the message property of our BuildMessage instance.

builder.message = "hello"

Though valid when working with class instances, the above code will generate the following error when attempted on an actor instance:

 

You are reading a sample chapter from SwiftUI Essentials – iOS 15 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 59 chapters and over 520 pages of in-depth information).

Learn more.

Preview  Buy eBook  Buy Print

 

Actor-isolated property ‘message’ can not be mutated from a non-isolated context

By default, all methods and mutable properties within an actor are considered to be isolated and, as such, can only be called using the await keyword. Actor methods that do not access mutable properties may be excluded from isolation using the nonisolated keyword. Once declared in this way, the method can be called without the await keyword, and also called from synchronous code. We can, for example, add a nonisolated method to our BuildMessage actor that returns the greeting string:

actor BuildMessage {
    
    var message: String = ""
    let greeting = "Hello"
    
    func setName(name: String) {
        self.message = "\(greeting) \(name)"
    }
    
    nonisolated func getGreeting() -> String {
        return greeting
    }
}

This new method can be called without the await keyword from both synchronous and asynchronous contexts:

var builder = BuildMessage()
 
func asyncFunction() async {
    let greeting = builder.getGreeting()
    print(greeting)
}
 
func syncFunction() {
    let greeting = builder.getGreeting()
    print(greeting)
}

It is only possible to declare getGreeting() as nonisolated because the method only accesses the immutable greeting property. If the method attempted to access the mutable message property, the following error would be reported:

Actor-isolated property 'message' can not be referenced from a non-isolated context

Note that although immutable properties are excluded from isolation by default, the nonisolated keyword may still be declared for clarity:

nonisolated let greeting = "Hello"

A Swift Actor Example

In the previous chapter, we looked at data races and explored how the compiler will prevent us from writing code that could cause one with the following error message:

 

You are reading a sample chapter from SwiftUI Essentials – iOS 15 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 59 chapters and over 520 pages of in-depth information).

Learn more.

Preview  Buy eBook  Buy Print

 

Mutation of captured var ‘timeStamps’ in concurrently-executing code

We encountered this error when attempting to write entries to a dictionary object using the following asynchronous code:

func doSomething() async {
 
    var timeStamps: [Int: Date] = [:]
    
    await withTaskGroup(of: Void.self) { group in
        for i in 1...5 {
            group.addTask {
                timeStamps[i] = await takesTooLong()
            }
        }
    }
}

One option to avoid this problem, and the one implemented in the previous chapter, was to process the results from the asynchronous tasks sequentially using a for-await loop. As we have seen in this chapter, however, the problem could also be resolved by making use of an actor.

With the ConcurrencyDemo project loaded into Xcode, edit the ContentView.swift file to add the following actor declaration is used to encapsulate the timeStamps dictionary and to provide a method via which data can be added:

import SwiftUI
 
actor TimeStore {
    
    var timeStamps: [Int: Date] = [:]
    
    func addStamp(task: Int, date: Date) {
        timeStamps[task] = date
    }
}
.
.

Having declared the actor, we can now modify the doSomething() method to add new timestamps via the addStamp() method:

func doSomething() async {
 
    let store = TimeStore()
    
    await withTaskGroup(of: Void.self) { group in
        for i in 1...5 {
            group.addTask {
                await store.addStamp(task: i, date: await takesTooLong())
            }
        }
    }
 
    for (task, date) in await store.timeStamps {
            print("Task = \(task), Date = \(date)")
    }
}
 
func takesTooLong() async -> Date {
    Thread.sleep(forTimeInterval: 5)
    return Date()
}

With these changes made, the code should now compile and run without error.

 

You are reading a sample chapter from SwiftUI Essentials – iOS 15 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 59 chapters and over 520 pages of in-depth information).

Learn more.

Preview  Buy eBook  Buy Print

 

Introducing the MainActor

In the chapter entitled An Overview of Swift Structured Concurrency, we talked about the main thread (or main queue) and explained how it is responsible both for handling the rendering of the UI and also responding to user events. We also demonstrated the risks of performing thread blocking tasks on the main thread and how doing so can cause the running program to freeze. As we have also seen, Swift provides a simple and powerful mechanism for running tasks on separate threads from the main thread. What we have not mentioned yet is that it is also essential that updates to the UI are only performed on the main thread. Performing UI updates on any separate thread other than the main thread is likely to cause instability and unpredictable app behavior that can be difficult to debug.

Within Swift, the main thread is represented by the main actor. This is referred to as a global actor because it is An Introduction to Swift Actors accessible throughout your program code when you need code to execute on the main thread.

When developing your app, situations may arise where you have code that you want to run on the main actor, particularly if that code updates the UI in some way. In this situation, the code can be marked using the @ MainActor attribute. This attribute may be used on types, methods, instances, functions, and closures to indicate that the associated operation must be performed on the main actor. We could, for example, configure a class so that it operates only on the main thread:

@MainActor
class TimeStore {
    
    var timeStamps: [Int: Date] = [:]
    
    func addStamp(task: Int, date: Date) {
        timeStamps[task] = date
    }
}

Alternatively, a single value or property can be marked as being main thread dependent:

class TimeStore {
    
    @MainActor var timeStamps: [Int: Date] = [:]
 
    func addStamp(task: Int, date: Date) {
        timeStamps[task] = date
    }
}

Of course, now that the timeStamps dictionary is assigned to the main actor, it cannot be accessed on any other thread. The attempt to add a new date to the dictionary in the above addStamp() method will generate the following error:

 

You are reading a sample chapter from SwiftUI Essentials – iOS 15 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 59 chapters and over 520 pages of in-depth information).

Learn more.

Preview  Buy eBook  Buy Print

 

Property 'timeStamps' isolated to global actor ‘MainActor’ can not be mutated from this context

To resolve this issue, the addStamp() method must also be marked using the @MainActor attribute:

@MainActor func addStamp(task: Int, date: Date) {
    timeStamps[task] = date
}

The run method of the MainActor can also be called directly from within asynchronous code to perform tasks on the main thread as follows:

func runExample() async {
 
    await MainActor.run {
        // Perform tasks on main thread
    }
}

Summary

A key part of writing asynchronous code is avoiding data races. A data race occurs when two or more tasks access the same data and at least one of those tasks performs a write operation. This can cause data inconsistencies where the concurrent tasks see and act on different versions of the same data.

A useful tool for avoiding data races is the Swift Actor type. Actors are syntactically and behaviorally similar to Swift classes but differ in that the data they encapsulate is said to be isolated from the rest of the code in the app. If a method within an actor that changes instance data is called, that method is executed to completion before the method can be called from anywhere else in the code. This prevents multiple tasks from concurrently attempting to change the data. Actor method calls and property accesses must be called using the await keyword.

The main actor is a special actor that provides access to the main thread from within asynchronous code. The @ MainActor attribute can be used to mark types, methods, instances, functions, and closures to indicate that the associated task must be performed on the main thread.

 

You are reading a sample chapter from SwiftUI Essentials – iOS 15 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 59 chapters and over 520 pages of in-depth information).

Learn more.

Preview  Buy eBook  Buy Print