| name | swift-concurrency |
| description | Swift 6 strict concurrency model, async/await, actors, @MainActor, Sendable, and thread safety. Use when user asks about concurrency, async/await, actors, MainActor, thread safety, data races, or migrating from GCD to structured concurrency. |
| allowed-tools | Bash, Read, Write, Edit |
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