Swift Concurrency
async/await, Task, actors, structured concurrency, AsyncSequence
- 01
Explain async/await in Swift. How does it differ from completion-handler callbacks?
MediumAn
asyncfunction is one that can suspend in the middle and resume later, andawaitmarks every point where that is allowed to happen.// Old style — completion handlers func loadUser(id: String, completion: @escaping (Result<User, Error>) -> Void) { /* ... */ } // New style — async/await func loadUser(id: String) async throws -> User { let url = URL(string: "https://api.example.com/users/\(id)")! … - 02
What is structured concurrency? How does Task work?
MediumStructured concurrency means every piece of concurrent work has a scope: a child task cannot outlive the function that started it, cancellation flows down the tree automatically, and the parent cannot return until all its children have finished.
// Concurrent with async let func loadDashboard() async throws -> Dashboard { async let posts = api.posts() async let friends = api.friends() async let news = api.news() // Three requests fly in parallel; the await collects all three (all-or-throw) … - 03
What problem do actors solve in Swift, and what is actor reentrancy?
MediumAn actor is a reference type whose mutable state is isolated: only one task executes inside it at a time, so data races on that state are impossible by construction rather than by discipline.
actor Counter { private var value: Int = 0 func increment() { value += 1 } func current() -> Int { value } nonisolated let id = UUID() // immutable — no isolation needed nonisolated func describe() -> String { "Counter(\(id))" } … - 04
What is AsyncSequence and AsyncStream?
MediumAsyncSequence is the asynchronous counterpart of Sequence: elements arrive over time and you consume them with
for await element in sequence, orfor try awaitwhen the sequence can fail.// Consume any AsyncSequence for try await line in url.lines { // URL.AsyncBytes print(line) } for await note in NotificationCenter.default.notifications(named: .myEvent) { … - 05
What is Sendable, and how does Swift enforce data-race safety?
HardSendableis a marker protocol that says a value is safe to hand from one concurrency domain to another, and the compiler checks it at every boundary crossing.// Sendable struct — inferred, nothing to write struct User: Sendable { let id: Int; let name: String } // Final class with let-only stored properties — never inferred: the // conformance is written out, and the compiler checks it final class Config: Sendable { … - 06
How do you cancel async work in Swift?
MediumCancellation in Swift Concurrency is cooperative — the task you cancel is asked to stop, and the code inside has to honor the request.
@MainActor @Observable final class SearchModel { var results: [SearchHit] = [] private var task: Task<Void, Never>? … - 07
What does
@MainActoractually guarantee, and how do you deliberately get off it?Medium@MainActoris a global actor that pins the annotated code to the main thread, and the compiler is what checks you obeyed it rather than a runtime assertion.@MainActor @Observable final class ProfileModel { private(set) var name = "" private(set) var isLoading = false … - 08
How do you wrap a legacy completion-handler or delegate API in async/await?
MediumA one-shot callback becomes a continuation, and a callback that fires many times becomes an AsyncStream.
// One-shot callback -> continuation. Resume exactly once, on every path. func currentLocation() async throws -> CLLocation { try await withCheckedThrowingContinuation { cont in provider.requestOnce { result in cont.resume(with: result) // twice traps; never leaves the caller suspended forever } … - 09
Why is calling
DispatchSemaphore.wait()inside an async function a bug?HardSwift Concurrency runs on a cooperative pool with roughly one thread per core and assumes every task makes forward progress, so a blocked thread is a thread nobody else can ever use.
// Blocks a cooperative-pool thread. One of these per core and the app is wedged. func badBridge() -> Data { let sema = DispatchSemaphore(value: 0) var result = Data() Task { result = (try? await api.download()) ?? Data(); sema.signal() } sema.wait() // the Task above may never get a thread to run on … - 10
How do you unit-test an async function, an actor, and an AsyncStream?
MediumThe test function itself is marked
async, so async code is tested with a plainawaitand none of the expectation-and-callback ceremony the old APIs needed.import Testing @Test func loadsProfile() async throws { let sut = ProfileLoader(api: StubAPI(result: .success(.fixture))) let profile = try await sut.load(id: "42") // the test is async: no expectations needed #expect(profile.name == "Ada") … - 11
Where does a
nonisolated asyncfunction actually run, and what does@concurrentchange?HardA
nonisolated asyncfunction now runs on whatever executor its caller was already using instead of always hopping to the global pool, and@concurrentis how you ask for the old behaviour explicitly.@MainActor final class FeedModel { var rows: [Row] = [] func reload() async throws { let data = try await api.fetch() // suspends, resumes back on the main actor … - 12
You need to download 500 images but no more than 5 at a time — how do you cap concurrency in a task group?
MediumA task group starts every child the moment you add it, so limiting parallelism means seeding a window of children and adding the next one only as each result comes back.
func downloadAll(_ urls: [URL], limit: Int = 5) async throws -> [URL: Data] { try await withThrowingTaskGroup(of: (URL, Data).self) { group in var pending = urls.makeIterator() var results: [URL: Data] = [:] // Seed the window … - 13
Two
async letrequests run side by side and the first one you await throws — what happens to the other, and what if you never await it at all?MediumBoth are child tasks of the enclosing scope, so the one you never reach is cancelled and implicitly awaited as the scope unwinds — nothing keeps running behind you.
// All three start at their declarations and run concurrently func loadDashboard() async throws -> Dashboard { async let posts = api.posts() async let friends = api.friends() async let news = api.news() // Awaited left to right: if posts throws here, friends and news are … - 14
The upload endpoint occasionally hangs forever and the spinner never stops — how do you bound an async call to five seconds when the API has no timeout parameter?
MediumYou race the work against a sleeping child task inside a group and take whichever finishes first — the standard library still ships no timeout operator, so this helper is the idiom.
struct TimedOut: Error {} func withTimeout<T: Sendable>( _ duration: Duration, clock: any Clock<Duration> = ContinuousClock(), operation: @escaping @Sendable () async throws -> T … - 15
Writes to your disk cache must never overlap, they are spread across three different types, and none of it belongs on the main thread — how do you express that?
MediumDeclare a custom global actor and annotate those three types with it: a global actor is one shared serialization domain that any number of declarations, in any number of files, can opt into.
@globalActor actor DiskActor { static let shared = DiskActor() } // Three types, one serialization domain — they never run concurrently … - 16
A 30-second poll built on
Task.sleepdrifts by minutes over an hour and does nothing at all while the phone is locked — what is wrong with it?MediumTask.sleep(for:)measures a duration from the moment it is called, so every iteration adds the request time and the wake-up latency to your period, and a suspended process does not run at all — sleeping is not scheduling.// Drifts: each period is 30s PLUS however long the fetch and the wake-up took func pollBad() async throws { while !Task.isCancelled { try await refresh() try await Task.sleep(for: .seconds(30)) } … - 17
You turn your image cache into an
actorand it stops conforming to theCacheprotocol it has always satisfied — why, and what are your options?HardThe protocol's requirements are synchronous and nonisolated, and an actor-isolated method cannot witness one: anybody holding
any Cachecould call it from any domain, which is exactly what isolation forbids.protocol Cache { func value(for key: String) -> Data? // synchronous, nonisolated var name: String { get } } actor ImageCache: Cache { // error: actor-isolated instance method … - 18
Every log line inside one request must carry the same trace ID ten async layers deep, and you refuse to thread a parameter through all of them — what do you reach for?
HardA
@TaskLocalvalue: a static binding visible to the current task and to every task created inside its scope, without appearing in a single signature.enum RequestContext { @TaskLocal static var traceID: String? @TaskLocal static var user: User? } func log(_ message: String) { … - 19
Pull-to-refresh runs its fetch in
Task.detached(priority: .background)and takes ten seconds, while the same call from a button is instant — what is going on?Hard.backgroundis a real quality of service, not a label: the system schedules that work behind everything else and defers it under Low Power Mode and thermal pressure, and a detached task cannot inherit the user-initiated priority it should have had.@MainActor final class FeedModel { private(set) var items: [Item] = [] // Wrong: detached inherits nothing, and .background is discretionary — // the user is staring at a spinner while the OS treats this as chores … - 20
Warming 500 entries through an
actorcache measures slower than the same loop behind a plain lock — why, and what would you change?HardEvery call from outside an actor is a suspension and an executor switch, so a loop of 500 tiny calls pays 500 crossings, while an uncontended lock is a couple of atomic operations.
actor ImageCache { private var storage: [String: Data] = [:] func insert(_ data: Data, for key: String) { storage[key] = data } func insert(_ entries: [String: Data]) { storage.merge(entries) { _, new in new } …