The beginner course taught async, await, Task and actors. In real apps you also need to run a *variable* number of jobs at once, stop work that nobody wants any more, turn callbacks and events into a sequence you can loop over, and satisfy the compiler's checks for data races. These are the topics here, and they are all common in senior interviews.
TaskGroup: a variable number of tasks
async let is fine when you know how many calls there are. When you have a list, such as one request per account, use a task group. You add one child task per item. Child tasks run at the same time, and you collect results as they finish.
func fetchTotal(_ id: Int) async throws -> Double {
try await Task.sleep(for: .milliseconds(100))
return Double(id) * 10
}
func totals(for ids: [Int]) async throws -> [Int: Double] {
try await withThrowingTaskGroup(of: (Int, Double).self) { group in
for id in ids {
group.addTask { (id, try await fetchTotal(id)) }
}
var result: [Int: Double] = [:]
for try await (id, total) in group {
result[id] = total
}
return result
}
}
- Results arrive in the order that tasks finish, and not the order you added them, so key them by an id, as above.
- If one child throws, the group is cancelled and the error comes out of the
for try awaitloop. - Do not start thousands of network calls at the same moment. Add a limit by starting a few tasks first and adding the next one each time one finishes.
Cancellation is cooperative
Cancelling a task does not stop it by force. It sets a flag, and the task must look at the flag and stop itself. Most Apple async APIs, such as Task.sleep and URLSession, check the flag for you and throw CancellationError. In your own long loops, check on purpose.
func processAll(_ items: [Int]) async throws -> Int {
var done = 0
for item in items {
try Task.checkCancellation() // throws if the task was cancelled
_ = item * 2 // pretend this is real work
done += 1
}
return done
}
let task = Task { try await processAll(Array(0..<1_000_000)) }
task.cancel()
A very common real use is search as you type. Each key press cancels the previous search task and starts a new one after a short pause, so only the last word is searched.
struct SearchView: View {
@State private var query = ""
@State private var results: [String] = []
@State private var searchTask: Task<Void, Never>?
var body: some View {
List(results, id: \.self) { Text($0) }
.searchable(text: $query)
.onChange(of: query) { _, newValue in
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
results = await search(newValue)
}
}
}
func search(_ text: String) async -> [String] {
["\(text) one", "\(text) two"] // replace with a real call
}
}
AsyncStream: events as a sequence
An AsyncSequence is like an array whose items arrive over time. You loop over it with for await. AsyncStream lets you make one from your own source, such as a timer, a location update or an old delegate callback. You yield values into it, and finish when there are no more.
func ticks(every seconds: Double) -> AsyncStream<Int> {
AsyncStream { continuation in
let task = Task {
var count = 0
while !Task.isCancelled {
continuation.yield(count)
count += 1
try? await Task.sleep(for: .seconds(seconds))
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
// somewhere async:
for await tick in ticks(every: 1) {
print("tick", tick)
if tick == 3 { break } // leaving the loop ends the stream
}
The onTermination closure cleans up when the reader stops, so the timer task does not run forever. The same idea turns a callback-based API, such as a location manager delegate, into a clean stream.
Sendable and strict concurrency
A type is Sendable when it is safe to pass between tasks, because nothing can be changed by two places at once. Value types made of Sendable parts, such as simple structs and enums, are Sendable. Actors are Sendable. A class is only Sendable if it is final and its stored properties are immutable (or protected). The compiler uses this to find data races *before* the app runs.
struct Receipt: Sendable { // a struct of simple values
let id: UUID
let total: Double
}
final class AppConfig: Sendable { // final class, immutable properties
let baseURL: URL
init(baseURL: URL) { self.baseURL = baseURL }
}
Actors: two more things to know
- Reentrancy: when an actor method hits an
await, other calls can run on the same actor before it continues. So state read before theawaitmay have changed after it. Check again after theawaitwhen it matters. - Global actors:
@MainActoris the one you know. You can make your own, but you rarely need to. - `nonisolated`: marks an actor member that does not touch the actor's data, so it can be called without
await.
Common mistakes
- Starting a
Taskand forgetting to keep a reference, so it cannot be cancelled later. - Long loops that never check for cancellation.
- Reading actor state before an
awaitand trusting it after. - Using
@unchecked Sendableto make a warning go away.
Your task
- Write
totals(for:)and call it with ten ids. Print the dictionary. - Make the search-as-you-type view, replace
searchwith a function that waits for 200 ms, and watch earlier searches get cancelled. - Write
ticks(every:)and read it withfor await. Break after five ticks and confirm the timer task stops. - Make a struct and a final class that are
Sendable, and write what would break if the class had avarproperty.
How to check it worked
- The group returns all ids even though tasks finish in a different order.
- Typing quickly in the search box runs only the last search to the end.
- You can explain what cooperative cancellation means and name one API that checks for it for you.