adjoe Engineers’ Blog
 /  Infrastructure  /  One await, Two Actors: A Runtime Trace of Swift Concurrency
Infrastructure
One await, Two Actors: A Runtime Trace of Swift Concurrency

adjoe’s Playtime SDK runs on around 200 million devices per month. That’s not a number we throw around lightly. It means every decision we make in the SDK, good or bad, scales to somewhere between “minor tax on most phones” and “measurable drain on everyone’s battery.” 

The Playtime SDK also lives inside apps we don’t own. A leading app publisher integrates it, ships it to their users, and we’re along for the ride. Which means competing for threads with their UI code, their networking stack, and their analytics. We can’t afford to be a bad guest.      

In this article, we’ll walk through what actually happens at runtime when you use Swift Concurrency, specifically what await does, how executors switch, and why those details matter at scale. 

Swift Concurrency Runtime Problem

At a surface level, Swift Concurrency seems simple. But under real production load, those runtime details start to shape performance, correctness, and how safely work moves across threads and actors.  

The actual work the SDK does is mostly async by nature: 

  • batching analytics events before uploading to reduce backend pressure,
  • retrying failed requests with exponential backoff, 
  • jitter to avoid thundering herd problems during outages, 
  • buffering data locally when connectivity is poor, and flushing it later.  

All of that is concurrent tasks that touch multiple layers: network, persistence, UI, and these must be cancellable when the host app’s lifecycle says stop.

GCD can do this, but it offers no structural assistance. 

Dispatch queues don’t compose, cancellation is manual, and building a retry-with-backoff flow that cleans up properly on cancellation requires scaffolding (which isn’t provided).    

Every DispatchQueue.asyncAfter used for retries is a potential leak.  

Swift Concurrency fits the problem better. Task hierarchies ensure cancellation is communicated to the whole retry-and-flush tree automatically; each task is still responsible for acting on it. 

Actor isolation enforces which layer owns which state at compile time rather than convention. Cooperative scheduling means a network waits for the task rather than holding a thread.   

But using it correctly requires understanding what the runtime does at every await.

  • When does it switch executors?
  • When does it stay on the same one? 
  • What happens to the thread in between?

In an SDK running on 200M devices, those details matter. You write await. What happens next?  

At that point, Swift operates in terms of executors, tasks, and continuation state. 

The trace scenario 

Execution starts on @MainActor. The code awaits work on another actor (ImagePipeline). Execution returns to @MainActor.  

actor ImagePipeline {

    func decode(_ bytes: Data) async -> UIImage { /* heavy work */ }

}

@MainActor

final class FeedViewModel {

    private let pipeline = ImagePipeline()

    private var thumbnail: UIImage?

    func refresh(_ bytes: Data) async {

        // This is the boundary we are tracing

        let image = await pipeline.decode(bytes)

        self.thumbnail = image

    }

}
Swift Structured Concurrency, A Runtime trace - Swift Concurrency

At that point, Swift operates in terms of executors, tasks, and continuation state. Each await is a chain of runtime decisions — and in a SDK running on 200M devices, every link in that chain has a cost. Here’s what each one is. 

1. Executor Identity is a Concrete Runtime Value 

Before the runtime can decide whether to switch, it needs to know what “same context” means, and that’s executor identity.  

Actor isolation isn’t an abstract compile-time concept. At runtime, it’s represented by executor references, not thread IDs. The ABI-level SerialExecutorRef (in include/swift/ABI/Executor.h) holds two fields: 

  • Identity –  a HeapObject *, the executor’s unique identifier. 
  • Implementation – a uintptr_t whose high bits carry the SerialExecutor witness table pointer and whose low bits carry an ExecutorKind tag.

The ExecutorKind enum  

In ExecutionKind, each variation has a different cost at hop-check time:

Ordinary – the default. Hop checks are cheap pointer identity comparisons on the Identity field.

ComplexEquality – opt-in for executors that need custom same-context semantics. Calls isSameExclusiveExecutionContext instead of comparing pointers. This is slower and uncommon. 

Immediate (Swift 6.2+) – used by Task.immediate. Marks an executor that cannot be switched to asynchronously; it runs synchronously on whatever thread calls it.

Three values, stored in the low bits of Implementation:    

enum class ExecutorKind : uintptr_t {

    Ordinary        = 0b00, // Default; also used implicitly by Generic/DefaultActor configs

    ComplexEquality = 0b01, // Invokes isSameExclusiveExecutionContext for hop checks

    Immediate       = 0b10, // Used by Task.immediate; cannot participate in switching

};

Factory configurations 

Four configurations cover the common cases. These aren’t separate kind values; they’re specific field combinations built on top of the enum:  

ConfigurationIdentityImplementationNotes
generic()nullptr0No specific executor; implicit Ordinary kind
forDefaultActor(actor)actor pointer0Plain actor; implicit Ordinary kind
forOrdinary(id, wtable)object pointerwtable | 0b00Custom SerialExecutor conformance
forComplexEquality(id, wtable)object pointerwtable | 0b01Custom equality semantics

Immediate (0b10) is produced by forSynchronousStart(), reserved for Task.immediate semantics. It marks an executor that cannot be switched to asynchronously.  

For most executors, hop checks are pointer identity comparisons on the Identity field. 

ComplexEquality executors opt into a slower path that calls isSameExclusiveExecutionContext instead.

With executor identity defined, what does it attach to? Not threads – tasks.

2. Tasks are Not Threads

You can’t understand the hop decision without understanding what actually suspends – and it’s not the thread. A task is a schedulable runtime object (Job) that carries resumption entry points.  

A thread is an OS execution resource. On suspend, Swift marks the task as suspended and clears the current-task association. The thread is not blocked:

task->flagAsSuspendedOnContinuation(context);

_swift_task_clearCurrent();

The thread is free. The task might resume on a completely different thread. Think open orders at a restaurant versus cooks: how many tickets are open has nothing to do with how many people are in the kitchen.   

The task is now suspended. The thread is free. So who decides when and where the task resumes? That’s the continuation state machine.

3. await is a Continuation State Machine

An await compiles to a lock-free state machine driven by atomic CAS operations. The machine has to handle a genuine race: async work might finish before the caller suspends, or the caller might suspend before the work finishes. Both orderings happen in production, and both have to work correctly without a lock. 

The state lives in ContinuationStatus (from include/swift/ABI/MetadataValues.h):

enum class ContinuationStatus : size_t {

    Pending  = 0, // Not yet awaited or resumed

    Awaited  = 1, // Awaited, not yet resumed

    Resumed  = 2, // Resumed, not yet awaited

};
Swift Structured Concurrency, A Runtime trace - Swift Concurrency

On initialization, the runtime records ResumeToExecutor. For an actor caller, this gets set to that actor’s executor via an executor override – the Main Actor in our trace. Without one, it defaults to SerialExecutorRef::generic(). AwaitSynchronization starts as Pending. 

At the await site, the runtime does a CAS from Pending → Awaited. 

Success means the task suspends, and _swift_task_clearCurrent() frees the thread. 

But if the CAS instead observes Resumed, the async work finished before the caller even got to the suspend point, and the continuation proceeds immediately. 

No suspension occurs. The completing side does the reverse: it tries Pending → Resumed, and if it finds Awaited, the task is already parked, so it enqueues on ResumeToExecutor. 

Once the state machine commits to resuming, the runtime faces a new question: does it actually need to switch executors? 

4. The hop Decision: mustSwitchToRun

Before resuming, the runtime checks whether an executor switch is actually needed.

There are two mustSwitchToRun functions in the source.  

The one used during task switching is a static function in stdlib/public/Concurrency/Actor.cpp, and it takes more parameters than you might expect:

static bool mustSwitchToRun(SerialExecutorRef currentSerialExecutor,

                            SerialExecutorRef newSerialExecutor,

                            TaskExecutorRef currentTaskExecutor,

                            TaskExecutorRef newTaskExecutor) {

    if (currentSerialExecutor.getIdentity() != newSerialExecutor.getIdentity()) {

        return true; // different isolation context

    }

    if (currentTaskExecutor.getIdentity() == newTaskExecutor.getIdentity())

        return false;

    if (currentTaskExecutor.isUndefined())

        currentTaskExecutor = swift_getDefaultExecutor();

    if (newTaskExecutor.isUndefined())

        newTaskExecutor = swift_getDefaultExecutor();

    return currentTaskExecutor.getIdentity() != newTaskExecutor.getIdentity();

}

The decision tree:

Swift Structured Concurrency, A Runtime trace - Swift Concurrency

Two independent things can require a hop: different serial executor identities (the obvious actor-boundary case), or a changed TaskExecutor preference on the task. That second one catches people off guard.  

You can trigger a switch without crossing an actor boundary at all, just from a task carrying a different executor preference. 

If neither fires, the runtime resumes inline via tail call. No enqueue, no scheduler round-trip. If either fires, it parks context on the task and moves to handoff or enqueue. 

A switch is needed. Now the runtime tries to avoid the scheduler entirely.  

5. Optimization: thread handoff

When a switch is required, the runtime tries to skip the scheduler. Thread handoff passes the current OS thread directly to the new executor. 

Three things must all be true at once (from Actor.cpp): 

if (currentTaskExecutor.isUndefined() &&

    canGiveUpThreadForSwitch(trackingInfo, currentExecutor) &&

    !shouldYieldThread()) {

Diagram:

Swift Concurrency Runtime trace

The current task has no TaskExecutor preference. The current executor is willing to give up its thread. And the system hasn’t asked this thread to yield.

When all three hold, tryAssumeThreadForSwitch runs on the destination. For a default actor, that means attempting to acquire the actor’s lock. 

Success: giveUpThreadForSwitch releases the old executor, and runOnAssumedThread runs as a tail call, keeping the stack flat. 

Failure: the runtime falls through to async enqueue.  When a handoff fails, there’s no choice but to enqueue, and where the task ends up depends on what kind of executor it’s targeting.

6. Where Does Enqueued Work Go?

The handoff failed. The runtime clears the current task association and enqueues asynchronously. But “enqueue” isn’t one thing. Where the job goes depends on the executor kind:

Swift Structured Concurrency, A Runtime trace - Swift Concurrency
if (serialExecutorRef.isGeneric()) {

     if (auto task = dyn_cast<AsyncTask>(job)) {

         auto taskExecutorRef = task->getPreferredTaskExecutor();

         if (taskExecutorRef.isDefined()) {

             return _swift_task_enqueueOnTaskExecutor(...);

         }

     }

     return swift_task_enqueueGlobal(job);

 }

 if (serialExecutorRef.isDefaultActor()) {

     return swift_defaultActor_enqueue(job, serialExecutorRef.getDefaultActor());

 }

 A “hop” is not one mechanism. When you hear “the task hopped to another actor,” the underlying operation could be a direct actor queue enqueue, a global pool submission, or a custom executor callback. 

In our trace, the job needs to reach @MainActor. That’s a specific executor with a specific route. 

7. Returning to @MainActor

This is where the trace completes. ResumeToExecutor was set to the main actor’s executor ref when the continuation was initialized. Now the runtime routes the job there. 

The runtime calls _enqueueOnMain(job), routing through swift_task_enqueueMainExecutor to MainActor.executor.enqueue(job)

On Apple platforms that dispatch onto the main GCD queue. 

The safety doesn’t come from the main thread itself. It comes from the serial executor’s exclusive-access contract. The main executor happens to use the main thread, but that’s an implementation detail of this particular executor. 

The isolation invariant is about executor identity, not thread affinity, something worth keeping in mind when working with custom executors, where the thread and the executor stop being the same thing.  

8. What This Means in Practice

PrincipleDetails
Minimize hopsmustSwitchToRun fires on every identity change. Hop costs compound across a call graph.
Await doesn’t always hopSame-executor awaits the hit, the inline fast path, and costs almost nothing.
Don’t blockThe global pool is fixed-size. Block a thread, and you starve everything else sharing it.
TaskExecutor preferences matterA changed TaskExecutor forces a hop even within the same actor.
Thread affinity is not isolationThe isolation guarantee is the executor identity. Which thread runs is an implementation detail of that executor, not the source of the guarantee.

End Note

Understanding Swift Concurrency at the runtime level has shaped how we structure async work in the Playtime SDK, especially around cancellation, executor hops, and cross-actor coordination under load. 

This has led to more predictable execution paths and safer concurrency patterns across network, persistence, and UI layers at scale. 

Other teams can apply the same model to reduce unintended hops and improve structured concurrency behavior in production systems.  

For more engineering breakdowns like this, check out the adjoe engineer’s blog

Source references

All references point to the Swift 6.3 main branch.

  • Executor identity and kinds: include/swift/ABI/Executor.h
  • Task lifecycle and continuation: include/swift/ABI/Task.h
  • Switch logic and mustSwitchToRun: stdlib/public/Concurrency/Actor.cpp
  • Main Actor implementation: stdlib/public/Concurrency/MainActor.swift
  • Continuation state enum: include/swift/ABI/MetadataValues.h

All references point to the Swift 6.3 main branch as of March 2026.

Build products that move markets

Your Skills Have a Place at adjoe

Find a Position