← All lessonsGuide · await, in full
Guide

await, in full

What one await does to a function, how a loop with an await in it is built, what waiting really is, how for await and AsyncStream are made of those, and what an await does to an actor — each with a program that compiles and runs

You have written await since lesson 15, looped with for await since lesson 40, and put code on the main actor and on actors of your own since lesson 37 and lesson 38. Each of those lessons answers its own question. This guide answers one: what does await do? The answer is small — every await is a new function — and once you have it, a loop with an await in it, a continuation, for await, AsyncStream and an actor that lets another call in are all the same thing seen from different sides. Each section adds one thing to the one before, and everything on the page that claims to print something has been compiled and run, built as the course's projects are: Swift 6, nothing on the main actor unless it says so, and no async function following its caller unless it says so.

1 · from lesson 37

Start here: every await is a new function

One async function, with one await in it. answer() takes a moment, as a request does, and main waits for it:

answer(): one wait
/// An answer that takes a moment, as a request's does. `Task.sleep` holds no thread while it waits.
func answer() async -> Int {
    try? await Task.sleep(for: .milliseconds(10))
    return 7
}
In main
print("before the await")
let n = await answer()
print("after the await:", n)

It prints

Console
before the await
after the await: 7

Here is the same program written with no async and no await at all. It is what the compiler builds from the one above, and the rule it follows is the whole of this guide: everything before an await stays in one function, and everything after it becomes a new function. answer cannot return a value it does not have yet, so it takes the function to call when it has one:

The same program, written out by hand — for comparison only
import Foundation

/// The same answer, with no `async`. It cannot return a value it does not have yet, so it takes the function to
/// call when it has one — and calls it from another thread, as the real one is resumed from another thread.
func answer(thenContinueAt whereToGoNext: @escaping @Sendable (Int) -> Void) {
    Thread.detachNewThread {
        Thread.sleep(forTimeInterval: 0.01)
        whereToGoNext(7)
    }
}

/// `main`, as the compiler builds it: everything before the await, and everything after it.
enum Main {
    static func start() {
        print("before the await")
        answer(thenContinueAt: afterTheAnswer)
    }

    static func afterTheAnswer(_ n: Int) {
        print("after the await:", n)
        done.signal()
    }
}

/// Only keeps the program alive until the second piece has run. An app's run loop does that for it.
let done = DispatchSemaphore(value: 0)
In main
Main.start()
done.wait()

It prints the same

Console
before the await
after the await: 7
MAIN, AS YOU WROTE ITprint("before the await")let n = await answer()print("after the await:", n)one await: one cutPIECE 1 · BEFORE THE AWAITprint("before the await")answer(thenContinueAt: piece 2)answer(): waiting, on no threadPIECE 2 · AFTER THE AWAITn: the answerprint("after the await:", n)when it has the answer
One await, one cut. Piece 1 is everything before it; piece 2, everything after. Between them the answer is waited for, on no thread, and piece 2 runs when it comes.

start is piece 1: it prints, hands answer the name of piece 2, and returns — the thread is free. afterTheAnswer is piece 2, and it runs when answer calls it, on whatever thread answer was on. The value n never sits in a local variable across the wait, because there is no function left to hold it: it arrives as piece 2's argument. That is what await means: the function ends here, and the rest of it is a new function that the awaited call knows how to reach.

The compiler's own version has four pieces, not two. These are the functions it made of main, from the compiled program, with the names the demangler gives them:

The functions the compiler made of main
static Program.main() async -> ()
(1) suspend resume partial function for static Program.main() async -> ()
(2) await resume partial function for static Program.main() async -> ()
(3) suspend resume partial function for static Program.main() async -> ()

The first is main as far as its first hop: main runs on the main actor, and getting there is a hop. A suspend resume partial function is a piece that begins after a hop; an await resume partial function is one that begins when an awaited call has answered — afterTheAnswer, in the hand-written version. The extra two come from the hops: answer is nonisolated, so it runs on the pool of threads, and when it has answered the task comes back to the main actor before piece 3 goes on. So the count is not of lines but of awaits: one await, and main is four functions. The two you wrote by hand are the ones that carry your code.

2 · from lesson 40

A loop with an await in it

Now put the await inside a loop. Countdown hands out three, two, one and then nil, through an async method, and main asks until it gets nil. No protocol anywhere — while let needs only an optional, and the await only an async method:

Countdown: an async method that answers nil when it is done
/// Three, two, one, handed out one at a time. No protocol: an `async` method that answers `nil` when it is done.
struct Countdown {
    var left: Int

    mutating func next() async -> Int? {
        guard left > 0 else {
            return nil
        }
        left -= 1
        return left + 1
    }
}
In main
var iterator = Countdown(left: 3)
while let number = await iterator.next() {
    print(number)
}
print("Liftoff")

It prints

Console
3
2
1
Liftoff

The same rule as before — everything before the await in one function, everything after it in another — but the await is in the middle of a loop, so the cut goes through the loop, and a loop that has been cut cannot stay a loop. Here is what it becomes. start is the piece before the await. gotAnswer is the piece after it, and the piece after it is the rest of the loop: the nil check, the body, and the next call of next — which is told to come back to gotAnswer:

The loop, written out by hand — for comparison only
/// The countdown with no `async`: `next` calls `whereToGoNext` instead of returning.
final class Countdown {
    var left: Int

    init(left: Int) {
        self.left = left
    }

    func next(thenContinueAt whereToGoNext: (Int?) -> Void) {
        guard left > 0 else {
            whereToGoNext(nil)
            return
        }
        left -= 1
        whereToGoNext(left + 1)
    }
}

/// `main`, as the compiler builds it: the piece before the await, and the piece after — which is the rest of the loop.
final class Main {
    let iterator = Countdown(left: 3)
    let whereToGoNext: () -> Void

    /// Whoever starts `main` says where to continue when it is done.
    init(thenContinueAt whereToGoNext: @escaping () -> Void) {
        self.whereToGoNext = whereToGoNext
    }

    func start() {
        iterator.next(thenContinueAt: gotAnswer)
    }

    func gotAnswer(_ number: Int?) {
        guard let number else {
            print("Liftoff")
            whereToGoNext()          // this is `main` returning
            return
        }
        print(number)
        iterator.next(thenContinueAt: gotAnswer)
    }
}
In main
// Stands in for the code the compiler writes around `main`, which ends the program when `main` is done.
let main = Main(thenContinueAt: {
    print("back in the startup code, which ends the program")
})
main.start()

It prints the same, and one line more

Console
3
2
1
Liftoff
back in the startup code, which ends the program
start()next(thenContinueAt:)gotAnswer(number)askthe answera number: print it, and ask againnilprint("Liftoff"); whereToGoNext()no while is left: going round is gotAnswer asking next to come back to gotAnswer
The loop as functions. There is no while any more: going round again is gotAnswer asking next to come back to gotAnswer. When next answers nil, gotAnswer prints Liftoff and continues wherever main was told to.

Three things are worth seeing in it. There is no while left. Going round again is gotAnswer asking next to come back to gotAnswer. The line after the loop needs no function of its own. print("Liftoff") sits in gotAnswer's nil branch, because leaving a loop is the if taking its other branch, not an await; a new function starts only at an await. return is a call. main cannot go back up a stack when it is done, because nothing of it is on a stack; whoever started main said where to continue, and whereToGoNext() is main returning — forward, to the function it was given. For main that is startup code the compiler writes, which ends the program, and the hand-written run stands in for it with one more line.

The compiler's pieces for the loop are the same four as for one await, because the loop has one await:

The functions the compiler made of main, loop and all
static Program.main() async -> ()
(1) suspend resume partial function for static Program.main() async -> ()
(2) await resume partial function for static Program.main() async -> ()
(3) suspend resume partial function for static Program.main() async -> ()
3 · from lesson 37

Down to the machine

You can watch the loop disappear. Here is the same countdown with no async — next answers at once — and the machine code the compiler emits for main, cut down to the lines that say where control goes:

Countdown, with no async
/// The same countdown with no `async`: `next` answers at once.
struct Countdown {
    var left: Int

    mutating func next() -> Int? {
        guard left > 0 else {
            return nil
        }
        left -= 1
        return left + 1
    }
}
In main
var iterator = Countdown(left: 3)
while let number = iterator.next() {
    print(number)
}
print("Liftoff")
The branches in main's machine code — the compiler's arm64, with everything that is not a branch or a label left out
static Program.main() -> ():
    bl Countdown.init(left: Swift.Int) -> Countdown
    b LBB6_1
LBB6_1:
    bl Countdown.next() -> Swift.Int?
    b.eq LBB6_4
    b LBB6_2
LBB6_2:
    b LBB6_3
LBB6_3:
    bl Swift.print(_: Any..., separator: Swift.String, terminator: Swift.String) -> ()
    b LBB6_1
LBB6_4:
    bl Swift.print(_: Any..., separator: Swift.String, terminator: Swift.String) -> ()
    ret
InstructionWhat the CPU does
blWhat the CPU doesbranch with link: saves the address of the next instruction in a register, then jumps — a call, which ret comes back from
bWhat the CPU doesbranch: jumps, and nothing records where it came from
b.eqWhat the CPU doesbranch if equal: jumps only if the last comparison came out equal — here, if next answered nil
retWhat the CPU doesjumps to the address bl saved: the function returns
br x0What the CPU doesjumps to the address in a register — whatever was put there

One function. bl next calls next and comes back to the b.eq; b.eq leaves the loop if the answer was nil; b LBB6_1 jumps back up, and that jump is the while. At the end, ret.

Now the async version, the loop of the last section, in the same cut:

The branches in the async main, piece by piece
static Program.main() async -> ():
    bl type metadata accessor for Swift.MainActor
    bl static Swift.MainActor.shared.getter : Swift.MainActor
    bl dispatch thunk of Swift.Actor.unownedExecutor.getter : Swift.UnownedSerialExecutor
    adrp x0, (1) suspend resume partial function for static Program.main() async -> ()@PAGE
    b _swift_task_switch

(1) suspend resume partial function for static Program.main() async -> ():
    bl Countdown.init(left: Swift.Int) -> Countdown
    adrp x8, async function pointer to Countdown.next() async -> Swift.Int?@PAGE
    bl _swift_task_alloc
    adrp x8, (2) await resume partial function for static Program.main() async -> ()@PAGE
    b Countdown.next() async -> Swift.Int?

(2) await resume partial function for static Program.main() async -> ():
    bl _swift_task_dealloc
    adrp x0, (3) suspend resume partial function for static Program.main() async -> ()@PAGE
    b _swift_task_switch

(3) suspend resume partial function for static Program.main() async -> ():
    b.eq LBB10_2
    b LBB10_1
LBB10_1:
    bl Swift.print(_: Any..., separator: Swift.String, terminator: Swift.String) -> ()
    adrp x8, async function pointer to Countdown.next() async -> Swift.Int?@PAGE
    bl _swift_task_alloc
    adrp x9, (2) await resume partial function for static Program.main() async -> ()@PAGE
    b Countdown.next() async -> Swift.Int?
LBB10_2:
    bl _swift_release
    bl Swift.print(_: Any..., separator: Swift.String, terminator: Swift.String) -> ()
    br x0

Four functions, and every line that reaches next is b, a one-way jump, never bl: there is no coming back, because the piece that jumped has ended. Before it jumps, a piece writes the address of piece 2 into the frame it has just allocated for next — the adrp of "(2) await resume partial function" — which is thenContinueAt: in the machine's own words; next ends by jumping to that address. Piece 3 ends the same way piece 1 did, with a b to next, and that is what goes round. When next answers nil, piece 3 ends with br x0: a jump to whatever address was put in x0, which is wherever main was told to continue. There is no ret anywhere in it, because nothing of main is on the thread's stack to return to.

4 · from lesson 40

Waiting for real: a continuation parks the task

So far every await was answered almost at once. The point of the whole arrangement is what happens when it is not. The piece before the await has returned, and the piece after it is a function with an address: so there is nothing on any thread, and the thread goes off to run other tasks. What is left is the task — its frames, on the heap, and a note of which piece to run next. Something has to bring it back, and that something is a continuation: withCheckedContinuation hands the wait a continuation, and whoever calls its resume puts the task back on a thread. Here the countdown waits, before every answer, for another thread to say so:

A wait that ends when another thread resumes it
import Foundation

/// Waits until another thread says so. `withCheckedContinuation` hands the wait a continuation, the way back in;
/// `resume` is the other thread using it.
func waitForTheOtherThread() async {
    await withCheckedContinuation { continuation in
        Thread.detachNewThread {
            Thread.sleep(forTimeInterval: 0.02)
            continuation.resume()
        }
    }
}

/// The countdown, with a real wait before each answer.
struct Countdown {
    var left: Int

    mutating func next() async -> Int? {
        await waitForTheOtherThread()
        guard left > 0 else {
            return nil
        }
        left -= 1
        return left + 1
    }
}
In main, the loop of section 2, unchanged
var iterator = Countdown(left: 3)
while let number = await iterator.next() {
    print(number)
}
print("Liftoff")

It prints

Console
3
2
1
Liftoff
A THREAD OF THE POOLa piece of some other taska piece of some other tasknothing of the loop’sthe thread is free to run anythingTHE TASK, ON THE HEAPmain’s frame: iterator, continue at piece 2next’s frame: waitinga continuation: the way back incancelled flag, priorityanother thread: continuation.resume()resumed: piece 2 goesback on a thread
While next waits. A pool thread holds nothing of the loop and runs other tasks; the task is frames on the heap, with the address of piece 2 and a continuation; another thread resumes the continuation, and piece 2 goes back on a thread.

In the compiled program, the wait is a call into the Swift runtime. These are the lines of LLVM IR — the compiler's last stage before machine code — that call it:

Every call to swift_continuation_await in the compiled program
musttail call swifttailcc void @swift_continuation_await(ptr %…) #3

swift_continuation_await marks the task as suspended and returns the thread to the pool; resume, on the other thread, calls swift_continuation_resume, which puts the task back on its executor's queue. Neither holds a thread for the task in between. Here is what that is worth: ten thousand tasks, every one of them waiting, and the number of threads in the process while they do. A small actor, which section 8 is about, keeps their continuations until it is told to let them all go:

A gate that keeps continuations, and a count of the process's threads
import Foundation

/// Keeps every waiting continuation until `open()`, as a stream keeps its reader's.
actor Gate {
    private var waiting: [CheckedContinuation<Void, Never>] = []

    func wait() async {
        await withCheckedContinuation { continuation in
            waiting.append(continuation)
        }
    }

    func open() {
        let all = waiting
        waiting = []
        for continuation in all {
            continuation.resume()
        }
    }
}

/// How many threads this process has right now.
func threadCount() -> Int {
    var threads: thread_act_array_t?
    var count: mach_msg_type_number_t = 0
    task_threads(mach_task_self_, &threads, &count)
    if let threads {
        vm_deallocate(mach_task_self_, vm_address_t(bitPattern: threads), vm_size_t(Int(count) * MemoryLayout<thread_t>.stride))
    }
    return Int(count)
}
In main
let gate = Gate()
print("threads before:", threadCount())
let total = await withTaskGroup(of: Int.self) { group in
    for _ in 0..<10_000 {
        group.addTask {
            await gate.wait()
            return 1
        }
    }
    try? await Task.sleep(for: .milliseconds(500))
    print("threads while 10,000 tasks wait:", threadCount())
    await gate.open()
    var total = 0
    for await one in group {
        total += one
    }
    return total
}
print("every task got through:", total == 10_000)

It prints

Console
threads before: 1
threads while 10,000 tasks wait: 6
every task got through: true

Ten thousand waits, and a handful of threads: the pool's one per core, and a few the system keeps. A waiting task costs a frame, not a thread, and that is why an app can have a .task waiting in every view on the screen and never run out of anything. It is also why the pool's threads must never be blocked with a lock or a sleep that holds the thread — section 9 measures what happens when one is.

5 · from lesson 40

for await: the loop the compiler writes for you

for await is section 2's while let, written by the compiler. For it to write the loop it has to know what to call, and two protocols tell it: AsyncSequence, with one requirement, makeAsyncIterator(), and AsyncIteratorProtocol, with next(). Here they are as the SDK declares them, shortened:

From the iOS 27 SDK's _Concurrency interface (shortened) — not code to type
public protocol AsyncSequence<Element, Failure> {
  associatedtype AsyncIterator : AsyncIteratorProtocol
  associatedtype Element where Self.Element == Self.AsyncIterator.Element
  associatedtype Failure = any Error where Self.Failure == Self.AsyncIterator.Failure
  func makeAsyncIterator() -> Self.AsyncIterator
}

public protocol AsyncIteratorProtocol<Element, Failure> {
  associatedtype Element
  associatedtype Failure : Error = any Error
  mutating func next() async throws -> Self.Element?
  mutating func next(isolation actor: isolated (any Actor)?) async throws(Self.Failure) -> Self.Element?
}

One struct can be both — its iterator is itself — and then the loop shrinks to one line:

Countdown, now an AsyncSequence that is its own iterator
/// The same countdown, usable with `for await`: `AsyncSequence` asks for `makeAsyncIterator()`,
/// `AsyncIteratorProtocol` for `next()`. One struct can be both.
struct Countdown: AsyncSequence, AsyncIteratorProtocol {
    var left: Int

    mutating func next() async -> Int? {
        guard left > 0 else {
            return nil
        }
        left -= 1
        return left + 1
    }

    func makeAsyncIterator() -> Countdown {
        self
    }
}
In main
for await number in Countdown(left: 3) {
    print(number)
}
print("Liftoff")

It prints

Console
3
2
1
Liftoff

What the compiler does with that line is not a secret, and you can watch it. While it is still checking types, it rewrites every for-in loop, await or not, into a while loop, and -dump-ast prints the result under the loop as a desugared_loop. This is that tree for the loop above, trimmed to the nodes that matter:

The while the compiler rewrote for await into, from swiftc -dump-ast (trimmed)
(desugared_loop=brace_stmt
  (pattern_binding_decl
    (pattern_entry
      (pattern_named type="Countdown" "$number$generator"
      (init=call_expr type="Countdown"
        (dot_syntax_call_expr type="() -> Countdown"
          (declref_expr type="(Countdown) -> () -> Countdown" decl="Countdown.makeAsyncIterator()"
  (while_stmt
    (conditions=array
      (pattern
        (pattern_optional_some type="Int?"
          (pattern_named type="Int" "$element"
        (unsafe_expr type="Int?"
          (call_expr type="Int?"
            (dot_syntax_call_expr type="(isolated (any Actor)?) async -> Int?"
              (declref_expr type="(inout Countdown) -> (isolated (any Actor)?) async -> Int?" decl="AsyncIteratorProtocol.next(isolation:)"
              (argument_list
                (argument inout
                  (inout_expr type="inout Countdown"
                    (declref_expr type="@lvalue Countdown" decl="Program.main().$number$generator"
            (argument_list labels="isolation:"
              (argument label="isolation"
                (current_context_isolation_expr type="(any Actor)?"
    (brace_stmt
      (pattern_binding_decl
        (pattern_entry
          (init=declref_expr type="Int" decl="Program.main().$element"

Read it from the top. A variable named $number$generator, set to Countdown(left: 3).makeAsyncIterator(). Then a while whose condition is let $element = await $number$generator.next(isolation: …) — pattern_optional_some is the let taking the value out of the optional — and whose body begins by binding your number to $element. Written as Swift you can type, it is this, and it prints the same:

The rewrite, as Swift — for comparison only
// The loop above, as the compiler rewrites it — written as Swift you can type. The compiler's own names are
// `$number$generator` and `$element`: the `$` keeps them out of your code's way.
var generator = Countdown(left: 3).makeAsyncIterator()
while let element = await generator.next(isolation: #isolation) {
    let number = element
    print(number)
}
print("Liftoff")

It prints

Console
3
2
1
Liftoff

So the rewrite is section 2's loop with the protocols doing the naming. Three things follow. Why the protocols are required. The rewrite calls two methods it has never seen, and it finds them by looking up the conformance to AsyncSequence; without the conformance there is nothing to look up, and the loop fails before it is built. Lesson 40 tries it with the countdown of section 2, which conforms to nothing:

for await over a type with no conformance

for-in loop requires 'Countdown' to conform to 'AsyncSequence'

Where next runs. The rewrite passes #isolation — the actor the loop is on — to next(isolation:), which SE-0421 added. A nonisolated async function runs on the pool whoever calls it, so your own next() leaves the actor and comes back for every value, from a while let and from a for await alike; the default next(isolation:) runs on the loop's actor and then calls it. Implement next(isolation:) yourself and its isolated parameter keeps it on the caller's actor. The lesson measured both from a loop on the main actor:

A next() with no isolation, from a while let and then from a for await

Console
next() on the main thread: false
next() on the main thread: false
next() on the main thread: false
next() on the main thread: false

The same loop over a next(isolation:)

Console
next(isolation:) on the main thread: true
next(isolation:) on the main thread: true

Where the C++ is. The class that does the rewrite is DesugarForEachStmt, in the Swift compiler's type checker. Here it is, shortened to the lines that do what the tree shows: isAsync is whether you wrote await; sequenceProto is AsyncSequence for an await loop and Sequence for a plain one, so the same class rewrites both; buildMakeIteratorVar makes the $…$generator name; buildNextCall labels the argument isolation; and buildWhileStmt makes the while:

From the Swift compiler's source, lib/Sema/TypeCheckStmt.cpp (shortened) — C++, not code to type
class DesugarForEachStmt {
  ForEachStmt *stmt;
  DeclContext *dc;
  ASTContext &ctx;
  bool isAsync;
  …
  DesugarForEachStmt(ForEachStmt *stmt)
      : stmt(stmt), dc(stmt->getDeclContext()),
        ctx(stmt->getDeclContext()->getASTContext()),
        isAsync(stmt->getAwaitLoc().isValid()) {}

  BraceStmt *operator()() {
    …
    sequenceProto =
        isAsync ? ctx.getProtocol(KnownProtocolKind::AsyncSequence)
                : (isBorrowing
                       ? ctx.getProtocol(KnownProtocolKind::Iterable)
                       : ctx.getProtocol(KnownProtocolKind::Sequence));
    seqConformanceRef = lookupConformance(seqType, sequenceProto);
    …
    buildMakeIteratorVar();
    …
    SmallVector<ASTNode, 2> stmts;
    stmts.push_back(buildMakeIterator());
    stmts.push_back(buildWhileStmt());
    …
  }

  void buildMakeIteratorVar() {
    std::string name;
    {
      if (auto np = dyn_cast_or_null<NamedPattern>(stmt->getPattern()))
        name = "$" + np->getBoundName().str().str();
      name += "$generator";
    }
    …
  }

  Expr *buildNextCall() {
    …
    FuncDecl *nextFn = TypeChecker::getForEachIteratorNextFunction(
        dc, stmt->getForLoc(), isAsync);
    TinyPtrVector<Identifier> labels;
    if (nextFn && nextFn->getParameters()->size() == 1)
      labels.push_back(ctx.Id_isolation);
    …
  }

  WhileStmt *buildWhileStmt() {
    Expr *nextCall = buildNextCall();
    auto varName = isBorrowing ? "$span" : "$element";
    …
    auto *whileStmt = new (ctx)
        WhileStmt(LabeledStmtInfo(), stmt->getForLoc(),
                  buildWhileCond(nextCallVar, nextCall), whileBody, true);
    …
  }
};

After this rewrite, nothing further down the compiler knows that there was ever a for. The SIL it emits for main — its own intermediate code, one step before LLVM — shows the two calls, and nothing else of a loop:

From the compiler's SIL for main: the calls
// static Program.main()
// function_ref Countdown.makeAsyncIterator()
%12 = function_ref @$s7program9CountdownV17makeAsyncIteratorACyF : $@convention(method) (Countdown) -> Countdown
%13 = apply %12(%11) : $@convention(method) (Countdown) -> Countdown
// function_ref AsyncIteratorProtocol.next(isolation:)
%23 = function_ref @$sScIsE4next9isolation7ElementQzSgScA_pSgYi_tYa7FailureQzYKF : $@convention(method) @async <τ_0_0 where τ_0_0 : AsyncIteratorProtocol> (@sil_isolated @guaranteed Optional<any Actor>, @inout τ_0_0) -> (@out Optional<τ_0_0.Element>, @error_indirect τ_0_0.Failure)
try_apply %23<Countdown>(%16, %24, %21, %22) : $@convention(method) @async <τ_0_0 where τ_0_0 : AsyncIteratorProtocol> (@sil_isolated @guaranteed Optional<any Actor>, @inout τ_0_0) -> (@out Optional<τ_0_0.Element>, @error_indirect τ_0_0.Failure), normal bb2, error bb5

makeAsyncIterator() once, then AsyncIteratorProtocol.next(isolation:), generic over the iterator, with the actor as its first argument and the iterator passed @inout so that each call can change it. What the protocols add is for everyone else: map, filter, prefix and the rest of AsyncSequence work on the countdown now, and so does any function that takes some AsyncSequence. Observations, the sequence of every change of an @Observable property, is one more such type: the guide @Observable, in full shows this same rewrite over it, and what its next does inside.

Where this is

The rewrite is DesugarForEachStmt in lib/Sema/TypeCheckStmt.cpp of the Swift compiler, linked below at the commit this page quotes. SE-0298 is the proposal for AsyncSequence, AsyncIteratorProtocol and for await; SE-0421 is the one that gave next its isolation parameter.

Where this comes from: The Swift compiler's rewrite of every for-in loop, async or not: DesugarForEachStmt in lib/Sema/TypeCheckStmt.cpp · SE-0298: Async/Await: Sequences — AsyncSequence, AsyncIteratorProtocol and for await · SE-0421: Generalize effect polymorphism for AsyncSequence and AsyncIteratorProtocol — next(isolation:)

6 · from lesson 40

A stream, by hand, and then AsyncStream

Section 4's countdown waited for another thread to say so, and section 2's handed out values it already had. A stream is the two put together: values can arrive before anyone asks for them, so it keeps a buffer; and next can be asked before a value has arrived, so it keeps the continuation of whoever is waiting. yield and finish are the producer's side, on any thread; next is the consumer's. Here is one, in full:

MyStream: a buffer, a flag, and the waiting continuation, under one lock
import Foundation
import Synchronization

/// A stream of your own: a buffer for values nobody has asked for yet, and the continuation of whoever is waiting
/// for one. `yield` and `finish` are the producer's side; `next` is the consumer's.
final class MyStream<Element: Sendable>: Sendable {
    private struct State {
        var buffer: [Element] = []
        var finished = false
        var waiting: CheckedContinuation<Element?, Never>?
    }

    private let lock = Mutex(State())

    /// A value goes to whoever is waiting, or into the buffer.
    func yield(_ value: Element) {
        let waiting = lock.withLock { state -> CheckedContinuation<Element?, Never>? in
            if let waiting = state.waiting {
                state.waiting = nil
                return waiting
            }
            state.buffer.append(value)
            return nil
        }
        waiting?.resume(returning: value)
    }

    /// The end: whoever is waiting gets `nil`, and so does every `next()` after the buffer is empty.
    func finish() {
        let waiting = lock.withLock { state -> CheckedContinuation<Element?, Never>? in
            state.finished = true
            let waiting = state.waiting
            state.waiting = nil
            return waiting
        }
        waiting?.resume(returning: nil)
    }

    /// A buffered value at once; `nil` after `finish`; otherwise park here until a value comes.
    func next() async -> Element? {
        await withCheckedContinuation { continuation in
            lock.withLock { state in
                if !state.buffer.isEmpty {
                    continuation.resume(returning: state.buffer.removeFirst())
                } else if state.finished {
                    continuation.resume(returning: nil)
                } else {
                    state.waiting = continuation
                }
            }
        }
    }
}

A producer on another thread yields three values and finishes; the consumer is section 2's while let, unchanged:

In main
let stream = MyStream<Int>()
Thread.detachNewThread {
    for number in [3, 2, 1] {
        Thread.sleep(forTimeInterval: 0.02)
        stream.yield(number)
    }
    stream.finish()
}
while let number = await stream.next() {
    print(number)
}
print("Liftoff")

It prints

Console
3
2
1
Liftoff
THE PRODUCERyield(3)yield(2)yield(1)finish()THE STREAMbuffer: [ ]waiting: a continuationTHE CONSUMER, ONE TASKfor await number in streamprint(number)a valuenext()resumea value goes to whoever is waiting, or into the buffer; next takes from the buffer, or waits
A stream between a producer and a consumer. A value goes to whoever is waiting, or into the buffer; next takes from the buffer, or parks the consumer until a value comes.

That is AsyncStream, in miniature. The real one is made the same way — Apple's page calls it "well-suited to adapt callback- or delegation-based APIs to participate with async-await" — and it is a sequence, so the loop can be for await:

The same program, with AsyncStream
let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
Thread.detachNewThread {
    for number in [3, 2, 1] {
        Thread.sleep(forTimeInterval: 0.02)
        continuation.yield(number)
    }
    continuation.finish()
}
for await number in stream {
    print(number)
}
print("Liftoff")

It prints

Console
3
2
1
Liftoff

makeStream(of:) gives the sequence and its continuation — the producer's side, with yield and finish, which you may hand to a delegate method, a timer, or another thread. What the real one adds to MyStream is bookkeeping you would otherwise write yourself, and lesson 40 measures each piece of it. A buffering policy, for a producer faster than its consumer — .bufferingNewest(1) keeps only the last value:

Five values yielded, then one loop

Console
[5]

Values are kept until a loop takes them, so a producer may start before anyone is listening:

Two yields, then a loop

Console
nobody was looping yet
Seattle
Vancouver

An end on cancellation. When the task running the loop is cancelled, the stream ends by itself — onTermination runs, and next answers nil — which is the next section. And one reader: a second loop over the same stream shares the values rather than seeing them all, which is why each screen that needs a stream makes its own.

7 · from lesson 40

In a Task, and when the task is cancelled

A loop like section 2's needs an async function to live in, so in an app it goes in a Task or, in a view, in .task, which starts when the view appears and cancels when the view goes away. Cancelling sets a flag; it stops nothing. A while let over a next() of your own runs on until next() answers nil, and a next() that never looks at the flag never does. Lesson 40 cancels one halfway:

A while let in a task cancelled after the second value

Console
got 3 — cancelled: false
got 2 — cancelled: false
got 1 — cancelled: true
the loop ended

The third value came after the cancel. A next() that is meant to end with its task has to say so — here, by answering nil when its wait is cancelled:

The same, with a next() that notices

Console
got 3 — cancelled: false
got 2 — cancelled: false
the loop ended

AsyncStream does this for you — Apple: "Canceling an active iteration invokes the onTermination callback first, then resumes by yielding nil" — so a for await over a stream in a .task ends when the view does:

A loop over a stream, its task cancelled

Console
terminated: cancelled

One more thing about a loop in a .task: the line after it runs only when the loop ends, and a loop over something that never finishes, such as a notification, ends only when the view goes away. The lesson shows it:

While the view is on the screen

Console
the loop begins
became active

After it is removed

Console
after the loop
8 · from lesson 38

await on an actor: the main actor, and one of your own

An actor is an object that runs one job at a time, and the await you write to call into one is the same await as every other on this page: the function is cut, and the piece after the cut runs when the actor gets to it. The main actor is the one you have used most, and it is not a macro and not a special case. It is an actor like any you could write, with the @globalActor attribute and the one shared instance the GlobalActor protocol asks for:

From the iOS 27 SDK's _Concurrency interface (shortened) — not code to type
@globalActor final public actor MainActor : GlobalActor {
  public static let shared: MainActor
  nonisolated final public var unownedExecutor: UnownedSerialExecutor { get }
  public static var sharedUnownedExecutor: UnownedSerialExecutor { get }
  nonisolated final public func enqueue(_ job: UnownedJob)
}

public protocol GlobalActor {
  associatedtype ActorType : Actor
  static var shared: Self.ActorType { get }
  static var sharedUnownedExecutor: UnownedSerialExecutor { get }
}

public protocol Actor : AnyObject, Sendable {
  nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}

So you can write one. Two lines make a global actor; @MyActor can then go on a type, a function or a closure, as @MainActor can, and everything marked with it takes turns on that one actor. This program marks one function with each and asks each where it runs, by the label of the dispatch queue under its thread:

A global actor of your own, and three functions that say where they run
import Foundation

/// A global actor of your own: the attribute, and the one shared instance the protocol asks for.
@globalActor
actor MyActor {
    static let shared = MyActor()
}

/// The label of the dispatch queue this thread is serving: the main thread's, or one of the pool's.
func queueName() -> String {
    String(cString: __dispatch_queue_get_label(nil))
}

@MainActor
func onTheMainActor() async {
    print("@MainActor runs on", queueName())
}

@MyActor
func onMyActor() async {
    print("@MyActor runs on", queueName(), "- and #isolation is MyActor.shared:", #isolation === MyActor.shared)
}

func onNoActor() async {
    print("nonisolated async runs on", queueName())
}
In main
await onTheMainActor()
await onMyActor()
await onNoActor()

It prints

Console
@MainActor runs on com.apple.main-thread
@MyActor runs on com.apple.root.default-qos.cooperative - and #isolation is MyActor.shared: true
nonisolated async runs on com.apple.root.default-qos.cooperative

The main actor runs on the main thread. Your actor runs on the pool, on whichever of its threads is free: an actor is not a thread, and not a queue either, but a promise that its jobs run one at a time, kept by the runtime on the shared threads. #isolation inside a @MyActor function is MyActor.shared, the instance the attribute names. And a function on no actor runs on the pool too, as section 5 said.

Now the one thing about actors that every await on this page has been preparing you for. An await inside an actor's method is a cut like any other: the piece before it ends, and the actor — which runs one job at a time — is free to run another job until the piece after it is ready. Shop has one bag. buy checks the stock, awaits the price, and sells:

Shop: an actor with an await in the middle of a method
/// Stands in for a request: it waits, then answers.
func askPrice() async -> Int {
    try? await Task.sleep(for: .milliseconds(20))
    return 5
}

/// One bag to sell. `buy` checks the stock, asks the price, and sells — with an `await` between the check and the sale.
actor Shop {
    var bags = 1
    var sold = 0

    func buy() async {
        guard bags > 0 else {
            return
        }
        let price = await askPrice()        // the actor is free while this waits
        bags -= 1
        sold += 1
        print("sold a bag for", price)
    }
}
In main: two buys at once
let shop = Shop()
async let first: Void = shop.buy()
async let second: Void = shop.buy()
_ = await (first, second)
let sold = await shop.sold
let bags = await shop.bags
print("sold:", sold, "- bags left:", bags)

It prints

Console
sold a bag for 5
sold a bag for 5
sold: 2 - bags left: -1
the Shop actorbuy #1buy #2#1 sees 1 bag#2 sees 1 bag#1 sells: 0 left#2 sells: −1freefreefreecheck the stockawait askPrice()sell: bags 0check the stockawait askPrice()sell: bags −1each await hands the actor back; the second buy checks the stock while the first is still waiting for the pricetime →
Two buys on one actor. The first checks the stock and awaits the price; while it waits the actor is free, so the second checks the stock, sees the same bag, and awaits too. Each then sells.

Two bags sold, of one. Both buys saw a bag, because the second checked the stock while the first was waiting for the price, and the actor was free. Nothing was corrupted: the actor kept its promise, and every line ran one at a time. What it does not promise is that the world is as you left it on the far side of an await. The fix is to look again after the wait:

buy, with the check repeated after the await
/// Stands in for a request: it waits, then answers.
func askPrice() async -> Int {
    try? await Task.sleep(for: .milliseconds(20))
    return 5
}

/// One bag to sell. `buy` checks the stock, asks the price, and sells — with an `await` between the check and the sale.
actor Shop {
    var bags = 1
    var sold = 0

    func buy() async {
        guard bags > 0 else {
            return
        }
        let price = await askPrice()        // the actor is free while this waits
        guard bags > 0 else {               // so look again: the world may have changed
            return
        }
        bags -= 1
        sold += 1
        print("sold a bag for", price)
    }
}

It prints

Console
sold a bag for 5
sold: 1 - bags left: 0

This is reentrancy, and SE-0306 chose it on purpose, so that an actor waiting on something slow never holds up everything else that needs it. The rule to carry: what you checked before an await is a fact about before the await. Check it again, do the whole change after the wait, or keep the in-flight work somewhere so that a second caller joins it instead of starting it again — all three are in lesson 38.

9 · from lesson 38

An executor of your own: when, and when not

The Actor protocol above has one requirement, unownedExecutor: the thing that runs the actor's jobs. The runtime provides one by default, on the pool. Give the actor one of your own and its jobs run there instead — here, on a serial dispatch queue:

MyActor, now with a serial queue as its executor
import Foundation

/// The same global actor, now on a serial queue of its own: its jobs run there, one at a time — and may block it.
@globalActor
actor MyActor {
    static let shared = MyActor()

    private let queue = DispatchSerialQueue(label: "my.queue")

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        queue.asUnownedSerialExecutor()
    }
}

func queueName() -> String {
    String(cString: __dispatch_queue_get_label(nil))
}

@MyActor
func onMyActor() async {
    print("@MyActor now runs on", queueName())
}
In main
await onMyActor()

It prints

Console
@MyActor now runs on my.queue

The jobs now run on that queue — still one at a time, still with every await handing the actor back, exactly as before; a queue does not make an actor any less reentrant. What changes is which threads do the work, and that matters in one case: code that blocks. A Data(contentsOf:), a lock held for a while, a C library that sleeps — these hold their thread until they are done, and the pool's thread model counts on nobody doing that. Lesson 38 measures it: thirty saves that block for a tenth of a second each, on an actor on the pool and on an actor with a queue of its own, against other work that wants a turn every few milliseconds:

Thirty blocking saves, on the pool and on a queue of their own

Console
on the pool:      30 saves took 312 ms; other work got 1 of the 31 turns it wanted
on its own queue: 30 saves took 105 ms; other work got 9 of the 10 turns it wanted

On the pool, other work got almost nothing: the pool has one thread per core and never adds one when a thread is blocked, so the blocked saves took the whole pool with them. On a queue of its own, the actor blocks its queue's thread and nobody else's. That is when to reach for an executor of your own: an actor whose work must block, and which must not take the pool down with it. When you do, the queue is still not a thread — a serial dispatch queue runs its blocks one at a time on whatever thread GCD gives it — and a queue that blocks is slow for its own callers; a usleep in a save is a usleep for everyone waiting on that actor.

And when not: every other time. A hop onto a queue actor goes through GCD, and a hop between two actors on the pool does not. The lesson measured the difference:

A hop between two actors, on the pool and on queues of their own

Console
two plain actors:            144 ns a turn
two on queues of their own:  5367 ns a turn

Thirty-odd times slower, for an actor that does nothing but arithmetic. The same lesson measured a global actor against four plain ones, four pieces of work that could have run side by side, and found the one actor several times slower for the same reason that makes it right for the weather cache: one actor is one line, and everything in it waits for everything else:

The same work on four actors, and on one global actor

Console
four actors of their own: 0.1 sec
four on one global actor: 0.4 sec
You wantReach forBecause
One copy of some state, shared by everyone, reached with awaitReach foran actorBecauseone job at a time, on the pool, and a cheap hop
The same, named by an attribute you can put on types and functionsReach fora @globalActorBecauseone instance for the whole program, as @MainActor is
An actor whose work blocks its threadReach forthe same, with unownedExecutor on a DispatchSerialQueueBecauseit blocks a queue of its own, never the pool
An actor whose jobs need one particular threadReach fora SerialExecutor of your own, with a thread of its ownBecausea queue is not a thread; SE-0392 shows the shape
To block on the poolReach fornothingBecausethe pool never grows; wait with await instead

That is the whole arrangement, and it comes down to the first section. An await ends a function and makes a new one; a task is frames and an address, not a thread; a continuation is the way back in; for await and AsyncStream are the loop and the wait with names; and an actor is a promise that its jobs take turns, kept by running each piece when its turn comes. Every piece of it was compiled and run for this page, and every number on it is what the run printed.

Further reading