Actors
Actors
Section titled “Actors”Use this when:
- You need to protect class-based mutable state from concurrent access.
- You are choosing between
actor,@MainActor,nonisolated, orMutex. - You are resolving protocol conformance issues on actor-isolated types.
Skip this file if:
- You mainly need to make a value safe to transfer across boundaries. Use
sendable.md. - You are debugging execution threads or suspension behavior. Use
threading.md.
Jump to:
- Actor Isolation
- Global Actors / @MainActor
- Isolated vs Nonisolated
- Actor Reentrancy
- Isolated Deinit / Isolated Conformances (Swift 6.2+)
#isolationMacro- Mutex: Alternative to Actors
- Decision Tree
What is an Actor?
Section titled “What is an Actor?”Actors protect mutable state by ensuring only one task accesses it at a time. They’re reference types with automatic synchronization.
actor Counter { var value = 0
func increment() { value += 1 }}Key guarantee: Only one task can access mutable state at a time (serialized access).
Actor Isolation
Section titled “Actor Isolation”Enforced by compiler
Section titled “Enforced by compiler”actor BankAccount { var balance: Int = 0
func deposit(_ amount: Int) { balance += amount }}
let account = BankAccount()account.balance += 1 // ❌ Error: can't mutate from outsideawait account.deposit(1) // ✅ Must use actor's methodsReading properties
Section titled “Reading properties”let account = BankAccount()await account.deposit(100)print(await account.balance) // Must await reads tooAlways use await when accessing actor properties/methods—you don’t know if another task is inside.
Actors vs Classes
Section titled “Actors vs Classes”Similarities
Section titled “Similarities”- Reference types (copies share same instance)
- Can have properties, methods, initializers
- Can conform to protocols
Differences
Section titled “Differences”- No inheritance (except
NSObjectfor Objective-C interop) - Automatic isolation (no manual locks needed)
- Implicit Sendable conformance
// ❌ Can't inherit from actorsactor Base {}actor Child: Base {} // Error
// ✅ NSObject exceptionactor Example: NSObject {} // OK for Objective-CGlobal Actors
Section titled “Global Actors”Shared isolation domain across types, functions, and properties.
@MainActor
Section titled “@MainActor”Ensures execution on main thread:
@MainActorfinal class ViewModel { var items: [Item] = []}
@MainActorfunc updateUI() { // Always runs on main thread}
@MainActorvar title: String = ""Custom global actors
Section titled “Custom global actors”@globalActoractor ImageProcessing { static let shared = ImageProcessing() private init() {} // Prevent duplicate instances}
@ImageProcessingfinal class ImageCache { var images: [URL: Data] = [:]}
@ImageProcessingfunc applyFilter(_ image: UIImage) -> UIImage { // All image processing serialized}Use private init to prevent creating multiple executors.
@MainActor Best Practices
Section titled “@MainActor Best Practices”When to use
Section titled “When to use”UI-related code that must run on main thread:
@MainActorfinal class ContentViewModel: ObservableObject { @Published var items: [Item] = []}Replacing DispatchQueue.main
Section titled “Replacing DispatchQueue.main”// Old wayDispatchQueue.main.async { // Update UI}
// Modern wayawait MainActor.run { // Update UI}
// Better: Use attribute@MainActorfunc updateUI() { // Automatically on main thread}MainActor.assumeIsolated
Section titled “MainActor.assumeIsolated”Use sparingly - assumes you’re on main thread, crashes if not:
func methodB() { assert(Thread.isMainThread) // Validate assumption
MainActor.assumeIsolated { someMainActorMethod() }}Prefer: Explicit @MainActor or await MainActor.run over assumeIsolated.
Isolated vs Nonisolated
Section titled “Isolated vs Nonisolated”Default: Isolated
Section titled “Default: Isolated”Actor methods are isolated by default:
actor BankAccount { var balance: Double
// Implicitly isolated func deposit(_ amount: Double) { balance += amount }}Isolated parameters
Section titled “Isolated parameters”Reduce suspension points by inheriting caller’s isolation:
struct Charger { static func charge( amount: Double, from account: isolated BankAccount ) async throws -> Double { // No await needed - we're isolated to account try account.withdraw(amount: amount) return account.balance }}Isolated closures
Section titled “Isolated closures”actor Database { func transaction<T>( _ operation: @Sendable (_ db: isolated Database) throws -> T ) throws -> T { beginTransaction() let result = try operation(self) commitTransaction() return result }}
// Usage: Multiple operations, one awaittry await database.transaction { db in db.insert(item1) db.insert(item2) db.insert(item3)}Generic isolated extension
Section titled “Generic isolated extension”extension Actor { func performInIsolation<T: Sendable>( _ block: @Sendable (_ actor: isolated Self) throws -> T ) async rethrows -> T { try block(self) }}
// Usagetry await bankAccount.performInIsolation { account in try account.withdraw(amount: 20) print("Balance: \(account.balance)")}Nonisolated
Section titled “Nonisolated”Opt out of isolation for immutable data:
actor BankAccount { let accountHolder: String
nonisolated var details: String { "Account: \(accountHolder)" }}
// No await neededprint(account.details)Protocol conformance
Section titled “Protocol conformance”extension BankAccount: CustomStringConvertible { nonisolated var description: String { "Account: \(accountHolder)" }}Isolated Deinit (Swift 6.2+)
Section titled “Isolated Deinit (Swift 6.2+)”Clean up actor state on deallocation:
actor FileDownloader { var downloadTask: Task<Void, Error>?
isolated deinit { downloadTask?.cancel() // Can call isolated methods }}Requires: iOS 18.4+, macOS 15.4+
Global Actor Isolated Conformance (Swift 6.2+)
Section titled “Global Actor Isolated Conformance (Swift 6.2+)”Protocol conformance respecting actor isolation:
@MainActorfinal class PersonViewModel { let id: UUID var name: String}
extension PersonViewModel: @MainActor Equatable { static func == (lhs: PersonViewModel, rhs: PersonViewModel) -> Bool { lhs.id == rhs.id && lhs.name == rhs.name }}Enable: InferIsolatedConformances upcoming feature.
Actor Reentrancy
Section titled “Actor Reentrancy”Critical: State can change between suspension points.
actor BankAccount { var balance: Double
func deposit(amount: Double) async { balance += amount
// ⚠️ Actor unlocked during await await logActivity("Deposited \(amount)")
// ⚠️ Balance may have changed! print("Balance: \(balance)") }}Problem
Section titled “Problem”async let _ = account.deposit(50)async let _ = account.deposit(50)async let _ = account.deposit(50)
// May print same balance three times:// Balance: 150// Balance: 150// Balance: 150Solution
Section titled “Solution”Complete actor work before suspending:
func deposit(amount: Double) async { balance += amount print("Balance: \(balance)") // Before suspension
await logActivity("Deposited \(amount)")}Rule: Don’t assume state is unchanged after await.
#isolation Macro
Section titled “#isolation Macro”Inherit caller’s isolation for generic code:
extension Collection where Element: Sendable { func sequentialMap<Result: Sendable>( isolation: isolated (any Actor)? = #isolation, transform: (Element) async -> Result ) async -> [Result] { var results: [Result] = [] for element in self { results.append(await transform(element)) } return results }}
// Usage from @MainActor contextTask { @MainActor in let names = ["Alice", "Bob"] let results = await names.sequentialMap { name in await process(name) // Inherits @MainActor }}Benefits: Avoids unnecessary suspensions, allows non-Sendable data.
Task Closures and Isolation Inheritance
Section titled “Task Closures and Isolation Inheritance”When spawning unstructured Task closures that need to work with non-Sendable types, you must capture the isolation parameter to inherit the caller’s isolation context.
Problem: Task closures are @Sendable, which prevents capturing non-Sendable types:
func process(delegate: NonSendableDelegate) { Task { delegate.doWork() // ❌ Error: capturing non-Sendable type }}Solution: Use #isolation parameter and capture it inside the Task:
func process( delegate: NonSendableDelegate, isolation: isolated (any Actor)? = #isolation) { Task { _ = isolation // Forces capture, Task inherits caller's isolation delegate.doWork() // ✅ Safe - running on caller's actor }}Why _ = isolation is required: Per SE-0420, Task closures only inherit isolation when “a non-optional binding of an isolated parameter is captured by the closure.” The _ = isolation statement forces this capture. The capture list syntax [isolation] should work but currently does not.
When to use this pattern:
- Spawning
Tasks that work withnon-Sendabledelegate objects - Fire-and-forget async work that needs access to caller’s state
- Bridging callback-based APIs to async streams while keeping delegates alive
Note: This pattern keeps the non-Sendable value alive and accessible within the Task. The Task runs on the caller’s isolation domain, so no cross-isolation “sending” occurs.
Custom Actor Executors
Section titled “Custom Actor Executors”Advanced: Control how actor schedules work.
Serial executor
Section titled “Serial executor”final class DispatchQueueExecutor: SerialExecutor { private let queue: DispatchQueue
init(queue: DispatchQueue) { self.queue = queue }
func enqueue(_ job: consuming ExecutorJob) { let unownedJob = UnownedJob(job) let executor = asUnownedSerialExecutor()
queue.async { unownedJob.runSynchronously(on: executor) } }}
actor LoggingActor { private let executor: DispatchQueueExecutor
nonisolated var unownedExecutor: UnownedSerialExecutor { executor.asUnownedSerialExecutor() }
init(queue: DispatchQueue) { executor = DispatchQueueExecutor(queue: queue) }}When to use
Section titled “When to use”- Integration with legacy DispatchQueue-based code
- Specific thread requirements (e.g., C++ interop)
- Custom scheduling logic
Default executor is usually sufficient.
Mutex: Alternative to Actors
Section titled “Mutex: Alternative to Actors”Synchronous locking without async/await overhead (iOS 18+, macOS 15+).
Basic usage
Section titled “Basic usage”import Synchronization
final class Counter { private let count = Mutex<Int>(0)
var currentCount: Int { count.withLock { $0 } }
func increment() { count.withLock { $0 += 1 } }}Sendable access to non-Sendable types
Section titled “Sendable access to non-Sendable types”final class TouchesCapturer: Sendable { let path = Mutex<NSBezierPath>(NSBezierPath())
func storeTouch(_ point: NSPoint) { path.withLock { path in path.move(to: point) } }}Error handling
Section titled “Error handling”func decrement() throws { try count.withLock { count in guard count > 0 else { throw Error.reachedZero } count -= 1 }}Mutex vs Actor
Section titled “Mutex vs Actor”| Feature | Mutex | Actor |
|---|---|---|
| Synchronous | ✅ | ❌ (requires await) |
| Async support | ❌ | ✅ |
| Thread blocking | ✅ | ❌ (cooperative) |
| Fine-grained locking | ✅ | ❌ (whole actor) |
| Legacy code integration | ✅ | ❌ |
Use Mutex when:
- Need synchronous access
- Working with legacy non-async APIs
- Fine-grained locking required
- Low contention, short critical sections
Use Actor when:
- Can adopt async/await
- Need logical isolation
- Working in async context
Common Patterns
Section titled “Common Patterns”View model with @MainActor
Section titled “View model with @MainActor”@MainActorfinal class ContentViewModel: ObservableObject { @Published var items: [Item] = []
func loadItems() async { items = try await api.fetchItems() }}Background processing with custom actor
Section titled “Background processing with custom actor”@ImageProcessingfinal class ImageProcessor { func process(_ images: [UIImage]) async -> [UIImage] { images.map { applyFilters($0) } }}Mixed isolation
Section titled “Mixed isolation”actor DataStore { private var items: [Item] = []
func add(_ item: Item) { items.append(item) }
nonisolated func itemCount() -> Int { // ❌ Can't access items return 0 }}Transaction pattern
Section titled “Transaction pattern”actor Database { func transaction<T>( _ operation: @Sendable (_ db: isolated Database) throws -> T ) throws -> T { beginTransaction() defer { commitTransaction() } return try operation(self) }}Best Practices
Section titled “Best Practices”- Prefer actors over manual locks for async code
- Use @MainActor for UI - all view models, UI updates
- Minimize work in actors - keep critical sections short
- Watch for reentrancy - don’t assume state unchanged after await
- Use nonisolated sparingly - only for truly immutable data
- Avoid assumeIsolated - prefer explicit isolation
- Custom executors are rare - default is usually best
- Consider Mutex for sync code - when async overhead not needed
- Complete actor work before suspending - prevent reentrancy bugs
- Use isolated parameters - reduce suspension points
Decision Tree
Section titled “Decision Tree”Need thread-safe mutable state?├─ Async context?│ ├─ Single instance? → Actor│ ├─ Global/shared? → Global Actor (@MainActor, custom)│ └─ UI-related? → @MainActor│└─ Synchronous context? ├─ Can refactor to async? → Actor ├─ Legacy code integration? → Mutex └─ Fine-grained locking? → Mutex