Swift Concurrency
Comprehensive guide to Swift 6 strict concurrency, async/await patterns, actors, and modern thread-safe programming for iOS 26 and macOS Tahoe.
Prerequisites
-
Swift 6.x with strict concurrency enabled
-
Xcode 26+
Swift 6 Concurrency Model
Complete Concurrency Checking
Swift 6 enables complete data-race safety by default. The compiler statically verifies that your code is free from data races.
// In Package.swift .target( name: "MyApp", swiftSettings: [ .swiftLanguageMode(.v6) ] )
Key Concepts
-
Isolation Domains - Code is isolated to specific actors
-
Sendable - Types that can safely cross isolation boundaries
-
Actor Isolation - Data protected by actors
-
MainActor - Main thread isolation for UI
Async/Await Basics
Async Functions
func fetchUser(id: Int) async throws -> User { let url = URL(string: "https://api.example.com/users/\(id)")! let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(User.self, from: data) }
// Calling async functions func loadUserProfile() async { do { let user = try await fetchUser(id: 123) print("Loaded: (user.name)") } catch { print("Failed: (error)") } }
Async Properties
struct ImageLoader { var url: URL
var image: UIImage {
get async throws {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
return image
}
}
}
// Usage let loader = ImageLoader(url: imageURL) let image = try await loader.image
Async Sequences
func processLines(from url: URL) async throws { for try await line in url.lines { print(line) } }
// Custom async sequence struct Counter: AsyncSequence { typealias Element = Int let limit: Int
struct AsyncIterator: AsyncIteratorProtocol {
var current = 0
let limit: Int
mutating func next() async -> Int? {
guard current < limit else { return nil }
defer { current += 1 }
try? await Task.sleep(for: .seconds(1))
return current
}
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(limit: limit)
}
}
// Usage for await count in Counter(limit: 5) { print(count) // Prints 0, 1, 2, 3, 4 with 1s delays }
Task Management
Creating Tasks
// Unstructured task - runs independently let task = Task { await performWork() }
// Detached task - no inherited context let detached = Task.detached { await performBackgroundWork() }
// Task with priority let highPriority = Task(priority: .high) { await performUrgentWork() }
// Cancel a task task.cancel()
// Check cancellation func performWork() async throws { for item in items { try Task.checkCancellation() // Throws if cancelled await process(item) } }
Task Groups
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
}
}
// With result ordering func fetchUsersOrdered(ids: [Int]) async throws -> [User] { try await withThrowingTaskGroup(of: (Int, User).self) { group in for (index, id) in ids.enumerated() { group.addTask { (index, try await fetchUser(id: id)) } }
var results = [(Int, User)]()
for try await result in group {
results.append(result)
}
return results.sorted { $0.0 < $1.0 }.map(\.1)
}
}
Discarding Task Group (Swift 6)
// For fire-and-forget parallel tasks await withDiscardingTaskGroup { group in for url in urls { group.addTask { await prefetchImage(from: url) } } // Results are automatically discarded }
Actors
Basic Actor
actor BankAccount { private var balance: Decimal = 0
func deposit(_ amount: Decimal) {
balance += amount
}
func withdraw(_ amount: Decimal) throws {
guard balance >= amount else {
throw BankError.insufficientFunds
}
balance -= amount
}
func getBalance() -> Decimal {
balance
}
}
// Usage - all calls are async let account = BankAccount() await account.deposit(100) let balance = await account.getBalance()
Nonisolated Members
actor DataManager { private var cache: [String: Data] = [:]
// Isolated - requires await
func store(_ data: Data, for key: String) {
cache[key] = data
}
// Nonisolated - can be called synchronously
nonisolated let identifier = UUID()
nonisolated func createKey(for name: String) -> String {
"\(identifier)-\(name)"
}
}
let manager = DataManager() let key = manager.createKey(for: "test") // No await needed await manager.store(data, for: key) // Requires await
Actor Reentrancy
actor ImageCache { private var cache: [URL: UIImage] = [:] private var inProgress: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
// Check cache first
if let cached = cache[url] {
return cached
}
// Check if already loading
if let existing = inProgress[url] {
return try await existing.value
}
// Start new load
let task = Task {
let (data, _) = try await URLSession.shared.data(from: url)
let image = UIImage(data: data)!
return image
}
inProgress[url] = task
do {
let image = try await task.value
cache[url] = image
inProgress[url] = nil
return image
} catch {
inProgress[url] = nil
throw error
}
}
}
@MainActor
Class-Level Isolation
@MainActor class ViewModel: ObservableObject { @Published var items: [Item] = [] @Published var isLoading = false @Published var error: Error?
func loadItems() async {
isLoading = true
defer { isLoading = false }
do {
items = try await fetchItems()
} catch {
self.error = error
}
}
// Nonisolated for background work
nonisolated func fetchItems() async throws -> [Item] {
let url = URL(string: "https://api.example.com/items")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Item].self, from: data)
}
}
Method-Level Isolation
class DataProcessor { func processData() async -> ProcessedData { // Runs on background let result = await heavyComputation() return result }
@MainActor
func updateUI(with data: ProcessedData) {
// Runs on main thread
label.text = data.summary
}
}
MainActor.run
func performBackgroundWork() async { let result = await processLargeDataset()
// Jump to main actor for UI update
await MainActor.run {
updateProgressView(with: result)
}
}
Sendable Protocol
Sendable Types
// Value types are implicitly Sendable struct Point: Sendable { var x: Double var y: Double }
// Immutable classes can be Sendable final class Configuration: Sendable { let apiKey: String let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}
// Actors are implicitly Sendable actor Counter: Sendable { var count = 0 }
@unchecked Sendable
// Use carefully for types with internal synchronization final class ThreadSafeCache: @unchecked Sendable { private let lock = NSLock() private var storage: [String: Any] = [:]
func get(_ key: String) -> Any? {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
func set(_ key: String, value: Any) {
lock.lock()
defer { lock.unlock() }
storage[key] = value
}
}
Sendable Closures
// @Sendable closures can be sent across isolation boundaries func performAsync(_ work: @Sendable @escaping () async -> Void) { Task { await work() } }
// Capturing in Sendable closures func processItems(_ items: [Item]) { // items must be Sendable Task { @Sendable in for item in items { await process(item) } } }
Swift 6.2 Improvements
Default Isolation
// New in Swift 6.2: Default to main actor isolation // In Package.swift or build settings: // -default-isolation MainActor
// Or per-file: @MainActor extension MyView { // All methods here are MainActor-isolated by default }
Observations Async Sequence
import Observation
@Observable class Model { var count = 0 }
// Stream changes with async sequence func observeModel(_ model: Model) async { for await _ in model.observations(of: .count) { print("Count changed to: (model.count)") } }
Region-Based Isolation (SE-0414)
// Compiler can now reason about value regions func processData() async { var data = [1, 2, 3]
// Compiler knows data doesn't escape
await withTaskGroup(of: Void.self) { group in
for item in data {
group.addTask {
print(item)
}
}
}
// Safe to mutate after task group completes
data.append(4)
}
Common Pitfalls
Pitfall 1: DispatchQueue in Swift 6
// WRONG - flagged as unsafe in Swift 6 DispatchQueue.main.async { self.updateUI() }
// CORRECT - use MainActor await MainActor.run { self.updateUI() }
// Or make the enclosing context @MainActor @MainActor func handleResult() { updateUI() // Already on main actor }
Pitfall 2: Combine without Isolation
// WRONG - closure isolation unclear publisher .sink { value in self.items = value // Data race potential } .store(in: &cancellables)
// CORRECT - explicit isolation publisher .receive(on: DispatchQueue.main) .sink { [weak self] value in Task { @MainActor in self?.items = value } } .store(in: &cancellables)
Pitfall 3: Non-Sendable Captures
// WRONG - UIViewController not Sendable func saveData() { let vc = self // UIViewController Task { await save() vc.showSuccess() // Error: captured non-Sendable } }
// CORRECT - use weak capture and MainActor func saveData() { Task { [weak self] in await save() await MainActor.run { self?.showSuccess() } } }
Pitfall 4: Actor Reentrancy Surprises
actor DataStore { var items: [Item] = []
func addItem(_ item: Item) async {
// State before await
let countBefore = items.count
await saveToDatabase(item)
// WARNING: items may have changed during await!
items.append(item) // Could cause duplicates
}
// BETTER - capture state carefully
func addItemSafely(_ item: Item) async {
items.append(item)
let itemsCopy = items
await saveToDatabase(itemsCopy)
}
}
Migration from GCD
Before (GCD)
func loadData(completion: @escaping (Result<Data, Error>) -> Void) { DispatchQueue.global().async { do { let data = try self.fetchDataSync() DispatchQueue.main.async { completion(.success(data)) } } catch { DispatchQueue.main.async { completion(.failure(error)) } } } }
After (Async/Await)
func loadData() async throws -> Data { try await fetchData() }
// Usage Task { do { let data = try await loadData() // Already on calling context } catch { // Handle error } }
Bridging Completion Handlers
// Wrap completion handler API func fetchLegacyData() async throws -> Data { try await withCheckedThrowingContinuation { continuation in legacyFetchData { result in switch result { case .success(let data): continuation.resume(returning: data) case .failure(let error): continuation.resume(throwing: error) } } } }
// Important: Only call resume ONCE func fetchWithTimeout() async throws -> Data { try await withCheckedThrowingContinuation { continuation in var hasResumed = false
legacyFetch { data in
guard !hasResumed else { return }
hasResumed = true
continuation.resume(returning: data)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
guard !hasResumed else { return }
hasResumed = true
continuation.resume(throwing: TimeoutError())
}
}
}
AsyncStream
Creating Streams
func notifications() -> AsyncStream<Notification> { AsyncStream { continuation in let observer = NotificationCenter.default.addObserver( forName: .customNotification, object: nil, queue: .main ) { notification in continuation.yield(notification) }
continuation.onTermination = { _ in
NotificationCenter.default.removeObserver(observer)
}
}
}
// Usage for await notification in notifications() { print("Received: (notification)") }
Throwing Streams
func dataStream(from url: URL) -> AsyncThrowingStream<Data, Error> { AsyncThrowingStream { continuation in let task = URLSession.shared.dataTask(with: url) { data, _, error in if let error = error { continuation.finish(throwing: error) return } if let data = data { continuation.yield(data) } continuation.finish() } task.resume()
continuation.onTermination = { _ in
task.cancel()
}
}
}
Testing Concurrent Code
import Testing
@Test("Concurrent counter increments correctly") func concurrentCounter() async { let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1000 {
group.addTask {
await counter.increment()
}
}
}
let final = await counter.value
#expect(final == 1000)
}
@Test(.serialized, "Tests that must run sequentially") func sequentialTest() async { // Use .serialized trait for tests not ready for parallel }
Best Practices
-
Prefer structured concurrency - Use task groups over detached tasks
-
Make types Sendable - Design for thread safety from the start
-
Use actors for shared mutable state - Don't use locks in Swift 6
-
Isolate UI code to MainActor - Use @MainActor for ViewModels
-
Handle cancellation - Check Task.isCancelled in long operations
-
Avoid async in tight loops - Batch work when possible
-
Test concurrent code - Use .serialized trait when needed
Official Resources
-
Swift Concurrency Documentation
-
Migrating to Swift 6
-
SE-0414: Region-Based Isolation
-
WWDC23: Beyond the basics of structured concurrency