Instruction
Actors
Working with concurrent code can become complex very quickly, especially if you have the state of an object being accessed and changed across different tasks. This accessing and updating of object state across tasks is known as Shared Mutable State.
Shared Mutable State can be a problem when handled incorrectly because the same state can be in use at the same time across multiple tasks. If you have one task writing to the state, while another task is reading the state. Which task has priority to perform its operation, and which value being read is correct?
When this happens, you end up with a situation called a Data Race, where multiple tasks are trying to perform their operation at the same time.
Consider this code example, which builds on the Trip in a navigation app example from the previous lesson:
// 1
public struct Trip {
public let id: String
public let directions: [String]
public let duration: Int
}
public enum TripPart {
case directions([String])
case duration(Int)
}
// 2
class TripStore {
typealias TripTask = Task<Trip, Error>
private var taskLookup = [String: TripTask]()
private var tripLookup = [String: Trip]()
func task(for id: String) -> TripTask? {
return taskLookup[id]
}
func setTask(_ task: TripTask, for id: String) {
taskLookup[id] = task
}
func trip(for id: String) -> Trip? {
return tripLookup[id]
}
func setTrip(_ trip: Trip, for id: String) {
tripLookup[id] = trip
}
}
// 3
class TripClient {
private var tripStore = TripStore()
func getTrip(for id: String) async throws -> Trip {
if let trip = tripStore.trip(for: id) {
return trip
}
if let task = tripStore.task(for: id) {
return try await task.value
}
tripStore.setTask(Task {
return try await withThrowingTaskGroup(of: TripPart.self, returning: Trip.self) { taskGroup in
var directions: [String]!
var duration: Int!
taskGroup.addTask { [unowned self] in
return try await getDirections(for: id)
}
taskGroup.addTask { [unowned self] in
return try await getDuration(for: id)
}
for try await tripPart in taskGroup {
switch tripPart {
case .directions(let retrievedDirections):
directions = retrievedDirections
case .duration(let retrievedDuration):
duration = retrievedDuration
}
}
let newId = id + "-" + String(Int.random(in: 0...1000))
let trip = Trip(id: newId, directions: directions, duration: duration)
tripStore.setTrip(trip, for: id)
tripStore.removeTask(for: id)
return trip
}
}, for: id)
return try await tripStore.task(for: id)!.value
}
// 5
private func getDirections(for tripId: String) async throws -> [String] {
try await simulateNetworkCall()
return [
"Turn left in 500 feet",
"Turn right at the stop light",
"Destination is on your left"
]
}
public func getDuration(for tripId: String) async throws -> Int {
try await simulateNetworkCall()
return 42
}
private func simulateNetworkCall() async throws {
try await Task.sleep(nanoseconds: 1_000_000)
}
}
Here’s what this does:
-
You’re already familar with
TripandTripPartfrom the previous lesson, which are models that represent a “trip” within a navigation app. For simplicity, these models are abbreviated here and only include a few properties. -
TripStoreholds ontoTripobjects that have already been loaded andTaskobjects that are currently in progress. It usestaskLookupandtripLookupinternally and exposes methods to add, remove and delete values on dictionaries. -
TripClientprovides a method to retrieve aTripfor a givenid. This is very similar to the previous lesson, wherein you used aTaskGroupto load aTripviaTripPartcomponents. The main difference is this method callsTripStoreto check whether aTriphas previously been created. If so, it returns the existingTrip. If not, it then checks if an existingTaskis exists onTripStoreand awaits for it to complete. Otherwise, if neither aTripnorTaskare found onTripStore, then it creates a newTaskand sets it onTripStore.
Because Swift doesn’t provide a way to reference a TaskGroup outside the creation closure (i.e. withThrowingTaskGroup here), you can’t return TaskGroup from this method directly. Instead, you wrap this in another Task and return it.
-
For debugging purposes, you modify the passed-in
idto append a random String onto it. You’ll see why soon. -
Instead of making network calls to a real service, you cheat by creating
directionsanddurationlocally viagetDirectionsandgetDuration. To simulate a brief network delay, you callTask.sleep.
This code looks like it might work, but it actually has a data race! Can you tell where and why?
If you called getTrip with the same id in quick success, you’ll likely create multiple different Trip and Task objects! This is because TripStore doesn’t prevent different tasks from writing and reading to taskLookup and tripLookup simultaneously. This means taskLookup and tripLookup are shared mutable state.
Want to try it out yourself? Copy-and-paste the above code into a Playground in Xcode and then add this at the very bottom:
let client = TripClient()
for _ in 0...1000 {
Task {
let trip = try await client.getTrip(for: "id1")
print(trip)
}
}
This simulates calling getTrip a thousand times in quick succession. Run the Playround, and you’ll see the id is sometimes different. For example, my run looked like this:
Trip(id: "id1-638", directions: ...)
Trip(id: "id1-602", directions: ...)
Trip(id: "id1-638", directions: ...)
Trip(id: "id1-638", directions: ...)
Trip(id: "id1-716", directions: ...)
Trip(id: "id1-638", directions: ...)
This definitely shows there’s a race condition here!
How can you fix this? A great answer comes in the form of Actors. Actors are a concept in Swift that allows you to perform Data Isolation. A technique to ensure access to an object’s Shared Mutable State is given to one source at a time to perform its read or write.
Other tasks accessing the Shared Mutable State must wait until the first task has completed its work. A good analogy is to think of the Shared Mutable State as being locked when you’re accessing it and unlocked when you’re no longer accessing it. This locking/unlocking of Shared Mutable State, one source at a time, is known as Mutual Exclusion.
Update TripStore to make it an actor instead of a class, like this (ignore the resulting errors for now):
actor TripStore {
typealias TripTask = Task<Trip, Error>
// ...
The main change is that you change the type from a Class to an Actor. An actor is a reference type, similar to a Class. The difference is that actors enforce the values of TripIdStore to be isolated and can only be accessed by one task at a time, ensuring thread safety and preventing data races.
Many places in the code will now have an error that “Expression is ‘async’ but is not marked with ‘await’.” Swift automatically denotes all methods belonging to an actor as async. Consequently, you’ll need to add await to all places with this error inside getTrip. Ultimately, the resulting method should look like this:
func getTrip(for id: String) async throws -> Trip {
if let trip = await tripStore.trip(for: id) {
return trip
}
if let task = await tripStore.task(for: id) {
return try await task.value
}
await tripStore.setTask(Task {
return try await withThrowingTaskGroup(of: TripPart.self, returning: Trip.self) { taskGroup in
var directions: [String]!
var duration: Int!
taskGroup.addTask { [unowned self] in
return try await getDirections(for: id)
}
taskGroup.addTask { [unowned self] in
return try await getDuration(for: id)
}
for try await tripPart in taskGroup {
switch tripPart {
case .directions(let retrievedDirections):
directions = retrievedDirections
case .duration(let retrievedDuration):
duration = retrievedDuration
}
}
let newId = id + "-" + String(Int.random(in: 0...1000))
let trip = Trip(id: newId, directions: directions, duration: duration)
await tripStore.setTrip(trip, for: id)
await tripStore.removeTask(for: id)
return trip
}
}, for: id)
return try await tripStore.task(for: id)!.value
}
Non-Isolation
Sometimes, you may want to add code to an actor that’s Non Isolated if it doesn’t interact with the actor’s isolated state. You can do that using the non-isolated keyword. Take a look at adding a non-isolated function to TripIdStore:
actor TripStore {
typealias TripTask = Task<Trip, Error>
private var taskLookup = [String: TripTask]()
private var tripLookup = [String: Trip]()
private var pastTrips = [String: Trip]()
func task(for id: String) -> TripTask? {
return taskLookup[id]
}
func setTask(_ task: TripTask, for id: String) {
taskLookup[id] = task
}
func trip(for id: String) -> Trip? {
return tripLookup[id]
}
func setTrip(_ trip: Trip, for id: String) {
tripLookup[id] = trip
}
nonisolated func pastTrip(for id:String) -> Trip? {
return pastTrips[id]
}
}
Because pastTrip() has the non-isolated keyword added it’s not part of the Data Isolation Domain and so can access the state of TripStore.
Using non isolated functions within actors gives you the ability to opt in to the mutual exclusivity of actors. It is good practice to do this when you are sure the code is not going to interfere with other tasks or threads.
Global Actors
You don’t always have to apply actors at the type level. You can also use annotations to apply Data Isolation across a whole group of objects, known as Global Actors. One useful annotation is the @MainActor annotation. This makes any object you annotate with @MainActor safe to use on the main thread. Useful if you’re expecting to use an object as part of updating the UI.
Since using the main thread is a common operation, Apple has applied this annotation across many components in SwiftUI. Meaning most UI components you use from SwiftUI are automatically safe to use on the main thread.
You can use this annotation simply by adding it before the
@MainActor class TripsViewModel {
// All the properties and functions in this class are safe for the main thread to access.
}
You can also be more specific and add it to properties or functions.
class TripsViewModel {
@MainActor var trips: [Trip] // This property is safe to access on the main thread
@MainActor func addTrip() {
...
// This function is also safe to work on the main thread
}
}
Now that you know how actors work, you’ll make use of them in the next section.