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:

  1. You’re already familar with Trip and TripPart from 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.

  2. TripStore holds onto Trip objects that have already been loaded and Task objects that are currently in progress. It uses taskLookup and tripLookup internally and exposes methods to add, remove and delete values on dictionaries.

  3. TripClient provides a method to retrieve a Trip for a given id. This is very similar to the previous lesson, wherein you used a TaskGroup to load a Trip via TripPart components. The main difference is this method calls TripStore to check whether a Trip has previously been created. If so, it returns the existing Trip. If not, it then checks if an existing Task is exists on TripStore and awaits for it to complete. Otherwise, if neither a Trip nor Task are found on TripStore, then it creates a new Task and sets it on TripStore.

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.

  1. For debugging purposes, you modify the passed-in id to append a random String onto it. You’ll see why soon.

  2. Instead of making network calls to a real service, you cheat by creating directions and duration locally via getDirections and getDuration. To simulate a brief network delay, you call Task.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.

See forum comments
Download course materials from Github
Previous: Introduction Next: Demo