A Guide to Swift Actors

A Guide to Swift Actors

A Private Workshop

Before actors, managing shared data in concurrent code was a headache. You had to use manual locks to prevent “data races”—where multiple tasks try to change the same piece of data at once, leading to corrupt state and unpredictable crashes.

Actors solve this problem by providing a new mental model. Think of an actor as a dedicated workshop with its own private tools and projects (its stored properties).

  • Exclusive Access: Only one programmer (a task) can be inside the workshop at a time. This guarantees that the actor’s internal state is never modified by multiple tasks simultaneously.

  • Serialized Access: Programmers line up outside the workshop door. When one finishes, the next one in line gets to enter. Swift manages this queue for you automatically, like handing out tickets at a deli counter. You get all the safety without the manual locking code.

Actor Isolation: Crossing the Boundary with await

Because an actor protects its internal state, you can’t just barge in from the outside. Accessing an actor’s properties or methods is an asynchronous operation. You have to send a request and wait for your turn to get access. This “waiting” is what the await keyword signifies.

The await keyword marks a potential suspension point. Your code might pause here while it waits for the actor to be free. You aren’t necessarily calling a slow function—you’re waiting to safely cross the actor’s isolation boundary.

Code Example: The Counter Actor

This simple actor safely manages a number. Outside code must await to interact with its methods.

import Foundation

// The actor is like a protected workshop for the 'value' property.
actor Counter {
  private var value = 0

  // To change the value, a task must enter the actor.
  func increment() {
    value += 1
  }

  // To read the value, a task must also enter.
  func get() -> Int {
    return value
  }
}

// Create an instance of our actor.
let counter = Counter()

// Use a Task to create an asynchronous context to call the actor.
Task {
  // We send a request to run increment() and 'await' our turn.
  await counter.increment()

  // We send another request to run get() and 'await' access again.
  let current = await counter.get()
  print("Counter value is now: \(current)") // Prints: Counter value is now: 1
}

The Express Lane: nonisolated

What if a property or method doesn’t actually touch the actor’s private state? It would be inefficient to make callers wait in line for no reason.

For these cases, use the nonisolated keyword. A nonisolated member can be accessed synchronously from anywhere without await because it doesn’t pose a risk of a data race. It’s like reading a sign on the workshop door—you don’t need to go inside.

Code Example: Getting Static Information

actor ImageStats {
  private var bytesDownloaded = 0

  // This computed property doesn't touch 'bytesDownloaded'.
  // It's safe to call synchronously from anywhere.
  nonisolated var name: String {
    "ImageStats"
  }

  // This method *does* modify the actor's state, so it remains isolated.
  // Callers will need to 'await' to use it.
  func record(_ n: Int) {
    bytesDownloaded += n
  }
}

The Big Surprise: Reentrancy

Here’s the trickiest part of actors. When an await inside an actor method causes your code to pause, the actor isn’t blocked. It’s free to process other work from the queue. This is called reentrancy. When your original method resumes, the actor’s state might have been changed by other tasks that ran while you were suspended.

The Bug Story: Imagine you’re writing code to buy a concert ticket.

  1. You check if availableTickets > 0. It is, so you proceed.
  2. You await a network call to process the payment. (Suspension Point!)
  3. While you’re suspended, another task comes in, sees availableTickets > 0, and buys the last ticket. availableTickets is now 0.
  4. Your network call finishes and your method resumes. It still thinks the ticket is available (based on the check from step 1) and decrements the count to -1. You just oversold the concert!
actor TicketOffice {
  private var available = 1

  func buy() async throws {
    // 1. Check the state.
    guard available > 0 else {
      throw NSError(domain: "soldout", code: 0, userInfo: [NSLocalizedDescriptionKey: "Sold out!"])
    }

    // 2. Suspend the task. While paused, another task can run.
    try await Task.sleep(for: .seconds(1)) // Simulate a network call

    // 4. By the time we resume, 'available' might have been changed by another task!
    // This assumption is now stale and dangerous.
    available -= 1
    print("Ticket purchased! Tickets remaining: \(available)")
  }
}

The Safe Pattern: Commit Before You Suspend

To avoid reentrancy bugs, follow this critical rule: Modify the actor’s state to reflect your action before the first await.

If the later, asynchronous part of your function fails, you simply compensate for the change you already made (i.e., undo or roll it back).

Code Example: The Fixed TicketOffice

This version is safe. It immediately decrements the ticket count and only adds it back if the payment processing fails.

actor SafeTicketOffice {
  private var available = 1

  func buy() async throws {
    // 1. Check state and immediately commit the change.
    guard available > 0 else {
      throw NSError(domain: "soldout", code: 0, userInfo: [NSLocalizedDescriptionKey: "Sold out!"])
    }
    // Commit the state change BEFORE any suspension points.
    available -= 1

    do {
      // 2. Suspend for the network call. The state is already correct
      // even if another task runs.
      try await Task.sleep(for: .seconds(1)) // Simulate processing payment
      print("Payment successful! Ticket confirmed. Remaining: \(available)")
    } catch {
      // 3. Compensate on failure: if payment fails, add the ticket back.
      available += 1
      print("Payment failed. Ticket purchase rolled back. Remaining: \(available)")
      throw error // Re-throw the error
    }
  }
}

Who Runs Where: @MainActor and Global Actors

Sometimes, you need to ensure code runs on a specific thread or that different types share the same actor for protection.

  • @MainActor for UI: All UI updates in Apple frameworks must happen on the main thread. Marking a class, function, or property with @MainActor guarantees that its code will run on the main thread’s actor, preventing UI-related crashes. It’s your go-to tool for UI code.

  • Custom Global Actors for Shared Resources: Imagine you need to coordinate access to a single resource like a database or the filesystem from many different parts of your app. You can define a custom @globalActor. This creates a single, app-wide actor instance that any type can use for serialization.

Code Example: A Global Actor for Disk Access

// 1. Define a global actor.
@globalActor
actor DiskActor {
  static let shared = DiskActor()
}

// 2. Use the attribute to make a function run on that actor.
// Any calls to this function are now safely serialized, preventing
// you from trying to write to the same file from two places at once.
@DiskActor
func saveToDisk(data: String) {
  // Safely perform file I/O here...
  print("Saving data on the DiskActor's executor.")
}

Summary Checklist

  • Model actors as private workshops to protect data.
  • Use await to wait your turn to safely enter an actor.
  • Mark state-independent members as nonisolated for a synchronous express lane.
  • Treat every await inside an actor as a potential state change. The world can change while you’re paused.
  • Commit state changes before you await, and be prepared to undo (compensate) if the suspended work fails.
  • Use @MainActor for all UI updates. No exceptions.
See forum comments
Download course materials from Github
Previous: Introduction Next: Swift Actors Demo from Network to UI