Isolation: regions, sending, nonisolated

Swift · Expert · 18 min

Expert Swift 6 code relies on precise isolation rules to avoid both data races and needless actor hops.

Every piece of Swift code has a static isolation: an actor instance, a global actor such as @MainActor, or nonisolated. The compiler uses it to decide where code runs and what it may touch.

Inheritance rules worth knowing:

  • Task { } inherits the isolation of where it is created (and captures the actor); Task.detached { } does not.
  • Closures passed to non-@Sendable parameters inherit the caller's isolation.
  • Members of a @MainActor type are main-actor isolated unless marked nonisolated.

Where nonisolated async functions run changed in Swift 6.2. Historically (SE-0338) they always hopped to the global concurrent executor. With the NonisolatedNonsendingByDefault upcoming feature, they run on the caller's actor, which removes many Sendable errors; annotate a function `@concurrent` to explicitly run it off the actor. The spelling nonisolated(nonsending) states the caller-inheriting behavior explicitly.

Region-based isolation (SE-0414) tracks groups of non-Sendable values. A value in a "disconnected" region — freshly created, not reachable from an actor — can be transferred into another domain if the sender never uses it afterwards. The `sending` keyword (SE-0430) declares that a parameter or result is transferred this way: func enqueue(_ job: sending Job) accepts non-Sendable values the caller gives up.

isolated parameters (func f(on actor: isolated MyActor)) and #isolation let a function run on whatever actor it is passed, avoiding hops for generic utilities.

Example

final class Job { var log: [String] = [] }   // non-Sendable

actor Worker {
    private var jobs: [Job] = []
    func enqueue(_ job: sending Job) {        // caller gives the job up
        job.log.append("queued")
        jobs.append(job)
    }
    var count: Int { jobs.count }
}

func makeAndSend(to worker: Worker) async {
    let job = Job()                           // disconnected region
    job.log.append("created")
    await worker.enqueue(job)                 // OK: transferred
    // print(job.log)                         // error: 'job' used after being sent
}

func runOnCaller(_ a: isolated Worker) -> Int {  // runs on the actor passed in
    a.count                                   // no await needed
}

let w = Worker()
await makeAndSend(to: w)
print(await runOnCaller(w))                   // 1

Remember

  • Task {} inherits isolation; Task.detached {} does not
  • sending transfers a non-Sendable value the caller stops using
  • Swift 6.2 can run nonisolated async code on the caller's actor; @concurrent opts out

Watch out for

  • Reaching for Task.detached to "go off the main thread" when a @concurrent or nonisolated function expresses the intent safely.

Check yourself

  1. Why can a non-Sendable value be passed to an actor method declared with a `sending` parameter?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/dataracesafety

Builds on

Unlocks

Nothing builds on this yet.