Skip to main content

Swift Actors

Introduction

Swift Actors are a powerful feature introduced in Swift 5.5 as part of the Swift Concurrency model. They provide a safe way to work with mutable state in concurrent environments by preventing data races and synchronization issues that are common in traditional concurrent programming.

Think of an actor as a self-contained entity that owns and protects its data. Other parts of your code can interact with an actor, but only in a controlled manner that prevents multiple concurrent accesses to the actor's mutable state.

In this tutorial, we'll explore what actors are, why they're useful, and how to use them effectively in your Swift applications.

Why Do We Need Actors?

Before diving into actors, let's understand the problem they solve:

The Problem of Data Races

When multiple threads access and modify the same data simultaneously, they can create data races, which lead to unpredictable behavior and bugs that are difficult to reproduce and fix.

Consider this simple example of a counter:

swift
class Counter {
var value = 0

func increment() {
value += 1
}
}

// In concurrent code:
let counter = Counter()

// These could run on different threads
Task { counter.increment() }
Task { counter.increment() }

This seemingly innocent code can produce incorrect results if both tasks try to modify value at the same time, leading to a data race.

Introducing Swift Actors

Actors solve this problem by ensuring that all access to their mutable state happens in a synchronized manner. When you define a type as an actor instead of a class or struct, Swift ensures that its mutable state can only be accessed in a controlled way.

Basic Actor Syntax

Here's how to define an actor:

swift
actor Counter {
var value = 0

func increment() {
value += 1
}

func getValue() -> Int {
return value
}
}

How to Use an Actor

Using an actor requires the await keyword for most operations, as actor methods are implicitly asynchronous:

swift
let counter = Counter()

// Must use await when accessing actor methods from outside
Task {
await counter.increment()
let value = await counter.getValue()
print("Counter value: \(value)") // Output: Counter value: 1
}

Actor Isolation

The core feature of actors is actor isolation. This means that the mutable state inside an actor is isolated from external direct access.

Rules of Actor Isolation:

  1. Only code within the actor can access its mutable state directly
  2. External code must use await to access the actor's properties and methods
  3. The actor guarantees that only one task at a time can execute its code

Let's see this in action:

swift
actor BankAccount {
private var balance: Double
private(set) var accountNumber: String

init(initialBalance: Double, accountNumber: String) {
self.balance = initialBalance
self.accountNumber = accountNumber
}

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

func withdraw(amount: Double) throws {
guard balance >= amount else {
throw NSError(domain: "Insufficient funds", code: 1, userInfo: nil)
}
balance -= amount
}

func checkBalance() -> Double {
return balance
}
}

Using this bank account in concurrent code:

swift
let account = BankAccount(initialBalance: 1000, accountNumber: "1234-5678")

// Multiple tasks can safely interact with the account
Task {
await account.deposit(amount: 500)
print("New balance after deposit: \(await account.checkBalance())")
// Output: New balance after deposit: 1500
}

Task {
do {
try await account.withdraw(amount: 200)
print("New balance after withdrawal: \(await account.checkBalance())")
// Output: New balance after withdrawal: 1300
} catch {
print("Error: \(error)")
}
}

Non-isolated Code in Actors

Not everything inside an actor needs to be isolated. For example, code that doesn't access mutable state can be marked as nonisolated:

swift
actor MessageProcessor {
private var messages: [String] = []

func addMessage(_ message: String) {
messages.append(message)
}

func processMessages() -> [String] {
return messages.map { processMessage($0) }
}

// This method doesn't access actor state, so it can be non-isolated
nonisolated func processMessage(_ message: String) -> String {
return "Processed: \(message.uppercased())"
}
}

let processor = MessageProcessor()

// Non-isolated methods don't require await
let processed = processor.processMessage("hello")
print(processed) // Output: Processed: HELLO

// But isolated methods do require await
Task {
await processor.addMessage("hello")
await processor.addMessage("world")
let results = await processor.processMessages()
print(results) // Output: ["Processed: HELLO", "Processed: WORLD"]
}

Practical Example: Chat System

Let's build a more complex example to demonstrate actors in a real-world scenario - a simple chat system:

swift
actor ChatRoom {
private var messages: [Message] = []
private var members: [String] = []

struct Message {
let id: UUID
let sender: String
let content: String
let timestamp: Date
}

func join(username: String) -> Bool {
guard !members.contains(username) else {
return false
}

members.append(username)
let systemMessage = Message(
id: UUID(),
sender: "System",
content: "\(username) has joined the chat",
timestamp: Date()
)
messages.append(systemMessage)
return true
}

func leave(username: String) {
if let index = members.firstIndex(of: username) {
members.remove(at: index)
let systemMessage = Message(
id: UUID(),
sender: "System",
content: "\(username) has left the chat",
timestamp: Date()
)
messages.append(systemMessage)
}
}

func send(message content: String, from username: String) throws {
guard members.contains(username) else {
throw ChatError.notAMember
}

let message = Message(
id: UUID(),
sender: username,
content: content,
timestamp: Date()
)
messages.append(message)
}

func getMessages() -> [Message] {
return messages
}

enum ChatError: Error {
case notAMember
}
}

Now let's use our chat room:

swift
func simulateChat() async {
let chatRoom = ChatRoom()

// Alice joins and sends messages
await chatRoom.join(username: "Alice")
try? await chatRoom.send(message: "Hello everyone!", from: "Alice")

// Bob joins and sends messages
await chatRoom.join(username: "Bob")
try? await chatRoom.send(message: "Hi Alice!", from: "Bob")

// Display all messages
let messages = await chatRoom.getMessages()
for message in messages {
print("[\(message.timestamp.formatted(date: .omitted, time: .standard))] \(message.sender): \(message.content)")
}

// Output might look like:
// [10:15:30 AM] System: Alice has joined the chat
// [10:15:31 AM] Alice: Hello everyone!
// [10:15:32 AM] System: Bob has joined the chat
// [10:15:33 AM] Bob: Hi Alice!
}

// Run the simulation
Task {
await simulateChat()
}

This example demonstrates how an actor can safely manage shared mutable state (the messages and members arrays) while multiple users interact with it concurrently.

Actor Reentrancy

One important aspect of actors is that they support reentrancy. This means that when code inside an actor awaits something, the actor can process other requests until the awaited operation completes.

swift
actor DataProcessor {
var isProcessing = false
var processedCount = 0

func processData(id: Int) async {
print("Start processing #\(id)")
isProcessing = true

// Simulate network request - during this time, the actor can process other requests
await Task.sleep(for: .seconds(1))

processedCount += 1
print("Finished processing #\(id)")

if processedCount == 3 {
isProcessing = false
}
}
}

let processor = DataProcessor()

// These will run concurrently in an interleaved fashion
Task { await processor.processData(id: 1) }
Task { await processor.processData(id: 2) }
Task { await processor.processData(id: 3) }

// Output might be:
// Start processing #1
// Start processing #2
// Start processing #3
// Finished processing #1
// Finished processing #2
// Finished processing #3

Advanced Topics: Actor References and Sendable Types

When working with actors, you need to be careful about sharing data between them. Swift enforces the Sendable protocol to ensure that values passed between actors won't cause data races.

Sendable Protocol

Types that can be safely passed between concurrent contexts should conform to the Sendable protocol:

swift
struct UserProfile: Sendable {
let id: String
let name: String
let age: Int
}

actor UserManager {
private var profiles: [String: UserProfile] = [:]

func addProfile(_ profile: UserProfile) {
profiles[profile.id] = profile
}
}

let profile = UserProfile(id: "123", name: "John", age: 30)
let manager = UserManager()

Task {
// Safe because UserProfile is Sendable
await manager.addProfile(profile)
}

Value types like structs and enums with Sendable properties are automatically Sendable. Reference types like classes need explicit Sendable conformance and must guarantee thread safety.

Summary

Swift Actors provide an elegant solution to one of the most challenging problems in concurrent programming: safely managing shared mutable state. Here's what we've learned:

  1. Actors provide data isolation that prevents data races
  2. Actor methods are implicitly asynchronous when accessed from outside the actor
  3. You must use await when calling an actor's methods from outside
  4. Actors support reentrancy, allowing them to handle multiple tasks efficiently
  5. The nonisolated keyword allows defining methods that don't need actor isolation
  6. Sendable protocol ensures safe data transfer between concurrent contexts

By leveraging actors in your Swift code, you can write concurrent programs that are both safer and easier to reason about.

Additional Resources

Exercises

  1. Create an actor that represents a to-do list manager with methods to add tasks, mark tasks as complete, and list pending tasks.
  2. Implement a simple cache actor that stores values for a limited time before expiring them.
  3. Build a counter actor that tracks how many times different events occur in your application.
  4. Extend the chat room example to support multiple chat rooms and private messaging.
  5. Experiment with nonisolated methods to understand when they're appropriate to use in your actors.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)