Tasks, actors and the main actor
Think of a busy kitchen:
- Tasks are cooks working at the same time.
awaitis a cook waiting for the oven: while one waits, the others keep working, so nothing is blocked. - An actor is a station with one cook at a time, like the till. Anyone can ask the till for something, but requests are handled one by one, so two people can never change the same order at once. That’s how actors protect their data.
- The main actor is the waiter who talks to customers. Only they may change what the customer sees, which is why all UI code runs on the main actor.
- Sendable is the label on a dish that says “safe to hand to another station”: a copy that nobody else will change, or something that protects itself.
Swift’s concurrency model is built on isolation domains: each actor (including the @MainActor global actor) owns its mutable state, and code outside it must await to interact, which serializes access without locks. Values crossing domains must be Sendable, or be provably not used again by the sender (region-based isolation, sending). In the Swift 6 language mode, violations are compile-time errors, which eliminates data races in checked code.
The parts that still bite are semantic rather than syntactic: actor reentrancy (state can change across any await), unstructured tasks that outlive their callers or swallow errors, @unchecked Sendable promises that nothing verifies, and choosing isolation deliberately in Swift 6.2, where app targets can default to @MainActor. All in the Detailed version.
actor ThumbnailCache {
private var thumbnails: [URL: Data] = [:]
func thumbnail(for url: URL) -> Data? {
thumbnails[url]
}
func store(_ data: Data, for url: URL) {
thumbnails[url] = data
}
}
func loadThumbnails(_ urls: [URL], cache: ThumbnailCache) async {
await withTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask { // runs in parallel with the others
if await cache.thumbnail(for: url) != nil { return }
guard let data = try? await URLSession.shared.data(from: url).0 else { return }
await cache.store(data, for: url) // one at a time, inside the actor
}
}
}
}What to notice:
actorinstead ofclass. The dictionary can only be touched by code inside the actor, and the actor runs that code one call at a time.await cache.store(...)from outside: calling into an actor may have to wait for its turn, so it’s alwaysawait.withTaskGroupstarts one child task per URL. They run in parallel, and the group waits until all of them finish beforeloadThumbnailsreturns.
The race the compiler catches
Here’s the same cache as a plain class. In the Swift 5 language mode it compiles, and it crashes occasionally. Which line is the problem?
Tap the line with the bug.
final class ThumbnailCache {
private var thumbnails: [URL: Data] = [:]
func store(_ data: Data, for url: URL) { thumbnails[url] = data }
}
let cache = ThumbnailCache()
await withTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask {
if let data = try? await URLSession.shared.data(from: url).0 {
cache.store(data, for: url)
}
}
}
}Hint: The child tasks run at the same time on different threads. How many of them may be inside store at once?
ThumbnailCache is a class with mutable state and no protection, shared by every child task. Two tasks can write to the dictionary at the same moment, and Dictionary isn’t safe for that: the result is corrupted memory and a crash somewhere later, rarely and unpredictably.
In the Swift 6 language mode this doesn’t compile: the class isn’t Sendable, so capturing it in addTask (which runs concurrently) is an error. The fix is the one from the start of the tutorial: make it an actor, and call it with await:
actor ThumbnailCache {
private var thumbnails: [URL: Data] = [:]
func store(_ data: Data, for url: URL) { thumbnails[url] = data }
}
// in the task:
await cache.store(data, for: url)Turn on the Swift 6 language mode (or “Strict Concurrency Checking: Complete” in Swift 5 mode) in new projects. Every warning it shows is a potential crash like this one.
Parallel, not one after another
await waits for one thing at a time. When two loads don’t depend on each other, start both first and wait for both together with async let:
Predict the output
Each load takes one second. How many seconds does the measured block take?
func load(_ name: String) async -> String {
try? await Task.sleep(for: .seconds(1))
return name
}
let elapsed = await ContinuousClock().measure {
async let profile = load("profile")
async let orders = load("orders")
_ = await (profile, orders)
}
print(elapsed.components.seconds)async let starts each load immediately as a child task, and await (profile, orders) waits for both. They run at the same time, so the total is about one second. With two plain let x = await load(...) lines, the second load would only start after the first finished: two seconds.
Use async let for a fixed number of independent loads (a screen that needs a profile and its orders), and a task group when the number of tasks depends on the data (a thumbnail per URL).
Reentrancy: actors aren’t transactions
An actor runs one piece of its code at a time, but every await inside an actor method is a suspension point where other calls can run. State you read before an await may be different after it:
Predict the output
Two calls to incrementSlowly run concurrently. What gets printed?
actor Counter {
private(set) var value = 0
func incrementSlowly() async {
let current = value
try? await Task.sleep(for: .milliseconds(100))
value = current + 1
}
}
let counter = Counter()
async let first: Void = counter.incrementSlowly()
async let second: Void = counter.incrementSlowly()
_ = await (first, second)
print(await counter.value)Both calls read value (0) before suspending at Task.sleep. While the first is suspended, the actor runs the second, which also reads 0. Both then write 0 + 1. No data race (each access is isolated), and still a lost update: the classic reentrancy bug. Don't hold state across await: re-read after suspending, do the read-modify-write synchronously (value += 1 with no await in between), or track in-flight work explicitly.
The same shape appears as duplicate work: an image cache checks images[url], misses, awaits a download, and a second request for the same URL does the same in the meantime. The fix (the exercise below) is to store the in-flight task before suspending.
Sendable in practice
- Value types whose stored properties are all
Sendableare implicitlySendable(for non-publictypes). This is why value-type models cross actors for free. - Classes are
Sendableonly iffinalwith immutable (let)Sendableproperties, or isolated to a global actor (@MainActorclasses areSendable, because all access goes through the main actor). sendingand region isolation let you pass a non-Sendablevalue to another domain when the compiler can prove the sender never touches it again, which removes many false positives from Swift 5.10.@unchecked Sendableis a promise the compiler can’t verify: valid only with internal synchronization (aMutexfrom the Synchronization module on iOS 18+, orOSAllocatedUnfairLock), and worth a comment explaining why.nonisolated(unsafe)for globals is the same kind of promise; prefer making the global aletof aSendabletype.
A token store that silences the compiler
Tap the line with the bug.
final class TokenStore: @unchecked Sendable {
private var token: String?
func update(_ newToken: String) { token = newToken }
func current() -> String? { token }
}
// Used from the API client (many concurrent requests) and the refresh task:
let tokens = TokenStore()Hint: What does @unchecked Sendable actually check?
Nothing. @unchecked Sendable tells the compiler “trust me, this is thread-safe”, and the class isn’t: concurrent requests read token while the refresh task writes it. A String read during a write can observe a torn value and crash, and it will do so only under load, after a token expiry. The annotation was probably added to make a Swift 6 error go away, which turns a compile-time error into a production crash.
Either make it an actor (if callers can await), or protect the state with a lock the compiler understands:
import Synchronization
final class TokenStore: Sendable { // checked: Mutex is Sendable
private let token = Mutex<String?>(nil)
func update(_ newToken: String) { token.withLock { $0 = newToken } }
func current() -> String? { token.withLock { $0 } }
}Mutex (iOS 18 and macOS 15) makes the class genuinely Sendable with no @unchecked, and suits tiny synchronous critical sections like this. On older OS versions, OSAllocatedUnfairLock plays the same role. Search the codebase for @unchecked Sendable and nonisolated(unsafe): each one should have a lock or a comment explaining why it’s safe.
Deep dives
Deep diveGlobal actors beyond the main actorInterview favorite
@MainActor is a global actor: an annotation that puts functions, properties and whole types in one shared isolation domain, run by a single actor instance. You can define your own:
@globalActor actor DatabaseActor { static let shared = DatabaseActor() }
Then @DatabaseActor on a class or function confines it to that actor, so everything that touches the database is serialized and checked by the compiler, without passing an actor instance around. Use them sparingly: every call across the boundary is an await, and a few well-chosen domains are easier to reason about than many.
- Actors serialize access, but every
awaitinside an actor method is a suspension point: state read before it can change after it (lost updates, duplicate work). Store in-flight tasks before suspending. Sendable: value types withSendablemembers, final classes with immutable state, global-actor classes;sendingand region isolation remove false positives.@unchecked Sendableandnonisolated(unsafe)are unverified promises: only with a lock and a comment.
Check this part
What does an actor guarantee about its state?
Two independent loads each take one second. How do you make them take about one second together?
An actor method reads a value, awaits, then writes value + 1. Two calls run concurrently. What can happen?
What does @unchecked Sendable check?