Issue #1038
Swift 6 concurrency replaces Grand Central Dispatch queues, locks, and completion handlers with a structured model built around async/await, actors, and task groups. The compiler enforces isolation rules at build time, and the runtime catches violations that slip through. This article walks through the core tools and the patterns that make them work correctly.
async/await
Marking a function async means it can suspend without blocking the thread it runs on. When it suspends, the thread is freed to do other work. When the awaited value is ready, Swift resumes the function, possibly on a different thread.
func fetchUser(id: String) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: URL(string: "/users/\(id)")!)
return try JSONDecoder().decode(User.self, from: data)
}
Calling an async function requires await. The await keyword marks every suspension point explicitly, which makes it clear where the function might pause and where state could change in the meantime.
func loadProfile() async throws {
let user = try await fetchUser(id: currentUserID)
let posts = try await fetchPosts(for: user)
display(user: user, posts: posts)
}
These two calls run sequentially. To run them in parallel, use async let.
Running work in parallel
async let starts a child task immediately and lets other work continue. The result is not demanded until you await it. Swift cancels any unresolved async let bindings automatically if the enclosing scope exits early.
func loadDashboard() async throws -> Dashboard {
async let user = fetchUser(id: currentUserID)
async let feed = fetchFeed()
async let notifications = fetchNotifications()
return try await Dashboard(user: user, feed: feed, notifications: notifications)
}
Use async let when you have a small, fixed set of concurrent operations with different return types. When the number of operations is dynamic, use a task group instead.
Task groups
A task group creates a bounded scope of concurrent child tasks. All children are awaited before the group exits, and cancelling the group cancels every child.
func fetchAll(urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
var results: [Data] = []
for try await data in group {
results.append(data)
}
return results
}
}
Task groups are the right tool for processing arrays, batching requests, or any pattern where you previously wrote DispatchGroup or a semaphore with a loop. Avoid creating Task {} in a loop: those tasks are unstructured, so errors are silently lost and cancellation does not propagate.
When tasks produce side effects rather than return values, withDiscardingTaskGroup avoids buffering results in memory.
await withDiscardingTaskGroup { group in
for connection in activeConnections {
group.addTask {
await connection.sendHeartbeat()
}
}
}
Actors
An actor serializes access to its mutable state. Only one caller can execute actor-isolated code at a time, eliminating data races without explicit locks.
actor ImageCache {
private var storage: [URL: UIImage] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = storage[url] {
return cached
}
let (data, _) = try await URLSession.shared.data(from: url)
let image = UIImage(data: data)!
storage[url] = image
return image
}
}
The storage dictionary is only ever read or written from within the actor, so no external lock is needed. Calling actor methods from outside requires await, because the caller must wait for the actor to be available.
Actor reentrancy
Every await inside an actor method is a suspension point. While the actor is suspended, other callers can execute on it and modify state. Code written as “check, then await, then act” can behave incorrectly.
// Broken: another caller may set storage[url] during the await
func image(for url: URL) async throws -> UIImage {
if storage[url] == nil {
storage[url] = try await download(url) // actor is unlocked here
}
return storage[url]! // may crash if state changed
}
// Correct: capture before suspension, then write after
func image(for url: URL) async throws -> UIImage {
if let cached = storage[url] { return cached }
let image = try await download(url)
storage[url] = image
return image
}
For expensive operations you want to deduplicate, store an in-flight Task and return its value to subsequent callers rather than starting a second download.
actor ImageCache {
private var storage: [URL: UIImage] = [:]
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = storage[url] { return cached }
if let task = inFlight[url] { return try await task.value }
let task = Task { try await download(url) }
inFlight[url] = task
do {
let image = try await task.value
storage[url] = image
inFlight[url] = nil
return image
} catch {
inFlight[url] = nil
throw error
}
}
}
The main actor
@MainActor is a global actor that serializes work on the main thread. Annotate types or methods with it to ensure UI work always runs on the right thread.
@MainActor
class FeedViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var isLoading = false
func load() async throws {
isLoading = true
posts = try await fetchPosts()
isLoading = false
}
}
Because the class is @MainActor-isolated, every property access and method call on it runs on the main thread. This replaces DispatchQueue.main.async {} wrappers throughout the type.
When only one method needs to hop back to the main thread, use MainActor.run rather than annotating the entire type.
func processAndDisplay() async throws {
let result = try await heavyProcessing()
await MainActor.run {
self.label.text = result
}
}
For work that needs to leave the caller’s actor and run on the cooperative thread pool, mark the function @concurrent. This is appropriate for CPU-heavy processing, not for ordinary async I/O.
@concurrent
func resizeImage(_ image: UIImage, to size: CGSize) async -> UIImage {
// runs on the cooperative thread pool, not the main actor
return image.resized(to: size)
}
Sendable
Sendable marks a type as safe to share across isolation domains. Value types with Sendable members are Sendable by default. Reference types require explicit conformance, which the compiler enforces by checking immutability.
struct Point: Sendable {
let x: Double
let y: Double
}
final class Token: Sendable {
let value: String // immutable, so Sendable is safe
init(value: String) { self.value = value }
}
When you pass a value into a Task or across an actor boundary, the compiler verifies the value is Sendable. For types that manage their own internal locking, @unchecked Sendable suppresses the check, but this shifts the responsibility for correctness entirely to you.
The sending keyword describes a function parameter that transfers ownership across an isolation boundary. After the call, the original variable is no longer accessible, which prevents shared mutable access.
func upload(article: sending Article) async throws {
try await apiClient.post(article)
// article cannot be used here
}
Cancellation
Cancellation in Swift is cooperative: cancelling a task sets a flag, but the task keeps running until it checks for cancellation. Suspension points at await automatically propagate cancellation through structured child tasks, but CPU-bound loops without any await must check manually.
func processItems(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation()
try await process(item)
}
}
When wrapping a legacy API that has its own cancel mechanism, use withTaskCancellationHandler to wire them together.
func download(from url: URL) async throws -> Data {
let session = URLSession.shared
return try await withTaskCancellationHandler {
let (data, _) = try await session.data(from: url)
return data
} onCancel: {
session.invalidateAndCancel()
}
}
When catching errors from cancellable work, handle CancellationError separately. It is a normal lifecycle event, not an error to surface to the user.
do {
try await loadData()
} catch is CancellationError {
return
} catch {
self.errorMessage = error.localizedDescription
}
When storing a task as a property, cancel the previous one before replacing it. Otherwise the old task keeps running silently in the background.
class ViewModel {
private var loadTask: Task<Void, Never>?
func load() {
loadTask?.cancel()
loadTask = Task {
do {
try await loadContent()
} catch is CancellationError {
return
} catch {
await MainActor.run { self.errorMessage = error.localizedDescription }
}
}
}
deinit {
loadTask?.cancel()
}
}
In SwiftUI, prefer the .task view modifier over creating Task {} inside onAppear. The modifier automatically cancels when the view disappears.
struct ArticleView: View {
@State private var article: Article?
var body: some View {
ArticleContent(article: article)
.task {
article = try? await fetchArticle()
}
}
}
The mental model for Swift 6 concurrency is: functions declare their isolation, values declare their shareability, and the compiler verifies that nothing crosses boundaries unsafely. Adopting these tools consistently is what makes the model reliable in production.
Start the conversation