A Guide to Swift Actors

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

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.

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.

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.

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.

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.

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