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:
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:
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:
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:
- Only code within the actor can access its mutable state directly
- External code must use
await
to access the actor's properties and methods - The actor guarantees that only one task at a time can execute its code
Let's see this in action:
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:
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
:
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:
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:
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.
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:
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:
- Actors provide data isolation that prevents data races
- Actor methods are implicitly asynchronous when accessed from outside the actor
- You must use
await
when calling an actor's methods from outside - Actors support reentrancy, allowing them to handle multiple tasks efficiently
- The
nonisolated
keyword allows defining methods that don't need actor isolation - 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
- Swift Documentation on Actors
- WWDC21: Protect mutable state with Swift actors
- Swift Evolution Proposal: SE-0306
Exercises
- Create an actor that represents a to-do list manager with methods to add tasks, mark tasks as complete, and list pending tasks.
- Implement a simple cache actor that stores values for a limited time before expiring them.
- Build a counter actor that tracks how many times different events occur in your application.
- Extend the chat room example to support multiple chat rooms and private messaging.
- 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! :)