Skip to content

Actors

Use this when:

  • You need to protect class-based mutable state from concurrent access.
  • You are choosing between actor, @MainActor, nonisolated, or Mutex.
  • 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+)
  • #isolation Macro
  • Mutex: Alternative to Actors
  • Decision Tree

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 BankAccount {
var balance: Int = 0
func deposit(_ amount: Int) {
balance += amount
}
}
let account = BankAccount()
account.balance += 1 // ❌ Error: can't mutate from outside
await account.deposit(1) // ✅ Must use actor's methods
let account = BankAccount()
await account.deposit(100)
print(await account.balance) // Must await reads too

Always use await when accessing actor properties/methods—you don’t know if another task is inside.

  • Reference types (copies share same instance)
  • Can have properties, methods, initializers
  • Can conform to protocols
  • No inheritance (except NSObject for Objective-C interop)
  • Automatic isolation (no manual locks needed)
  • Implicit Sendable conformance
// ❌ Can't inherit from actors
actor Base {}
actor Child: Base {} // Error
// ✅ NSObject exception
actor Example: NSObject {} // OK for Objective-C

Shared isolation domain across types, functions, and properties.

Ensures execution on main thread:

@MainActor
final class ViewModel {
var items: [Item] = []
}
@MainActor
func updateUI() {
// Always runs on main thread
}
@MainActor
var title: String = ""
@globalActor
actor ImageProcessing {
static let shared = ImageProcessing()
private init() {} // Prevent duplicate instances
}
@ImageProcessing
final class ImageCache {
var images: [URL: Data] = [:]
}
@ImageProcessing
func applyFilter(_ image: UIImage) -> UIImage {
// All image processing serialized
}

Use private init to prevent creating multiple executors.

UI-related code that must run on main thread:

@MainActor
final class ContentViewModel: ObservableObject {
@Published var items: [Item] = []
}
// Old way
DispatchQueue.main.async {
// Update UI
}
// Modern way
await MainActor.run {
// Update UI
}
// Better: Use attribute
@MainActor
func updateUI() {
// Automatically on main thread
}

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.

Actor methods are isolated by default:

actor BankAccount {
var balance: Double
// Implicitly isolated
func deposit(_ amount: Double) {
balance += amount
}
}

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
}
}
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 await
try await database.transaction { db in
db.insert(item1)
db.insert(item2)
db.insert(item3)
}
extension Actor {
func performInIsolation<T: Sendable>(
_ block: @Sendable (_ actor: isolated Self) throws -> T
) async rethrows -> T {
try block(self)
}
}
// Usage
try await bankAccount.performInIsolation { account in
try account.withdraw(amount: 20)
print("Balance: \(account.balance)")
}

Opt out of isolation for immutable data:

actor BankAccount {
let accountHolder: String
nonisolated var details: String {
"Account: \(accountHolder)"
}
}
// No await needed
print(account.details)
extension BankAccount: CustomStringConvertible {
nonisolated var description: String {
"Account: \(accountHolder)"
}
}

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:

@MainActor
final 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.

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)")
}
}
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: 150

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.

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 context
Task { @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.

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 with non-Sendable delegate 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.

Advanced: Control how actor schedules work.

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)
}
}
  • Integration with legacy DispatchQueue-based code
  • Specific thread requirements (e.g., C++ interop)
  • Custom scheduling logic

Default executor is usually sufficient.

Synchronous locking without async/await overhead (iOS 18+, macOS 15+).

import Synchronization
final class Counter {
private let count = Mutex<Int>(0)
var currentCount: Int {
count.withLock { $0 }
}
func increment() {
count.withLock { $0 += 1 }
}
}
final class TouchesCapturer: Sendable {
let path = Mutex<NSBezierPath>(NSBezierPath())
func storeTouch(_ point: NSPoint) {
path.withLock { path in
path.move(to: point)
}
}
}
func decrement() throws {
try count.withLock { count in
guard count > 0 else {
throw Error.reachedZero
}
count -= 1
}
}
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
@MainActor
final class ContentViewModel: ObservableObject {
@Published var items: [Item] = []
func loadItems() async {
items = try await api.fetchItems()
}
}
@ImageProcessing
final class ImageProcessor {
func process(_ images: [UIImage]) async -> [UIImage] {
images.map { applyFilters($0) }
}
}
actor DataStore {
private var items: [Item] = []
func add(_ item: Item) {
items.append(item)
}
nonisolated func itemCount() -> Int {
// ❌ Can't access items
return 0
}
}
actor Database {
func transaction<T>(
_ operation: @Sendable (_ db: isolated Database) throws -> T
) throws -> T {
beginTransaction()
defer { commitTransaction() }
return try operation(self)
}
}
  1. Prefer actors over manual locks for async code
  2. Use @MainActor for UI - all view models, UI updates
  3. Minimize work in actors - keep critical sections short
  4. Watch for reentrancy - don’t assume state unchanged after await
  5. Use nonisolated sparingly - only for truly immutable data
  6. Avoid assumeIsolated - prefer explicit isolation
  7. Custom executors are rare - default is usually best
  8. Consider Mutex for sync code - when async overhead not needed
  9. Complete actor work before suspending - prevent reentrancy bugs
  10. Use isolated parameters - reduce suspension points
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