Skip to content

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]

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)
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)!)
}
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 Task
Task {
await displayUserProfile(id: 1)
}
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)
}
// Detached task -- independent of current context
let handle = Task.detached {
let result = await heavyComputation()
print("Result: \(result)")
return result
}
// Structured task -- inherits context, bounded lifetime
let task = Task {
try await fetchUser(id: 1)
}
// Cancel a task
task.cancel()
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 checks
Task {
for i in 0..<1000 {
if Task.isCancelled { break }
await doWork(step: i)
}
}
@TaskLocal static var currentRequestID: String?
func processRequest(id: String) async {
// Inherit the request ID across async boundaries
$currentRequestID.withValue(id) {
performDatabaseOperation()
}
}

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 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 version
func 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
}
}
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 })
}
}
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 are reference types that provide data isolation — access to their mutable state is synchronized automatically by the compiler.

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)")
}

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
}
}
@globalActor
struct DatabaseActor: GlobalActor {
static let shared = DatabaseActor()
actor ActorType { }
}
@DatabaseActor
func saveUser(_ user: User) {
// Runs on DatabaseActor's executor
db.insert(user)
}
@DatabaseActor
var currentUser: User?

@MainActor marks code that must run on the main thread, which is essential for UI updates.

@MainActor
class 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
@MainActor
func updateLabel(_ text: String) {
label.text = text
}
// Call from background task
Task {
let data = await heavyComputation()
await updateLabel("Result: \(data)")
}
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()
}
}
}

The Sendable protocol marks types that are safe to transfer across concurrency boundaries.

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).

// Value types with Sendable stored properties are automatically Sendable
struct Message: Sendable {
let id: UUID
let text: String
let timestamp: Date
}
// Lock-based Sendable conformance for reference types
final 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] = [:]
}

Closures passed to concurrency APIs must be @Sendable:

// The compiler infers @Sendable for Task and TaskGroup
Task {
await fetchUser(id: 1)
}
// Explicit @Sendable
func runInParallel(
_ a: @Sendable @escaping () -> Void,
_ b: @Sendable @escaping () -> Void
) async {
async let t1 = Task { a() }
async let t2 = Task { b() }
await t1
await t2
}
// Error: class is not Sendable
class DataStore {
var cache: [String: Data] = [:]
}
// Fix 1: Use an actor
actor SafeDataStore {
var cache: [String: Data] = [:]
func store(_ data: Data, for key: String) {
cache[key] = data
}
}
// Fix 2: Use Sendable value type
struct 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] = [:]
}

Streams provide a way to deliver values over time, similar to Combine publishers.

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)
}
// AsyncThrowingStream
func 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")
}
func generateEvents() -> AsyncStream<Event> {
AsyncStream(bufferingPolicy: .bufferingOldest(100)) { continuation in
// ...
}
}
// Before: completion handler
func 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/await
func fetchNew() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
// Bridging callback to async
func fetchBridged() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
fetchOld { result in
continuation.resume(with: result)
}
}
}
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)
}
}
}
}
// BAD: shared mutable state across tasks
var counter = 0
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1000 {
group.addTask {
counter += 1 // Data race!
}
}
}
// counter is unpredictable
// GOOD: use actor
actor SafeCounter {
var count = 0
func increment() { count += 1 }
func value() -> Int { count }
}
// GOOD: use atomic value
let counter = LockedValue<Int>(initialValue: 0)
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1000 {
group.addTask {
await counter.update { $0 += 1 }
}
}
}
// BAD: fire and forget
func loadAll() {
Task { await fetchUser(id: 1) }
Task { await fetchFeed() }
// No way to wait for completion, no error handling
}
// GOOD: structured with async let
func 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 number
func 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
}
}
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
}
// BAD: CPU-intensive work on main actor
@MainActor
func processLargeDataset(_ data: [DataPoint]) {
let result = heavyComputation(data) // Blocks UI!
displayResult(result)
}
// GOOD: offload to background, update on main
@MainActor
func processLargeDataset(_ data: [DataPoint]) async {
let result = await Task.detached {
heavyComputation(data)
}.value
displayResult(result)
}

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.

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))
}
}
}
}
// Usage
let 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.


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()
}
}
}
// Usage
Task {
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.

@MainActor
class 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.

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.

  • 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