swift-concurrency

SKILL.md

Swift Concurrency

Lifecycle Position

Phase 3 (Implement) and Phase 5 (Review). Load for async patterns during implementation; use checklist during code review. Related: swift-networking for async network calls.

Decision Tree: Where Should This Code Run?

Is it UI code (views, view models, UI state)?
  → Yes → @MainActor
Does it manage shared mutable state?
  → Yes → Custom actor
Is it pure computation with no shared state?
  → Yes → nonisolated (Swift 6.2: @concurrent for CPU-heavy work)
Is it a one-off async operation from synchronous code?
  → Yes → Task { } (inherits actor context)

Actor Isolation

@MainActor

// On the entire class (recommended for ViewModels)
@Observable @MainActor
class UserViewModel {
    var users: [User] = []
    var isLoading = false

    func loadUsers() async {
        isLoading = true
        defer { isLoading = false }
        users = try? await api.fetchUsers() ?? []
    }
}

// On specific methods only
class DataProcessor {
    @MainActor func updateUI(with results: [Result]) {
        // Safe to touch UI state
    }

    nonisolated func processInBackground(_ data: Data) -> [Result] {
        // Runs on any thread, no actor isolation
    }
}

Custom Actors

actor ImageCache {
    private var cache: [URL: Image] = [:]

    func image(for url: URL) -> Image? {
        cache[url]
    }

    func store(_ image: Image, for url: URL) {
        cache[url] = image
    }
}

// Usage — every call crosses an isolation boundary
let cache = ImageCache()
let image = await cache.image(for: url)  // await required

Actor Reentrancy

actor BankAccount {
    var balance: Decimal

    func withdraw(_ amount: Decimal) async throws {
        // WARNING: balance might change between check and deduction
        // because another task could run during the await
        guard balance >= amount else { throw BankError.insufficientFunds }

        await recordTransaction(amount)  // suspension point — other tasks can interleave

        // Re-check invariant after suspension
        guard balance >= amount else { throw BankError.insufficientFunds }
        balance -= amount
    }
}

Rule: Always re-validate state after any await inside an actor.

SwiftData Concurrency

@Model classes have specific concurrency constraints enforced by Swift 6:

  • @Model is non-Sendable — do not pass model instances across actor boundaries
  • ModelContext is @MainActor-bound — create and use on the main actor
  • ModelContainer is Sendable — safe to pass between actors

Cross-Actor Access Pattern

// WRONG — passing @Model across actors
let item = context.fetch(...)  // on MainActor
await backgroundActor.process(item)  // non-Sendable error

// RIGHT — pass the identifier, re-fetch on the other side
let itemID = item.persistentModelID
await backgroundActor.process(itemID, container: container)

// In the background actor:
let bgContext = ModelContext(container)
let item = bgContext.model(for: itemID) as? Item

Testing Implications

When writing tests for @Observable @MainActor models that use SwiftData:

  • Test suites must be @MainActor (matches the model's isolation)
  • Use .serialized on @Suite to prevent parallel container conflicts
  • See ios-testing skill for complete SwiftData testing patterns

Sendability

Sendable Protocol

// Value types are automatically Sendable
struct UserID: Sendable { let rawValue: UUID }

// Immutable classes can be Sendable
final class Configuration: Sendable {
    let apiURL: URL        // let = immutable = safe
    let timeout: TimeInterval
}

// Mutable shared state needs an actor, not @unchecked Sendable
// AVOID unless you truly manage synchronization yourself:
final class LegacyCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: Data] = [:]
    // Must manually synchronize ALL access
}

The sending Keyword (Swift 6)

func process(_ data: sending Data) async {
    // Compiler guarantees caller won't access data after passing it
}

Structured Concurrency

async let (parallel independent work)

async let profile = api.fetchProfile(id)
async let posts = api.fetchPosts(userId: id)
async let followers = api.fetchFollowers(userId: id)

let (p, po, f) = try await (profile, posts, followers)  // all run in parallel

TaskGroup (dynamic parallel work)

let images = try await withThrowingTaskGroup(of: (URL, Image).self) { group in
    for url in urls {
        group.addTask {
            let image = try await downloadImage(from: url)
            return (url, image)
        }
    }
    var results: [URL: Image] = [:]
    for try await (url, image) in group {
        results[url] = image
    }
    return results
}

Cancellation

func processLargeDataset(_ items: [Item]) async throws {
    for item in items {
        try Task.checkCancellation()  // Throws if cancelled
        await process(item)
    }
}

// Or check without throwing
if Task.isCancelled { return }

Unstructured Tasks

// Task { } — inherits current actor context
@MainActor class ViewModel {
    func start() {
        Task {
            // This runs on MainActor (inherited)
            await loadData()
        }
    }
}

// Task.detached { } — no actor inheritance, runs on global executor
Task.detached(priority: .background) {
    // This does NOT run on MainActor
    let result = heavyComputation(data)
    await MainActor.run { self.updateUI(result) }
}

When to use each:

  • Task { } — 90% of cases. Starting async work from sync context.
  • Task.detached { } — CPU-heavy work that must NOT run on MainActor.

Migration from GCD

GCD Pattern Modern Equivalent
DispatchQueue.main.async { } @MainActor or MainActor.run { }
DispatchQueue.global().async { } Task.detached { } or nonisolated method
DispatchGroup TaskGroup or async let
DispatchSemaphore Actor isolation (semaphores + async = deadlock risk)
DispatchQueue (serial, for sync) Actor
Completion handler async throws -> return value

Bridging Completion Handlers

func legacyFetch(completion: @escaping (Result<Data, Error>) -> Void) { /* ... */ }

// Wrap with continuation
func modernFetch() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        legacyFetch { result in
            continuation.resume(with: result)  // MUST be called exactly once
        }
    }
}

Swift 6 Strict Concurrency

Incremental Adoption

// Enable per-target in Package.swift
.target(name: "MyApp", swiftSettings: [.swiftLanguageMode(.v6)])

// Or per-file: suppress specific warnings during migration
@preconcurrency import SomeOldLibrary

Common Warnings and Fixes

Warning Fix
"Sending 'value' risks data race" Make type Sendable or use sending parameter
"Non-sendable type captured by @Sendable closure" Move to actor-isolated method or make type Sendable
"Actor-isolated property cannot be mutated from nonisolated context" Add await or annotate caller with same actor
"Global variable is not concurrency-safe" Make it let, move to actor, or use nonisolated(unsafe) (last resort)
"Conformance of X to protocol Y crosses into main actor-isolated code" Use isolated conformance: extension X: @MainActor Y (Swift 6.2+)

Swift 6.2 Approachable Concurrency

Swift 6.2 changes the default: async functions stay on the calling actor instead of hopping to the global concurrent executor. This eliminates entire categories of data race errors for UI-bound code.

Key changes: See references/swift-6-2-changes.md for full details.

Feature What It Does
Async stays on caller's actor No implicit background hop — eliminates "Sending X risks data races" errors
@concurrent attribute Explicit opt-in to run on concurrent pool (replaces Task.detached for this use case)
Main-actor-by-default mode Opt-in: all types in a target implicitly @MainActor
Isolated conformances extension Foo: @MainActor Bar — protocol conformance restricted to main actor

SwiftUI-specific: See references/swiftui-concurrency.md for off-main-thread APIs (Shape, Layout, visualEffect, onGeometryChange) and their Sendable closure requirements.

Project triage: Before diagnosing concurrency errors, check Xcode Build Settings → Swift Compiler → Concurrency for language version and default actor isolation settings.

Common Patterns

Actor-Isolated Repository

For a complete actor-based persistence pattern with file backing, see swift-actor-persistence.

actor UserRepository {
    private let api: any APIClientProtocol
    private var cache: [UserID: User] = [:]

    func user(_ id: UserID) async throws -> User {
        if let cached = cache[id] { return cached }
        let user: User = try await api.request("/users/\(id.rawValue)")
        cache[id] = user
        return user
    }
}

.task Modifier for View Lifecycle

struct UserView: View {
    @State private var viewModel = UserViewModel()

    var body: some View {
        content
            .task { await viewModel.load() }          // auto-cancels on disappear
            .task(id: selectedID) { await viewModel.load(selectedID) }  // re-runs when ID changes
    }
}

Review Checklist

  • No data races: mutable shared state in actors or protected by isolation
  • Actor isolation boundaries clearly documented (which actor owns which state)
  • Cancellation cooperative: long operations check Task.checkCancellation()
  • Task references stored for cleanup if not using .task modifier
  • .task modifier used for view lifecycle async work (not onAppear { Task { } })
  • @MainActor on view models and UI state classes
  • No DispatchSemaphore mixed with async code (deadlock risk)
  • withCheckedContinuation resumes exactly once (not zero, not twice)
  • State re-validated after suspension points in actors (reentrancy)
  • @unchecked Sendable justified in comment if used
  • Swift 6.2: Check project concurrency settings before diagnosing (language version, default actor isolation)
  • Swift 6.2: @concurrent used instead of Task.detached for explicit background work
  • SwiftUI: No @MainActor state accessed from Sendable closures (visualEffect, onGeometryChange) — use value copies

Cross-References

  • swift-networking — async/await network patterns
  • swiftui-ui-patterns.task modifier, @MainActor ViewModel pattern, Sendable checks
  • ios-testing — testing async code with Swift Testing (#expect with await)
  • code-analyzer — concurrency safety review section
  • swift-actor-persistence — actor-based persistence pattern with file backing (extends the Actor-Isolated Repository pattern above)
Weekly Installs
4
First Seen
3 days ago
Installed on
mcpjam4
claude-code4
replit4
junie4
windsurf4
zencoder4