Instruction

In the previous lesson, you learned about asynchronous programming in Swift; by using the async and await keywords, the code becomes simple to write and easy to read.

Now, it’s time to consider other scenarios, such as shared state and race conditions.

Shared State

Shared state adheres to the SOLID Single Responsibility Principle. This principle explains that classes should have only one job.

Building on this principle, you can figure out that many classes doing the same job is redundant, so a single source of truth helps focus on what a class handles — it helps engineers know where specific functionality resides. The same is true for shared state.

Race Conditions

Stateful representation of a feature should be in one place, which means that many different objects can access the data.

So, what happens when you have many accessors that try to get or modify shared data at the same time? That’s when race conditions can occur. Race conditions can cause corruption of data, as well as weird UI behavior and functionality.

Swift has many ways to handle race conditions:

  • Dispatch Queues: These are used to manually ensure thread safety by dispatching tasks to specific queues like DispatchQueue.main.

  • Locks and Semaphores: Used for low-level synchronization primitives to prevent multiple threads from accessing shared resources simultaneously. While effective, these approaches are error-prone and could lead to deadlocks.

NOTE: A deadlock occurs when multiple threads are waiting on a specific lock/semaphore to release but are locked in way where a circular dependency occurs. For thread 1 to resume, it must wait for thread 2 to release the lock, but thread 2 is also waiting for thread 1 to release the lock. This can cause apps to freeze and become unresponsive.

  • Value Types (Structs): Immutable value types are often used to avoid concurrency issues. This comes at the cost of duplicating data.

Actors

Enter: Actors! Swift actors address many of the common race condition challenges by:

  • Serializing access to shared mutable state automatically.
  • Being re-entrant by default, allowing them to process other tasks while waiting on an asynchronous call.
  • Providing a structured, high-level abstraction for thread-safe programming.
  • Integrating seamlessly with Swift’s concurrency model, including async/await.

What Are Actors?

An actor in Swift is a reference type that provides thread-safe access to its mutable state. Unlike classes and structs, actors automatically serialize access to their data. This prevents simultaneous modifications from multiple threads.

Here’s a breakdown of the features that actors, structs and classes all have:

Feature Structs Classes Actors Concurrency Handling Not built-in Not built-in Built-in serialization Reference vs. Value Value type Reference type Reference type Thread Safety Manual (Immutable data) Manual (Synchronization) Automatic (Serialized)

Understanding the actor Keyword

To create a Swift actor, use the actor keyword:

actor BankAccount {
    private var balance: Double = 0.0

    func deposit(amount: Double) {
        balance += amount
    }

    func getBalance() -> Double {
        balance
    }
}

let account = BankAccount()

Task {
    await account.deposit(amount: 100.0)
    let balance = await account.getBalance()
    print("Current balance: \(balance)")
}

Note that actor is used in the same way as the typical class or struct keyword that you would normally use.

In the example above, BankAccount is an implementation of an actor. This means that BankAccount is a thread-safe reference type. When the Task at the bottom of the example waits for deposit() and getBalance(), it does so using Swift concurrency.

And that’s not all you can do: While you’ve now created an actor, you may want to account for this change in your presentation layer as well. How can you ensure that results from an actor return on the main thread? Well, you can use DispatchQueue.main, like in lesson 1, but there’s also another actor-based approach you can do with @MainActor.

Understanding @MainActor

The Swift Concurrency framework provides a special kind of actor, called MainActor. MainActor is global, and only one instance of it exists. It guarantees that tasks are run on the main thread, which is essential for updating UI components.

Introducing the MainActor in your code is easy using the @MainActor keyword.

Take a look at the following code:

@MainActor
class UserInterface {
    func updateLabel(with text: String) {
        print("Updating label with: \(text)")
    }
}

let ui = UserInterface()

Task {
    await ui.updateLabel(with: "Hello, World!")
}

In this example, @MainActor ensures that the updateLabel method runs on the main thread, avoiding potential UI issues. You can also apply this to Tasks directly as well:

Task { @MainActor in
    await someAsyncTask()
}

At face value, this is concise and easy to read — but you might be wondering what’s actually happening here.

Here’s a helpful breakdown: By marking the Task’s body as a @MainActor, it will run on the main thread. Any async methods that are called from within the task body will run on background threads. To ensure that the someAsyncTask() function runs on the main thread, its definition must also be marked with @MainActor.

Trying out @MainActor

Let’s put @MainActor to use and see how it interacts with async functions.

Start by opening a new Xcode playground to see how thread switching occurs.

Open Xcode and go to File > New > Playground. Select a Blank playground type and click Next. Give it the name MainActorPlayground and click Create.

In the Playground window, copy and paste the following code:

import Foundation

// 1
actor AsyncActor {
    // 2
    func doSomethingAsync() async -> String {
        print("doSomethingAsync() is running on", Thread.current) // ignore the error for test purposes
        return "good bye"
    }
}

// 3
class MainClass {
    // 4
    private var asyncActor = AsyncActor()

    func main() {
        // 5
        Task { @MainActor in
            // 6
            print("main is running on", Thread.current) // ignore the error for test purposes
            let _ = await asyncActor.doSomethingAsync()
        }
    }
}

// 7
let mainClass = MainClass()
mainClass.main()

Here’s the breakdown:

  1. You create an AsyncActor actor, which conforms to the built-in thread-safe features Swift provides.
  2. doSomethingAsync() is a method marked as async. Inside the body of the method, you print out what thread you’re on.
  3. The MainClass is your class where you’ll call the async method.
  4. Create an instance of AsyncActor to use in the following method.
  5. Inside main(), you create a Task that runs its body using the @MainActor attribute.
  6. In the body of the Task, you print the current thread and then fire off a call to the async method.
  7. Create an instance of MainClass and run the main method.

Next, run the code in the playground by clicking on the blue play button next on the last line of code. Ignore the errors about Thread.current being used for async tasks — the code will still run, since this is a Playground.

Once the code runs, click on the console icon at the bottom left of Playground screen to open up the console window:

Playground Console Output Icon
Playground Console Output Icon

The window should now look something like this:

Main Actor Playground
Main Actor Playground

In the console window, you’ll notice something similar to this:

main is running on <_NSMainThread: 0x60000170c000>{number = 1, name = main}
doSomethingAsync() is running on <NSThread: 0x600001707540>{number = 3, name = (null)}

The output from the print statements in the code show how Swift actors move between threads. Code inside @MainActor scopes run on NSMainThread, async functions run on background threads.

Now it’s time to update your weather app to use actors!

Update the Weather App

Launch the WeatherSampleApp project in Xcode and open the WeatherRepositoryImpl.swift file. Find the following line:

class WeatherRepositoryImpl: WeatherRepository {

Now replace it with the following code:

actor WeatherRepositoryImpl: WeatherRepository {

This is a straightforward change. You replaced the class keyword with actor. Now, your repository implementation will instantiate in a thread-safe manner!

Next, open up HomeViewModel.swift and replace the class with:

class HomeViewModel: ObservableObject {
  @Published var state: HomeState = .empty

  private let weatherRepo: WeatherRepository

  init(weatherRepo: WeatherRepository = WeatherRepositoryImpl()) {
    self.weatherRepo = weatherRepo
  }

  // 1
  func getWeather(query: String) {
    state = .loading

    // 2
    Task { @MainActor in
      do {
        let weatherData = try await weatherRepo.fetchWeather(for: query)
        // 3
        state = .ready(weatherData)
      } catch (_) {
        // 4
        state = .error
      }
    }
  }
}

Note the following changes:

  1. updateState() has been removed. The following steps explain further, but you no longer need this function.
  2. Inside the Task, its body is now scoped with the @MainActor attribute. All code in this Task will now run on the main actor.
  3. Since the code is running on the main actor, we can directly update state without the need for calling updateState() and encapsulating it behind a DispatchQueue.main.async block.
  4. For the error case, we can also directly update state.

Notice how the code has been simplified using @MainActor. Great job!

Run the app and see that it works just as before.

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