Actor Isolation
What are Actors?
Actors are reference types that protect their mutable state by ensuring only one task can access them at a time. This eliminates data races at compile time:
actor BankAccount {
var balance: Decimal
let accountNumber: String
init(balance: Decimal, accountNumber: String) {
self.balance = balance
self.accountNumber = accountNumber
}
func deposit(_ amount: Decimal) {
balance += amount
}
func withdraw(_ amount: Decimal) throws {
guard balance >= amount else { throw BankError.insufficientFunds }
balance -= amount
}
}
Accessing Actor State
All property access and method calls go through the actor isolation boundary:
let account = BankAccount(balance: 1000, accountNumber: "123")
// Must use await for any access
await account.deposit(500)
let balance = await account.balance
// Sequential access within a task
await account.deposit(100)
await account.withdraw(50) // Guaranteed no data race
nonisolated
Mark methods as nonisolated when they do not access actor state:
actor DataStore {
private var cache: [String: Any] = [:]
// nonisolated: does not access cache
nonisolated func description() -> String {
"DataStore instance"
}
// isolated: accesses cache
func store(_ value: Any, forKey key: String) {
cache[key] = value
}
}
Actor Reentrancy
Actors are reentrant, meaning they can yield control during awaits. This requires careful state management:
actor FileManager {
func processFile(named name: String) async throws -> Data {
let data = try await download(name) // May yield here
// State may have changed by the time we resume
let processed = transform(data) // Must revalidate state
return processed
}
}
Never assume actor state is unchanged across await points.
Sendable Protocol
What is Sendable?
Sendable marks types that can be safely passed across concurrency domains. It ensures the type has no mutable shared state:
struct UserInfo: Sendable {
let name: String
let email: String
let preferences: UserPreferences
}
struct UserPreferences: Sendable {
let theme: String
let notifications: Bool
}
Sendable Requirements
A type is Sendable if:
- It is a value type (struct, enum) with Sendable properties
- It is a final class with no mutable state
- It is an actor (actors are implicitly Sendable)
- It is marked as Sendable and the compiler verifies safety
@Sendable Closures
Closures that cross isolation boundaries must be marked @Sendable:
func processInBackground(_ data: Data, completion: @Sendable @escaping (Result<ProcessedData, Error>) -> Void) {
Task.detached {
let result = await process(data)
completion(.success(result))
}
}
Non-Sendable Types
Classes with mutable state are not Sendable. Use actors instead:
// NOT Sendable - has mutable state
class NetworkCache {
var cache: [String: Data] = [:] // Mutable!
}
// Sendable - actor protects mutable state
actor NetworkCache {
var cache: [String: Data] = [:]
}
Sendable and Swift Evolution
Sendable is part of Swift push toward data-race safety. In future Swift versions, Sendable conformance will be required more strictly. Adopt it now to prepare.
Structured Concurrency
TaskGroup
TaskGroup manages a collection of child tasks:
func fetchAllUsers(ids: [UUID]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask { try await self.fetchUser(id: id) }
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
TaskGroup Benefits
- Automatic cancellation propagation
- Structured lifetime management
- Error handling for all child tasks
- Memory safety with automatic cleanup
withThrowingTaskGroup vs withTaskGroup
- withThrowingTaskGroup: child tasks can throw, group throws if any child throws
- withTaskGroup: child tasks cannot throw
// Non-throwing variant
await withTaskGroup(of: Void.self) { group in
for item in items {
group.addTask { await self.process(item) }
}
}
Unstructured Tasks
When you need more control over task lifecycle:
// Detached task: no parent-child relationship
let task = Task.detached {
await longRunningOperation()
}
// Cancel when needed
task.cancel()
// Check cancellation
if Task.isCancelled { return }
Task Priority
Task.detached(priority: .high) {
await criticalOperation()
}
// Current task priority
if let priority = Task.currentPriority {
print("Current priority: \(priority)")
}
Best Practices
- Use structured concurrency (TaskGroup) when possible
- Avoid unstructured tasks unless you need explicit lifecycle control
- Always handle errors in throwing task groups
- Propagate cancellation for responsive cancellation
- Set appropriate task priorities for user-facing work
Quiz
1. What do actors protect against?
2. What does the Sendable protocol guarantee?
3. What happens in a TaskGroup when a child task throws?
4. Why must you use await when accessing actor properties?
Flashcards
Question
What is actor isolation?
Click to reveal answer
Answer
The property that ensures only one task can access an actor mutable state at a time, preventing data races at compile time.
Question
What is the difference between withTaskGroup and withThrowingTaskGroup?
Click to reveal answer
Answer
withTaskGroup handles non-throwing tasks. withThrowingTaskGroup handles throwing tasks and propagates errors from any child task.
Question
Why must closures crossing isolation boundaries be @Sendable?
Click to reveal answer
Answer
To ensure the closure does not capture mutable state unsafely when moving between different concurrency domains.
Revision Notes
Key Takeaways
- 1. Actors prevent data races through compile-time isolation
- 2. Sendable ensures types are safe to pass across concurrency domains
- 3. TaskGroup provides structured concurrency with automatic cancellation
- 4. nonisolated optimizes actor methods that do not access state
- 5. Always revalidate actor state after await points due to reentrancy
Interview Tips
- • Explain how actors prevent data races
- • Describe the difference between actors and classes
- • Discuss when to use structured vs unstructured concurrency
- • Walk through handling errors in TaskGroup
Cheat Sheet
Advanced Concurrency Quick Reference
- Actor: protects mutable state from data races
- Sendable: safe to cross concurrency domains
- nonisolated: skip actor isolation for pure functions
- TaskGroup: structured child task management
- @Sendable: required for cross-isolation closures
- Actor reentrancy: always revalidate state after await