Concurrency | Swift - Wyatt's Notes
flowchart TD
A[2_Concurrency] --> B[Key Concepts]
A --> C[Core Principles]
A --> D[Practical Applications]
B --> E[Fundamental definitions]
C --> F[Design patterns]
D --> G[Real-world usage]Overview of Swift Concurrency
Section titled “Overview of Swift Concurrency”Swift’s concurrency model provides structured concurrency — asynchronous tasks are organised in a hierarchy where the lifetime of child tasks is bounded by their parent. This prevents common bugs like dangling callbacks, forgotten cleanup, and race conditions.
Key components:
- async/await: Asynchronous function syntax
- Task: Unit of asynchronous work
- Actor: Isolated, thread-safe reference type
- Sendable: Protocol for safe data transfer across concurrency boundaries
- @MainActor: Ensures code runs on the main thread (UI updates)
async and await
Section titled “async and await”Defining Async Functions
Section titled “Defining Async Functions”func fetchUser(id: Int) async throws -> User { let url = URL(string: "https://api.example.com/users/\(id)")! let (data, response) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(User.self, from: data)}
func loadAvatar(for user: User) async throws -> Image { let (data, _) = try await URLSession.shared.data(from: user.avatarURL) return Image(uiImage: UIImage(data: data)!)}Calling Async Functions
Section titled “Calling Async Functions”func displayUserProfile(id: Int) async { do { let user = try await fetchUser(id: id) let avatar = try await loadAvatar(for: user) updateUI(with: user, avatar: avatar) } catch { print("Failed: \(error)") }}
// Calling from synchronous context: wrap in a TaskTask { await displayUserProfile(id: 1)}Sequential Async Operations
Section titled “Sequential Async Operations”func loadDashboard() async throws -> Dashboard { // These run sequentially -- each waits for the previous let user = try await fetchUser(id: currentUserId) let notifications = try await fetchNotifications(for: user) let feed = try await fetchFeed(for: user) let settings = try await fetchSettings(for: user) return Dashboard(user: user, notifications: notifications, feed: feed, settings: settings)}Creating Tasks
Section titled “Creating Tasks”// Detached task -- independent of current contextlet handle = Task.detached { let result = await heavyComputation() print("Result: \(result)") return result}
// Structured task -- inherits context, bounded lifetimelet task = Task { try await fetchUser(id: 1)}
// Cancel a tasktask.cancel()Task Cancellation
Section titled “Task Cancellation”func processAll(items: [Item]) async throws -> [Result<Item, Error>] { var results: [Result<Item, Error>] = []
for item in items { // Check for cancellation at each iteration try Task.checkCancellation()
do { let processed = try await process(item) results.append(.success(processed)) } catch { results.append(.failure(error)) } }
return results}
// Using isCancelled for non-throwing checksTask { for i in 0..<1000 { if Task.isCancelled { break } await doWork(step: i) }}Task Local Values
Section titled “Task Local Values”@TaskLocal static var currentRequestID: String?
func processRequest(id: String) async { // Inherit the request ID across async boundaries $currentRequestID.withValue(id) { performDatabaseOperation() }}Structured Concurrency
Section titled “Structured Concurrency”async let (Concurrent Bindings)
Section titled “async let (Concurrent Bindings)”async let starts a child task that runs concurrently with the current task.
func loadDashboard() async throws -> Dashboard { // All three start simultaneously async let user = fetchUser(id: currentUserId) async let notifications = fetchNotifications(for: .user(currentUserId)) async let feed = fetchFeed()
// Wait for all results return try await Dashboard( user: user, notifications: notifications, feed: feed )}TaskGroup
Section titled “TaskGroup”TaskGroup and ThrowingTaskGroup manage a dynamic number of child tasks.
func fetchAllUsers(ids: [Int]) async throws -> [User] { try await withThrowingTaskGroup(of: User.self) { group in for id in ids { group.addTask { try await fetchUser(id: id) } }
var users: [User] = [] for try await user in group { users.append(user) } return users }}
// Non-throwing versionfunc downloadImages(urls: [URL]) async -> [UIImage] { await withTaskGroup(of: UIImage?.self) { group in for url in urls { group.addTask { try? await downloadImage(from: url) } }
var images: [UIImage] = [] for await image in group { if let image { images.append(image) } } return images }}TaskGroup with Collect
Section titled “TaskGroup with Collect”func searchAll(query: String) async throws -> [SearchResult] { try await withThrowingTaskGroup(of: [SearchResult].self) { group in group.addTask { try await searchWeb(query: query) } group.addTask { try await searchLocal(query: query) } group.addTask { try await searchCache(query: query) }
var allResults: [SearchResult] = [] for try await results in group { allResults.append(contentsOf: results) } return allResults.sorted(by: { $0.relevance > $1.relevance }) }}Cancellation in TaskGroups
Section titled “Cancellation in TaskGroups”func processInParallel(items: [Item]) async throws -> [ProcessedItem] { try await withThrowingTaskGroup(of: ProcessedItem.self) { group in for item in items { group.addTask { try await process(item) } }
// If one task fails, all remaining are cancelled // Use collectAllResults() to continue on failure var results: [ProcessedItem] = [] for try await result in group { results.append(result) } return results }}Actors
Section titled “Actors”Actors are reference types that provide data isolation — access to their mutable state is synchronized automatically by the compiler.
Defining an Actor
Section titled “Defining an Actor”actor Counter { private var count = 0
func increment() { count += 1 }
func decrement() { count -= 1 }
func current() -> Int { return count }
func reset() { count = 0 }}
let counter = Counter()
Task { await counter.increment() let value = await counter.current() print("Count: \(value)")}Actor Isolation
Section titled “Actor Isolation”All access to actor state must go through await (or from within the actor itself). The compiler enforces this at compile time, eliminating data races.
actor BankAccount { let accountNumber: String private var balance: Double
init(accountNumber: String, initialBalance: Double) { self.accountNumber = accountNumber self.balance = initialBalance }
func deposit(amount: Double) { balance += amount }
func withdraw(amount: Double) throws { guard balance >= amount else { throw BankError.insufficientFunds } balance -= amount }
func transfer(amount: Double, to other: BankAccount) async throws { try withdraw(amount: amount) await other.deposit(amount: amount) }
func getBalance() -> Double { return balance }}Global Actors
Section titled “Global Actors”@globalActorstruct DatabaseActor: GlobalActor { static let shared = DatabaseActor() actor ActorType { }}
@DatabaseActorfunc saveUser(_ user: User) { // Runs on DatabaseActor's executor db.insert(user)}
@DatabaseActorvar currentUser: User?@MainActor
Section titled “@MainActor”@MainActor marks code that must run on the main thread, which is essential for UI updates.
@MainActorclass ViewModel: ObservableObject { @Published var items: [Item] = [] @Published var isLoading = false @Published var errorMessage: String?
func load() async { isLoading = true defer { isLoading = false }
do { items = try await fetchItems() } catch { errorMessage = error.localizedDescription } }}
// Mark individual functions@MainActorfunc updateLabel(_ text: String) { label.text = text}
// Call from background taskTask { let data = await heavyComputation() await updateLabel("Result: \(data)")}MainActor in SwiftUI Views
Section titled “MainActor in SwiftUI Views”struct ContentView: View { @StateObject private var viewModel = ViewModel()
var body: some View { List(viewModel.items) { item in ItemRow(item: item) } .overlay { if viewModel.isLoading { ProgressView("Loading...") } } .task { await viewModel.load() } .refreshable { await viewModel.load() } }}Sendable
Section titled “Sendable”The Sendable protocol marks types that are safe to transfer across concurrency boundaries.
Built-In Sendable Types
Section titled “Built-In Sendable Types”Value types are implicitly Sendable: Int, String, Array, Dictionary, Set, structs with only Sendable stored properties.
Reference types are not implicitly Sendable (unless they are actors or explicitly conform).
Making Types Sendable
Section titled “Making Types Sendable”// Value types with Sendable stored properties are automatically Sendablestruct Message: Sendable { let id: UUID let text: String let timestamp: Date}
// Lock-based Sendable conformance for reference typesfinal class SafeCache<Key: Hashable, Value>: Sendable { private var storage: [Key: Value] = [:] private let lock = NSLock()
func get(_ key: Key) -> Value? { lock.lock() defer { lock.unlock() } return storage[key] }
func set(_ key: Key, value: Value) { lock.lock() defer { lock.unlock() } storage[key] = value }}
// @unchecked Sendable -- opt out of compiler checking (use carefully)final class LegacyStore: @unchecked Sendable { var data: [String: Any] = [:]}@Sendable Closures
Section titled “@Sendable Closures”Closures passed to concurrency APIs must be @Sendable:
// The compiler infers @Sendable for Task and TaskGroupTask { await fetchUser(id: 1)}
// Explicit @Sendablefunc runInParallel( _ a: @Sendable @escaping () -> Void, _ b: @Sendable @escaping () -> Void) async { async let t1 = Task { a() } async let t2 = Task { b() } await t1 await t2}Non-Sendable Types in Concurrent Code
Section titled “Non-Sendable Types in Concurrent Code”// Error: class is not Sendableclass DataStore { var cache: [String: Data] = [:]}
// Fix 1: Use an actoractor SafeDataStore { var cache: [String: Data] = [:]
func store(_ data: Data, for key: String) { cache[key] = data }}
// Fix 2: Use Sendable value typestruct DataCache: Sendable { private let storage: [String: Data]
func get(_ key: String) -> Data? { storage[key] }}
// Fix 3: Mark @unchecked (only if you guarantee thread safety)final class ThreadSafeStore: @unchecked Sendable { private let lock = NSLock() private var cache: [String: Data] = [:]}AsyncStream and AsyncThrowingStream
Section titled “AsyncStream and AsyncThrowingStream”Streams provide a way to deliver values over time, similar to Combine publishers.
Creating an AsyncStream
Section titled “Creating an AsyncStream”func countdown(from n: Int) -> AsyncStream<Int> { AsyncStream { continuation in Task { for i in (1...n).reversed() { continuation.yield(i) try? await Task.sleep(nanoseconds: 1_000_000_000) } continuation.finish() } }}
for await count in countdown(from: 5) { print(count)}
// AsyncThrowingStreamfunc fetchPaginatedResults() -> AsyncThrowingStream<[Item], Error> { AsyncThrowingStream { continuation in var page = 1
Task { while true { do { let items = try await fetchPage(page: page) if items.isEmpty { continuation.finish() break } continuation.yield(items) page += 1 } catch { continuation.finish(throwing: error) break } } } }}
for try await items in fetchPaginatedResults() { print("Received \(items.count) items")}Buffering Policy
Section titled “Buffering Policy”func generateEvents() -> AsyncStream<Event> { AsyncStream(bufferingPolicy: .bufferingOldest(100)) { continuation in // ... }}Combining Async with Existing Patterns
Section titled “Combining Async with Existing Patterns”Replacing Callbacks with async/await
Section titled “Replacing Callbacks with async/await”// Before: completion handlerfunc fetchOld(completion: @escaping (Result<Data, Error>) -> Void) { URLSession.shared.dataTask(with: url) { data, _, error in if let data { completion(.success(data)) } else if let error { completion(.failure(error)) } }.resume()}
// After: async/awaitfunc fetchNew() async throws -> Data { let (data, _) = try await URLSession.shared.data(from: url) return data}
// Bridging callback to asyncfunc fetchBridged() async throws -> Data { try await withCheckedThrowingContinuation { continuation in fetchOld { result in continuation.resume(with: result) } }}withCheckedThrowingContinuation
Section titled “withCheckedThrowingContinuation”func performLegacyOperation() async throws -> String { try await withCheckedThrowingContinuation { continuation in legacyAPI.execute { result, error in if let error { continuation.resume(throwing: error) } else { continuation.resume(returning: result) } } }}Concurrency Best Practices
Section titled “Concurrency Best Practices”Avoid Data Races
Section titled “Avoid Data Races”// BAD: shared mutable state across tasksvar counter = 0await withTaskGroup(of: Void.self) { group in for _ in 0..<1000 { group.addTask { counter += 1 // Data race! } }}// counter is unpredictable
// GOOD: use actoractor SafeCounter { var count = 0 func increment() { count += 1 } func value() -> Int { count }}
// GOOD: use atomic valuelet counter = LockedValue<Int>(initialValue: 0)await withTaskGroup(of: Void.self) { group in for _ in 0..<1000 { group.addTask { await counter.update { $0 += 1 } } }}Prefer Structured Concurrency
Section titled “Prefer Structured Concurrency”// BAD: fire and forgetfunc loadAll() { Task { await fetchUser(id: 1) } Task { await fetchFeed() } // No way to wait for completion, no error handling}
// GOOD: structured with async letfunc loadAll() async throws { async let user = fetchUser(id: 1) async let feed = fetchFeed() let dashboard = try await Dashboard(user: user, feed: feed)}
// GOOD: TaskGroup for dynamic numberfunc loadAll(ids: [Int]) async throws { try await withThrowingTaskGroup(of: User.self) { group in for id in ids { group.addTask { try await fetchUser(id: id) } } // All results collected or errors propagated }}Cancellation Cooperatively
Section titled “Cancellation Cooperatively”func longRunningOperation() async throws -> Result { for i in 0..<100 { try Task.checkCancellation() // Respond to cancellation
let partial = try await processBatch(startIndex: i * 100)
if partial.isComplete { return partial } } throw OperationError.timedOut}Avoid Main Thread Blocking
Section titled “Avoid Main Thread Blocking”// BAD: CPU-intensive work on main actor@MainActorfunc processLargeDataset(_ data: [DataPoint]) { let result = heavyComputation(data) // Blocks UI! displayResult(result)}
// GOOD: offload to background, update on main@MainActorfunc processLargeDataset(_ data: [DataPoint]) async { let result = await Task.detached { heavyComputation(data) }.value displayResult(result)}Intuition
Section titled “Intuition”Swift concurrency with async/await is like giving instructions to a personal assistant. You say go fetch this, and the assistant goes off to do it while you continue with other work. When the assistant returns with the result, you pick up where you left off. The assistant handles the complexity of managing the task in the background.
Actors in Swift are like single-threaded offices. Only one person can be in the office at a time, so there is never confusion about who is working on what. If someone else needs to do something in the office, they must wait their turn. This serialization prevents data races by design.
Worked Examples
Section titled “Worked Examples”Example 1: Actor-Based Rate Limiter
Section titled “Example 1: Actor-Based Rate Limiter”Problem: Build a thread-safe rate limiter using an actor that controls access to a limited resource.
actor RateLimiter { private let maxRequests: Int private let windowSeconds: TimeInterval private var timestamps: [Date] = []
init(maxRequests: Int, windowSeconds: TimeInterval) { self.maxRequests = maxRequests self.windowSeconds = windowSeconds }
func shouldAllow() -> Bool { let now = Date() let windowStart = now.addingTimeInterval(-windowSeconds)
// Remove timestamps outside the current window timestamps = timestamps.filter { $0 > windowStart }
if timestamps.count < maxRequests { timestamps.append(now) return true } return false }
func waitUntilAllowed() async { while !shouldAllow() { let oldestAllowed = timestamps.first?.addingTimeInterval(windowSeconds) ?? Date() let waitTime = oldestAllowed.timeIntervalSinceNow if waitTime > 0 { try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000)) } } }}
// Usagelet limiter = RateLimiter(maxRequests: 10, windowSeconds: 60)
Task { for i in 1...20 { await limiter.waitUntilAllowed() print("Request \(i) allowed at \(Date())") }}Explanation: The actor ensures that all access to timestamps is serialized. shouldAllow checks if we’re within the rate limit by filtering old timestamps. waitUntilAllowed loops until permission is granted, sleeping for the required duration. The actor’s isolation guarantees thread safety without explicit locks.
Example 2: AsyncStream for Real-Time Data
Section titled “Example 2: AsyncStream for Real-Time Data”Problem: Create an AsyncStream that emits temperature readings at regular intervals, simulating a sensor.
func temperatureStream(interval: TimeInterval = 1.0) -> AsyncStream<Double> { AsyncStream { continuation in let task = Task { var baseTemperature = 20.0 while !Task.isCancelled { // Simulate fluctuating temperature let noise = Double.random(in: -0.5...0.5) baseTemperature += noise continuation.yield(baseTemperature) try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) } continuation.finish() }
continuation.onTermination = { _ in task.cancel() } }}
// UsageTask { var count = 0 for await temp in temperatureStream(interval: 0.5) { print(String(format: "Temperature: %.1f°C", temp)) count += 1 if count >= 10 { break } }}Explanation: AsyncStream wraps the imperative generation loop. continuation.yield emits values to consumers. onTermination ensures the producer task is cancelled when the consumer stops. Task.isCancelled provides cooperative cancellation in the loop. The consumer uses for await to iterate over the stream.
Example 3: @MainActor ViewModel with Error Handling
Section titled “Example 3: @MainActor ViewModel with Error Handling”Problem: Build a SwiftUI view model that fetches data on the main actor, handles errors, and provides retry logic.
@MainActorclass DataViewModel: ObservableObject { enum State { case idle case loading case loaded([Item]) case failed(String) }
@Published var state: State = .idle @Published var retryCount = 0 private let maxRetries = 3
func load() async { state = .loading
for attempt in 1...maxRetries { do { let items = try await fetchItems() state = .loaded(items) retryCount = 0 return } catch { retryCount = attempt if attempt == maxRetries { state = .failed(error.localizedDescription) } else { try? await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt)) * 1_000_000_000)) } } } }
func retry() async { guard case .failed = state else { return } await load() }}
struct ItemListView: View { @StateObject private var viewModel = DataViewModel()
var body: some View { Group { switch viewModel.state { case .idle: ContentUnavailableView("Pull to load", systemImage: "arrow.down") case .loading: ProgressView("Loading items...") case .loaded(let items): List(items) { item in Text(item.name) } case .failed(let message): VStack { Image(systemName: "exclamationmark.triangle") Text(message) Button("Retry (\(viewModel.retryCount)/3)") { Task { await viewModel.retry() } } } } } .task { await viewModel.load() } }}Explanation: The @MainActor annotation ensures all published property updates happen on the main thread. The State enum models all possible UI states. load() implements exponential backoff retry logic. SwiftUI’s .task modifier starts the async load and automatically cancels when the view disappears.
Common Mistakes
Section titled “Common Mistakes”Updating UI from a background thread. SwiftUI requires all UI updates to happen on the main thread. Updating @Published properties from a background actor causes runtime warnings or crashes. Always dispatch UI updates to @MainActor or use @MainActor-annotated properties.
Forgetting that async let runs child tasks concurrently. When you write async let a = fetch1() and async let b = fetch2(), both tasks run concurrently until you await their results. If you await them sequentially, you lose the concurrency benefit. Structure code to start all independent tasks before awaiting any of them.
Ignoring task cancellation. Structured concurrency propagates cancellation from parent to child tasks. If a parent task is cancelled, all child tasks are too. Always check Task.isCancelled or call try Task.checkCancellation() in long-running loops to respond to cancellation promptly.
Cross-References
Section titled “Cross-References”- Classes and Structs - How actors extend the class model for safe concurrent access
- Error Handling - How async functions use try/catch for error propagation
- Functions - How async/await changes function signatures and closure patterns