Concurrency and Asynchronous ProgrammingIntroduction and ContextMobile applications routinely perform work that may take an unpredictable amount of time: downloading data, reading from a database, decoding images, processing files, or communicating with sensors. If this work runs on the main thread, the user interface can stop responding. A responsive application separates time-consuming work from interface updates and coordinates the results safely. Swift provides a modern concurrency model based on async/await, tasks, structured concurrency, actors, global actors such as MainActor, cancellation, continuations, and the Sendable type system. These features do not make every operation automatically parallel, nor do they eliminate all design errors. They provide language-supported ways to express when work may suspend, how child work is organized, and which data may safely cross concurrency boundaries. This chapter develops the concepts needed to implement responsive iOS programs while recognizing race conditions and applying structured-concurrency principles. Learning ObjectivesBy the end of this chapter, you should be able to: - explain the difference between synchronous, asynchronous, and concurrent execution;
- write and call
async functions using await; - create and manage
Task values; - use task groups for dynamic child operations;
- protect shared mutable state with actors;
- update user-interface state on
MainActor; - design cooperative cancellation;
- bridge callback-based APIs with continuations;
- explain
Sendable and data-isolation rules; - identify race conditions and apply structured-concurrency practices.
1. Synchronous, Asynchronous, and Concurrent WorkA synchronous function completes before returning control to its caller. If it performs a network request synchronously, the caller may be blocked for the entire request. An asynchronous function can suspend while waiting and allow other work to proceed. Suspension is not the same as creating a new thread: an asynchronous task may suspend and later resume on a different thread. Concurrency means that multiple tasks can make progress during overlapping periods. Parallelism is a stronger condition in which operations execute simultaneously, usually on different processor cores. Swift concurrency supports concurrency and may use parallel execution, but programmers should reason primarily about isolation, suspension, and correctness rather than assume a particular thread. Consider this function: func loadProfile() async throws -> Profile {
let data = try await URLSession.shared.data(from: profileURL).0
return try JSONDecoder().decode(Profile.self, from: data)
}The async marker states that the function may suspend. The throws marker states that it may fail. The caller must use await at a suspension point and handle the error: Task {
do {
let profile = try await loadProfile()
print(profile.name)
} catch {
print("Loading failed: \(error)")
}
}await makes a potential suspension visible in source code. It does not mean “start a background thread,” and it does not guarantee that the operation will suspend every time.
2. Tasks and Structured ConcurrencyA task is a unit of asynchronous work. A task may suspend, resume, complete successfully, throw an error, or be cancelled. The simplest way to start independent asynchronous work from synchronous code is Task: let task = Task {
try await loadProfile()
}
// Later, if the result is needed:
let profile = try await task.valueThe task’s value is asynchronous because obtaining the result may require waiting. If the operation throws, task.value rethrows the error. A task created with Task { ... } generally inherits relevant actor context and task-local values from its creating context. Task.detached creates an unstructured task with fewer inherited properties and should be used only when that independence is intentional. Detached tasks also place greater responsibility on the programmer for priority, actor access, task-local values, and data transfer. Structured versus unstructured workStructured concurrency organizes child tasks within a lexical scope. The parent waits for its child tasks, child errors can be propagated, and cancellation can flow through the hierarchy. This makes lifetime and failure behavior easier to reason about. Unstructured tasks, such as a stored Task property or a detached task, are useful when work must outlive the current scope, but they require explicit lifetime management. A view model should normally cancel a stored task when a new request replaces it or when the object is no longer interested in the result. final class SearchModel {
private var searchTask: Task<Void, Never>?
func search(for query: String) {
searchTask?.cancel()
searchTask = Task { [weak self] in
guard let self else { return }
do {
let results = try await self.fetchResults(query)
guard !Task.isCancelled else { return }
self.publish(results)
} catch is CancellationError {
// Expected when a newer search replaces this one.
} catch {
self.publish(error: error)
}
}
}
deinit {
searchTask?.cancel()
}
}The example uses cancellation as a cooperative signal and prevents an obsolete search from publishing results. 3. Async Functions, Suspension, and Error HandlingAn asynchronous function can call another asynchronous function only from an asynchronous context, unless a task is used to create that context. A suspension point allows the executor to run other available work. Code before and after await should therefore not assume that no other task has changed shared state. For example, this pattern is unsafe if balance is shared mutable state accessed by several tasks: balance += amount Although the statement appears indivisible, it conceptually involves reading, adding, and writing. Another task may interleave between those operations. The resulting race condition can lose an update. Errors and cancellation should be modeled explicitly. A network failure is different from cancellation, even though both may exit a function. Catch CancellationError when cancellation is expected and avoid presenting it as a user-visible failure. 4. Parallel Work with Task GroupsTask groups are appropriate when a parent must start a dynamic collection of child tasks and collect their results. The group scope guarantees that child tasks finish before the group exits. func downloadAll(_ urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self, returning: [Data].self) { group in
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
var results: [Data] = []
for try await data in group {
results.append(data)
}
return results
}
}Results arrive in completion order, not necessarily the order in which URLs were submitted. If output order matters, return an index with each result and place it into a preallocated collection, or sort the collected values afterward. A throwing task group propagates an error according to the group’s semantics and cancels remaining child work when appropriate. Code should still avoid assuming that cancellation instantly stops an operation: cancellation is cooperative, and an operation must check or observe it. A group is preferable to manually creating many independent tasks when the child work has a clear parent and bounded lifetime. It also expresses ownership: the parent owns the children and cannot leave the scope while they continue running. 5. Cancellation as Cooperative ControlCancellation is a request, not forced termination. Swift provides two common checks: try Task.checkCancellation() This throws CancellationError if the current task is cancelled. The nonthrowing alternative is: if Task.isCancelled {
return
}Long-running loops should check periodically: func process(_ items: [Item]) async throws -> [Output] {
var outputs: [Output] = []
for item in items {
try Task.checkCancellation()
outputs.append(try await transform(item))
}
return outputs
}Cancellation should be checked at meaningful boundaries, such as between items, after a suspension, or before expensive work. Cleanup belongs in defer where resources must be released. A cancellation-aware API should document whether it throws, returns partial results, or silently stops. 6. Actors and Data IsolationAn actor protects its isolated mutable state. Only the actor itself can directly access that state; external callers interact through asynchronous actor calls. This serializes access to the actor’s isolated properties and prevents many data races. actor ImageCache {
private var storage: [URL: Data] = [:]
func value(for url: URL) -> Data? {
storage[url]
}
func insert(_ data: Data, for url: URL) {
storage[url] = data
}
}
let cache = ImageCache()
let image = await cache.value(for: imageURL)Actor isolation protects access, but it does not automatically make a sequence of separate calls atomic. For example, calling contains and then insert as two separate operations may allow another task to intervene. If the operation must be indivisible, place the complete operation in one actor method: actor Counter {
private var value = 0
func increment() {
value += 1
}
func current() -> Int { value }
}Actors are not a guarantee of performance. Excessive serialization can create a bottleneck, and an actor may still perform inefficient work. Keep actor methods focused, avoid unnecessary shared state, and do not hold locks or perform blocking operations without careful design. 7. MainActor and User-Interface UpdatesUIKit and SwiftUI interface state must be updated on the main actor. MainActor is a global actor that provides isolation for main-thread-bound work. Mark a view model or method with @MainActor when its state is UI-facing: @MainActor
final class ArticlesModel {
private(set) var articles: [Article] = []
private(set) var message: String?
func refresh() async {
do {
articles = try await ArticleService().fetch()
} catch is CancellationError {
return
} catch {
message = error.localizedDescription
}
}
}An await inside a main-actor method does not block the main actor while the asynchronous operation is suspended. Once the operation produces a result, the continuation resumes in the appropriate actor context before updating isolated state. Heavy CPU work should not be placed carelessly inside a @MainActor method. First obtain data asynchronously, then perform expensive processing in a suitable non-main context, and finally return to the main actor for presentation. “Background” should be expressed through appropriate asynchronous design rather than arbitrary thread assumptions. 8. Sendable and Safe Transfer of ValuesSendable describes a value that can safely cross concurrency domains, such as from one actor to another. Value types whose stored properties are themselves Sendable are often safe to transfer. Immutable reference types can be safe if their design guarantees immutable state, but ordinary mutable classes are generally not safe to share without isolation.
struct Article: Sendable {
let id: UUID
let title: String
}A closure can be marked @Sendable when it may execute concurrently: let operation: @Sendable () async -> String = {
"Completed"
}Compiler checking becomes stricter in Swift 6 language mode. Warnings or errors about sending values, actor isolation, or non-Sendable types should be treated as design feedback, not merely suppressed. @unchecked Sendable disables compiler verification and should be reserved for types whose synchronization has been independently established and documented. A useful rule is to pass immutable value types across boundaries and keep mutable reference state behind an actor or another well-defined isolation mechanism. 9. Continuations: Bridging Callback APIsContinuations adapt older completion-handler APIs to async/await. func fetchLegacy() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
LegacyClient.fetch { result in
continuation.resume(with: result)
}
}
}A checked continuation must be resumed exactly once on every possible path. Resuming zero times leaves the task waiting indefinitely; resuming more than once is a serious programming error. If a legacy API can invoke its callback multiple times, protect the bridge or use an API design that represents a stream of events instead. Use withCheckedContinuation or withCheckedThrowingContinuation during development because runtime checks help detect misuse. Unsafe continuations may reduce checking but should not be used to hide an incorrect callback contract. Continuations bridge control flow; they do not automatically solve thread safety, cancellation, or lifetime problems. If the underlying API supports cancellation, connect the task’s cancellation to that API, commonly with withTaskCancellationHandler. 10. Recognizing Race ConditionsA race condition occurs when the result depends on the timing or interleaving of concurrent operations. It commonly appears when multiple tasks access shared mutable state and at least one access writes. Typical warning signs include: - a mutable class instance shared by unrelated tasks;
- a read-modify-write operation split across suspension points;
- callbacks that update the same property without isolation;
- UI state changed from a non-main context;
- “check then act” logic implemented as separate actor calls;
- use of
@unchecked Sendable without a synchronization argument.
A race is different from a logical ordering problem. Even if every access is synchronized, the application may display an older response after a newer response. Request tokens, cancellation, or an explicit ordering policy are needed for that problem. The main tools for prevention are value semantics, actors, MainActor, task hierarchy, immutable data transfer, and carefully designed atomic actor methods. Testing should include repeated runs, artificial delays, cancellation, and concurrent access patterns. 11. Applied Case: A Responsive Article Search ScreenBackgroundConsider an illustrative iOS news application. As the user types, it requests matching articles from a server and displays the results. Network latency varies, users may type quickly, and an earlier request may finish after a later request. ProblemA naive implementation starts one request per keystroke and assigns every response to the same array. It creates unnecessary work, can display stale results, and may update UI state from an unsafe context. AnalysisThe screen needs four policies: - UI state belongs to
MainActor. - A new query cancels the previous request.
- Cancellation is checked before publishing results.
- The service returns value-type data that can safely cross isolation boundaries.
Approach@MainActor
final class SearchViewModel {
private let service: ArticleService
private var task: Task<Void, Never>?
private(set) var results: [Article] = []
private(set) var isLoading = false
private(set) var errorMessage: String?
init(service: ArticleService) {
self.service = service
}
func updateQuery(_ query: String) {
task?.cancel()
errorMessage = nil
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
results = []
isLoading = false
return
}
isLoading = true
task = Task { [weak self] in
do {
let articles = try await self?.service.search(trimmed)
try Task.checkCancellation()
guard let articles else { return }
self?.results = articles
self?.isLoading = false
} catch is CancellationError {
// A newer query has replaced this task.
} catch {
self?.isLoading = false
self?.errorMessage = error.localizedDescription
}
}
}
deinit {
task?.cancel()
}
}In production, a debounce may be added so that the request begins only after a short period of typing inactivity. Debouncing reduces server load but does not replace cancellation or stale-result protection. The service should use URLSession’s asynchronous APIs and return Sendable models where required by the project’s concurrency settings. OutcomeThe interface remains responsive because network waiting does not block the main actor. Previous searches are cancelled, errors are distinguished from expected cancellation, and UI state is isolated. The design also makes ownership explicit: the view model owns the current task. Learning PointsConcurrency design is a set of policies, not merely syntax. Correctness depends on lifetime, cancellation, isolation, ordering, and the data exchanged between tasks. 12. Limitations, Governance, and Best PracticesConcurrency abstractions do not eliminate all performance or correctness issues. Blocking synchronous APIs can still occupy an executor thread. Large task groups can overwhelm a service or consume memory. Actors prevent data races but may serialize work. Cancellation may be ignored by poorly behaved dependencies. Continuations may misrepresent callback lifetimes. Recommended practices include: - prefer structured concurrency for work with a clear parent;
- store and cancel unstructured tasks deliberately;
- isolate UI state with
@MainActor; - use actors for shared mutable state rather than ad hoc locking;
- make transferred models immutable and
Sendable where appropriate; - check cancellation in long-running operations;
- ensure every continuation resumes exactly once;
- avoid
Task.detached unless its independence is justified; - test slow networks, repeated cancellation, empty results, and failures;
- treat concurrency diagnostics as design issues;
- never expose private data or bypass authorization merely because work is asynchronous.
From a governance perspective, asynchronous code must preserve privacy, authorization, auditability, and predictable resource use. Cancellation should not leave transactions half-completed without a recovery policy. Network tasks should respect user consent, data minimization, and secure transport requirements. Contemporary Swift DevelopmentSwift’s concurrency checking continues to become more rigorous, particularly under Swift 6 language mode. This trend moves some defects from runtime discovery to compile-time diagnosis. Developers should understand actor isolation and Sendable rather than broadly suppressing warnings. Async sequences, task groups, and observation frameworks extend the same principles to streams of values and reactive UI state. The durable principle is separation of concerns: asynchronous operations describe waiting, actors describe ownership of mutable state, MainActor describes UI isolation, and structured concurrency describes lifetime. Chapter SummarySwift concurrency enables responsive applications by allowing operations to suspend without blocking the user interface. async and await express suspension points; Task starts asynchronous work; structured task groups organize dynamic child operations. Actors isolate mutable state, while MainActor protects UI-facing state. Cancellation is cooperative and must be checked by participating code. Continuations adapt callback APIs but must resume exactly once. Sendable communicates that values can safely cross concurrency boundaries. Race conditions arise from unsynchronized shared mutable state and from stale-result ordering. Reliable designs combine structured lifetimes, explicit cancellation, actor isolation, immutable data, and deliberate testing. Key TermsAsync function: A function that may suspend while performing asynchronous work. Await: A marker identifying a point where an async operation may suspend. Task: A unit of asynchronous work with a result, lifetime, and cancellation state. Structured concurrency: A model in which child tasks have a clear lexical parent and bounded lifetime. Unstructured task: A task whose lifetime is managed explicitly outside a structured scope. Task group: A structured collection of dynamically created child tasks. Cancellation: A cooperative request for asynchronous work to stop. Actor: A reference type that serializes access to its isolated mutable state. Actor isolation: The rule that protects actor-owned state from unsynchronized access. MainActor: The global actor used for main-actor-bound UI state and operations. Sendable: A protocol indicating that a value can safely cross concurrency boundaries. Race condition: A defect whose result depends on the timing of concurrent operations. Continuation: An object used to resume an async task from callback-based code. Suspension point: A location where an async task may pause and allow other work to proceed. Global actor: An actor-like isolation domain applicable across declarations. Detached task: An independently created task that does not inherit ordinary task context. Cooperative cancellation: Cancellation that requires operations to check and honor a request. Data race: Unsynchronized concurrent access to shared memory involving at least one write. Review QuestionsShort Answer Questions- What is the difference between asynchronous execution and parallel execution?
- Why is
await not equivalent to starting a background thread? - What problem does an actor solve?
- Why should cancellation be checked in a long-running loop?
- What requirement must a checked continuation satisfy?
Long Answer Questions- Explain structured concurrency and compare it with unstructured tasks.
- Describe how
MainActor contributes to safe and responsive UI programming. - Explain
Sendable, including why mutable reference types require special care. - Discuss how task groups collect results and why completion order may differ from submission order.
- Explain how race conditions arise and identify several Swift techniques for preventing them.
Analytical / Application Questions- Design a concurrency strategy for an image gallery that downloads twenty images, supports cancellation, and updates the UI safely.
- A search screen displays an older response after a newer query. Analyze the possible causes and propose a solution using cancellation and actor isolation.
- A legacy callback sometimes calls its completion handler twice. Explain why a direct checked-continuation bridge is unsafe and propose a corrective design.
Multiple Choice Questions with AnswersQuestion 1What does the async keyword indicate about a Swift function? A. It always runs on a background thread. B. It may suspend while performing asynchronous work. C. It cannot throw errors. D. It executes all operations in parallel. Correct Answer: B. It may suspend while performing asynchronous work. Question 2Which construct is best suited to dynamically create child tasks that must finish before the enclosing scope exits? A. A task group B. A detached task C. A global variable D. A synchronous loop only Correct Answer: A. A task group Question 3What is the primary purpose of an actor? A. To guarantee that all work is parallel. B. To serialize access to its isolated mutable state. C. To convert every callback into a continuation. D. To force execution on the main thread. Correct Answer: B. To serialize access to its isolated mutable state. Question 4What does Task.checkCancellation() do when the current task is cancelled? A. It silently restarts the task. B. It creates a new child task. C. It throws CancellationError. D. It changes the task’s priority. Correct Answer: C. It throws CancellationError. Question 5Which declaration is most appropriate for UI-facing mutable state in a view model? A. @MainActor final class ViewModel B. @Detached final class ViewModel C. @Sendable final class ViewModel only D. A global mutable dictionary Correct Answer: A. @MainActor final class ViewModel Question 6What does Sendable communicate? A. That a value can safely cross concurrency boundaries under the type’s design. B. That a function never suspends. C. That an object must run on the main actor. D. That a task cannot be cancelled. Correct Answer: A. That a value can safely cross concurrency boundaries under the type’s design. Question 7Why must a checked continuation be resumed exactly once? A. To ensure the task neither waits forever nor resumes multiple times. B. To force the callback onto the main thread. C. To make the continuation automatically parallel. D. To prevent all network errors. Correct Answer: A. To ensure the task neither waits forever nor resumes multiple times. Question 8Which situation is a likely data race? A. Two tasks read an immutable value. B. One actor accesses its own isolated property. C. Two tasks modify shared mutable state without synchronization. D. A task checks cancellation before processing. Correct Answer: C. Two tasks modify shared mutable state without synchronization. Question 9What is a key limitation of cancellation in Swift concurrency? A. Cancellation forcibly terminates every thread immediately. B. Cancellation is cooperative and must be observed by the operation. C. Cancellation applies only to detached tasks. D. Cancellation prevents all errors. Correct Answer: B. Cancellation is cooperative and must be observed by the operation. Question 10Why might results collected from a task group not match submission order? A. Task groups reverse every collection. B. Results are commonly received as child tasks complete. C. The compiler sorts results alphabetically. D. Actors delete late results automatically. Correct Answer: B. Results are commonly received as child tasks complete.
|