How is an async function different in compiled code, what does await compile to, and what is a hop_to_executor?

5.10.3 · L5 · Learning

Predict first

What does the SIL show for an @MainActor async function that awaits another?

import Swift
@inline(never) func slow() async -> Int { 1 }
@MainActor func ui() async -> Int { await slow() + 1 }
  • @async in the function type and a hop_to_executor
  • a plain thin function with no hop
  • a closure allocation only
  • a thread creation

Mental model. A normal call is a phone call that holds your line until finished. An async call writes your place in a notebook (the context) so that someone else can use the line and call you back where you left off.

An async function gets a different calling convention: the SIL type is @convention(thin) @async () -> Int, and the function receives a hidden async context instead of using only the machine stack. The context lives in a task-owned stack that grows with swift_task_alloc (visible in the assembly), so locals that are live across a suspension are saved there, and the function is split into parts that continue after each await that can actually suspend. An await is therefore a possible suspension point, not a guaranteed one: a call to slow() that returns immediately runs straight through.

Executors appear as hop_to_executor: the SIL of my @MainActor func ui() contains a hop_to_executor to the main-actor executor, placed after the call to slow() (which is not isolated), which is what “code after an await resumes on the actor again” means in the output. Those hops are cheap when already on the right executor and a real queue switch when not. Consequences: an async function cannot be called from a synchronous context without a task, async functions cannot be used as C callbacks, and on Apple platforms an async entry has an extra “function pointer” symbol (the Tu suffix in the symbols). The scheduling policy itself, the cooperative pool and the actor’s queue, is runtime behaviour that SIL does not show.

Async convention and an executor hop.

Common traps

  • Thinking every await switches threads. It is a potential suspension; many complete inline.
  • Holding non-Sendable state across a hop. Strict concurrency checking exists for exactly this.

They might follow up with

  • Why can’t sync code call async code directly? The calling convention differs and there is no task to suspend.
  • Does the hop cost anything? Only when it actually changes executors.

Related questions

  • 10.9.4 Where does code actually run after an await: what do @MainActor, MainActor.run and a nonisolated async…
  • 10.6.1 How do continuations bridge callback APIs, and what is the difference between checked and unsafe?
  • 10.4.2 How do @MainActor, global actors, isolated and nonisolated work, and how does isolation propagate?
  • 5.1.3 How do you look at what the compiler produced at each stage?
  • 5.1.4 How do you read SIL to tell static dispatch from dynamic dispatch?

← What does the compiler do with the statements inside a result builder…   How is throws implemented: is it like C++ exceptions, what does try… →