Module 3

Concurrency

This is the module to read carefully. It's the largest genuine difference from what you know, it's where your first build errors will come from, and it's the part of Swift that most rewards understanding rather than pattern-matching.

The problem being solved

In JavaScript you have one thread. Concurrency bugs are mostly ordering bugs, and they're annoying but bounded. Dart is similar — isolates don't share memory, so races are structurally prevented.

Swift is genuinely multi-threaded with shared memory. Two threads can write the same variable at the same time, which produces corruption that's non-deterministic and appalling to debug. Every mature language deals with this somehow; Swift's answer is to make the compiler prove your code is free of data races. That's why the rules feel strict — they're load-bearing.

async / await

Mechanically identical to JavaScript. A function marked async can suspend; await marks the suspension point.

func prepare() async throws {
    let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber])
    try await request?.downloadAndInstall()
}

The important mental model: await does not block a thread. It suspends the function and hands the thread back to the system to do other work. When the awaited operation completes, the function resumes — possibly on a completely different thread. That last part matters and is the source of most confusion.

Note the ordering of keywords: try await, always in that order. And async throws in the declaration. Swift is picky about this and the error message when you get it backwards is unhelpful.

Task and structured concurrency

You cannot call an async function from a synchronous one. Task { } is the bridge — it creates a new unit of asynchronous work and returns immediately.

// Sync context (a button action, a callback)
hotkey.onPress = {
    Task { await controller.beginListening() }
}

Tasks form a tree. A task created inside another task is a child, and this gives you two properties for free:

  • Cancellation propagates. Cancel a parent and every descendant is marked cancelled.
  • Parents wait for children. Structured tasks can't outlive their scope, so you can't leak work by accident.

Cancellation is cooperative — a cancelled task isn't killed, it's flagged. Long-running loops should check:

for item in hugeList {
    try Task.checkCancellation()   // throws CancellationError if cancelled
    process(item)
}

Most Apple async APIs check cancellation for you, which is why resultsTask?.cancel() in Spoke's Transcriber is enough to shut the results loop down cleanly.

Actors

An actor is a reference type that guarantees only one task touches its mutable state at a time. It's a class with a built-in lock that the compiler knows about and enforces.

actor Transcriber {
    private var finalizedText = ""

    func append(_ text: String) {
        finalizedText += text     // safe: only one caller at a time
    }
}

// From outside, every access must be awaited:
await transcriber.append("hello")

The await isn't because the work is slow — it's because your task may have to queue for access. Inside the actor, methods call each other without await, since they're already holding the lock. This is called actor isolation.

Spoke uses an actor for Transcriber because audio buffers arrive on a real-time audio thread while transcription results arrive on some other thread, and both mutate the same text state. Without the actor that's a textbook data race. With it, it's a compile-time guarantee.

Trap · actor reentrancy

Actors guarantee no simultaneous access, not that a method runs atomically start to finish. At every await inside an actor method, the actor can service another call. So state you read before an await may have changed after it. Re-read state after awaiting rather than assuming it's stable. This one catches experienced developers.

@MainActor

All UI work must happen on the main thread. Every platform has this rule; Swift is unusual in enforcing it at compile time.

@MainActor is a global actor — a single actor representing the main thread. Marking something with it guarantees it runs there.

@MainActor
final class OverlayController {
    func show() { /* guaranteed main thread */ }
}

When you're on a background thread and need the main one:

audio.start { buffer in
    // This closure runs on the real-time audio thread.
    Task { @MainActor in
        overlay.pushLevel(level)   // now safely on main
    }
}

The error message you'll meet most often is some variation of "call to main actor-isolated method in a synchronous nonisolated context." Translation: you're on the wrong thread; wrap it in Task { @MainActor in ... }. Once you can read that sentence fluently, this stops being a problem.

Sendable

Sendable is a protocol meaning "safe to pass between threads." Value types made of other Sendable things are automatically Sendable. Classes generally aren't, unless they're immutable or internally synchronised.

The compiler checks this at every concurrency boundary. When you see an error about a type not conforming to Sendable, it's telling you that you're about to hand mutable shared state to another thread.

Trap · AVAudioPCMBuffer is not Sendable

This will be your first Swift 6 error in Spoke. Audio buffers are Objective-C objects with mutable internals, so the compiler refuses to let them cross threads — even though our usage is fine, because we hand each buffer off and never touch it again.

While learning, set Swift Language Version → 5 in build settings. You keep async/await and actors; you drop the hard errors. Revisit in Swift 6 mode later, when the concepts are automatic and you can solve it properly (usually by copying the samples into a Sendable value type at the boundary).

AsyncSequence and AsyncStream

An AsyncSequence is a sequence whose elements arrive over time — conceptually an async iterator, or an Observable if you come from Rx.

for try await result in transcriber.results {
    let text = String(result.text.characters)
    apply(text: text, isFinal: result.isFinal)
}

That loop suspends at the top of each iteration until the next result exists. It reads like a normal for loop and handles backpressure, cancellation, and errors correctly. It's one of the nicest things in modern Swift.

AsyncStream is how you build one from a callback-based source — which is exactly what we need, since audio arrives via a C-style callback:

let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()

// Producer side: push values in from anywhere
continuation.yield(AnalyzerInput(buffer: converted))

// Consumer side: hand the stream to the speech analyzer
try await analyzer.start(inputSequence: stream)

// When dictation ends, close the stream
continuation.finish()

This adapter pattern — callback source in, AsyncSequence out — is worth memorising. You'll use it constantly when bridging older Apple APIs into modern Swift.

Spoke never sends your voice, your text, or anything else off this Mac. Not a promise in a privacy policy — a property of the binary.