Swift Concurrency has a handful of keywords that control where code runs: nonisolated, isolated, @concurrent, @globalActor. They all answer the same question — which executor does this function run on? — just in different ways.
nonisolated — run on the caller's executor
When you mark a function nonisolated inside an actor, it doesn’t hop off to some generic executor. With Approachable Concurrency enabled (default from Swift 6.2, opt-in via SWIFT_UPCOMING_FEATURE_APPROACHABLE_CONCURRENCY = YES in earlier versions), a nonisolated function runs on the caller’s executor instead.
This was the original Swift Concurrency behaviour. SE-0338 changed it so that nonisolated async functions always ran off-actor. That change was later reverted in Swift 6.2 — so we’re back to running on the caller’s executor. nonisolated is now equivalent to nonisolated(nonsending).
actor MyActor {
var value = "abc"
func test() async {
await nonisolatedFuncCalledFromSelfActor()
Task { @MainActor in
await nonisolatedFuncCalledFromMainActor()
}
}
nonisolated func nonisolatedFuncCalledFromSelfActor() async {
self.assertIsolated() // ✓ — runs on MyActor's executor
// self.value = "x" ← ✗ compiler still sees this as nonisolated
}
nonisolated func nonisolatedFuncCalledFromMainActor() async {
MainActor.assertIsolated() // ✓ — caller was @MainActor
}
}
Notice the catch: even though assertIsolated() passes, the compiler still treats the function as nonisolated at the type-system level, so you can’t touch actor-isolated properties. The runtime and the type system are telling you different things — nonisolated describes the static view, not what’s actually happening on the thread.
Why is this useful? Less hopping on and off executors. Every unnecessary hop is overhead — the function suspends, waits for the target executor, and resumes. If the caller is already on the right executor, nonisolated avoids all of that.
@concurrent — force a hop off the actor
Sometimes you want the opposite — explicitly force the function off the actor and onto the global concurrent executor, regardless of who called it. That’s what @concurrent is for.
actor MyActor {
// Always hops to global concurrent executor
@concurrent nonisolated func concurrentNonisolatedFuncCalledFromSelfActor() async {
// self.assertIsolated() ← ✗ not on MyActor's executor
}
// @concurrent automatically marks the func as nonisolated too
@concurrent func concurrentFuncCalledFromSelfActor() async {
// self.assertIsolated() ← ✗ not on MyActor's executor
}
}
isolated parameter — borrow another actor's isolation
Putting isolated before a parameter transfers the function’s isolation to that actor. The function is no longer isolated to self — it runs on the actor passed as an argument.
actor ActorA {
var x = "abc"
func test() async {
let b = ActorB()
// Must await — needs to hop to b's executor
await changeOtherActorStateWithIsolationToOtherActor(b)
// No await — already on self's executor
changeOtherActorStateWithSelfActorIsolation(b)
}
// Isolated to b, NOT to self
func changeOtherActorStateWithIsolationToOtherActor(_ b: isolated ActorB) {
b.y = "new" // ✓
// self.x = "new" ← ✗ no longer isolated to self
}
// Isolated to self, b is just a regular parameter
func changeOtherActorStateWithSelfActorIsolation(_ b: ActorB) {
x = "new" // ✓
}
}
Only one parameter per function can be marked isolated.
isolated (any Actor) and #isolation
You can take it further and write functions that work with any actor dynamically. And with #isolation, the caller’s actor is captured automatically at the call site — no need to pass it explicitly.
// #isolation captures the caller's actor at the call site
func changeIsolatedActorVariableWithDefaultIsolatedAny(
isolation: isolated (any Actor) = #isolation
) {
if let a = isolation as? MyActor {
// Compiler doesn't know the dynamic type, so we assert it
a.assumeIsolated { isolated in
isolated.value = "abc" // ✓
}
}
}
actor Caller {
func go() {
changeIsolatedActorVariableWithDefaultIsolatedAny() // isolation = self, no await
}
}
#isolation is a compiler macro — it captures the surrounding isolation at the call site, not at the function definition.
@globalActor — a shared singleton actor
@MainActor is the most familiar example of a global actor, but you can define your own. A global actor is a singleton actor that multiple types can all be isolated to — since they share the same executor, they can access each other’s state directly without awaiting.
// Defining a new global actor
@globalActor
actor DatabaseActor {
static let shared = DatabaseActor()
}
// Both classes share the same actor — they can access each other freely
@DatabaseActor class Repository {
var cache: [String] = []
}
@DatabaseActor class QueryService {
func invalidate(_ repo: Repository) {
repo.cache = [] // ✓ same actor, no await needed
}
}
// From outside — must hop in explicitly
Task.detached {
// repo.cache = [] ← ✗ not isolated to DatabaseActor
Task { @DatabaseActor in
repo.cache = [] // ✓
}
}