swift-concurrency
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.isCancelledin long operations - Avoid async in tight loops - Batch work when possible
- Test concurrent code - Use
.serializedtrait when needed
Official Resources
More from bluewaves-creations/bluewaves-skills
photographer-testino
Generate images in Mario Testino's glamorous vibrant style. Use when users ask for Testino style, high fashion glamour, bold saturated colors, warm luxurious photography, dynamic sensual energy.
35photographer-lindbergh
Generate images in Peter Lindbergh's iconic black and white style. Use when users ask for Lindbergh style, raw authentic beauty, emotional B&W portraits, supermodel aesthetic, or unretouched natural photography.
30photographer-lachapelle
Generate images in David LaChapelle's surreal pop art style. Use when users ask for LaChapelle style, pop surrealism, hyper-saturated colors, theatrical staging, baroque maximalism, kitsch aesthetic.
24epub-creator
Create production-quality EPUB 3 ebooks from markdown and images with automated QA, formatting fixes, and validation. Use when creating ebooks, converting markdown to EPUB, or compiling chapters into a publishable book. Handles markdown quirks, generates TOC, adds covers, and validates output automatically.
22photographer-vonunwerth
Generate images in Ellen von Unwerth's playful vintage style. Use when users ask for von Unwerth style, playful sensuality, vintage film noir, whimsical feminine photography, retro glamour, narrative storytelling.
19photographer-ritts
Generate images in Herb Ritts' sculptural black and white style. Use when users ask for Ritts style, classical Greek aesthetic, sculptural body photography, California golden hour, minimalist athletic portraits.
18