A type system for time
Swift concurrency is not a threading library with nicer syntax. It is a type system for time: the compiler tracks which values may cross between concurrent contexts and refuses the ones that would race. Once that clicks, the errors stop looking arbitrary.
func loadProfile(id: String) async throws -> Profile {
let data = try await network.fetch("/profile/\(id)")
return try decoder.decode(Profile.self, from: data)
}
await is a suspension point: the function may pause here and give the thread back to the system,
resuming later — possibly on a different thread. It does not mean “go to the background”. A function
marked async runs wherever its caller’s context says, and the two lines above may execute on two
different threads with any amount of time between them.
That gap is the thing to hold in your head. Any state you read before an await may have changed
after it.
Child tasks belong to a parent, and the parent cannot finish before its children do. That guarantee is what makes cancellation and error propagation work.
func loadDashboard() async throws -> Dashboard {
async let profile = loadProfile(id: currentUserID)
async let feed = loadFeed(page: 0)
return try await Dashboard(profile: profile, feed: feed)
}
Both loads start immediately and run concurrently; await collects them. If either throws, the other
is cancelled automatically. For a dynamic number of children, use a task group:
func loadAll(ids: [String]) async throws -> [Profile] {
try await withThrowingTaskGroup(of: Profile.self) { group in
for id in ids {
group.addTask { try await loadProfile(id: id) }
}
return try await group.reduce(into: []) { $0.append($1) }
}
}
Warning
Task { } creates an unstructured task: it does not inherit cancellation from the surrounding
scope and outlives the function that started it. It is the right tool at the boundary between
synchronous and async code, and the wrong one everywhere else. Reach for async let or a group
first.
Cancelling a task sets a flag. Nothing is interrupted; code that never checks keeps running to completion.
func process(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation() // throws CancellationError if cancelled
await handle(item)
}
}
Standard-library async functions check for you, so a task blocked in URLSession or Task.sleep
does react. Your own loops do not, unless you ask.
An actor protects its state by serialising access to it:
actor ImageCache {
private var storage: [URL: Image] = [:]
func image(for url: URL) -> Image? { storage[url] }
func store(_ image: Image, for url: URL) {
storage[url] = image
}
}
Every call from outside is awaited, and only one runs at a time. No locks to forget.
The subtlety that catches everyone is actor reentrancy. An actor does not hold its lock across an
await; it lets another call in while the first is suspended:
actor Downloader {
private var cache: [URL: Data] = [:]
func data(for url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
let downloaded = try await fetch(url) // ← another call can run here
cache[url] = downloaded
return downloaded
}
}
Two concurrent calls for the same URL both miss the cache and both download. The state is not
corrupted — that is what the actor guarantees — but the logic is still wrong. The fix is to store the
in-flight Task rather than only the result:
actor Downloader {
private var tasks: [URL: Task<Data, Error>] = [:]
func data(for url: URL) async throws -> Data {
if let existing = tasks[url] { return try await existing.value }
let task = Task { try await fetch(url) }
tasks[url] = task
return try await task.value
}
}
Sendable marks a type as safe to hand between concurrency domains. Value types of Sendable parts
conform automatically; classes do not, unless they are immutable or you take responsibility with
@unchecked Sendable and your own locking.
struct Settings: Sendable { let theme: Theme } // automatic
final class Logger: @unchecked Sendable { // your promise, your lock
private let lock = NSLock()
private var lines: [String] = []
}
Under Swift 6 language mode these are errors, not warnings. The migration is tedious once and then permanent: the compiler has proven the absence of a whole class of bug that used to be found only by users.
UI work belongs on the main thread, and @MainActor states that in the type system:
@MainActor
final class ProfileModel {
private(set) var profile: Profile?
func load(id: String) async throws {
profile = try await loadProfile(id: id) // hops back to main automatically
}
}
The await suspends, the network work happens elsewhere, and the assignment resumes on the main
actor. No DispatchQueue.main.async, and no way to forget it — calling a @MainActor method from
another context is a compile error unless you await it.