Swift 6 concurrency: tasks, actors and Sendable
Task and TaskGroup, async let, actor isolation, MainActor, Sendable checking, structured cancellation and the data-race safety rules of language mode 6.
Structured concurrency
func loadDashboard(userID: Int) async throws -> Dashboard {
// concurrent, independent pieces
async let profile = fetchProfile(userID)
async let orders = fetchOrders(userID)
async let messages = fetchMessages(userID)
return try await Dashboard(profile: profile, orders: orders, messages: messages)
}
func averageLatency(of hosts: [String]) async -> Double {
await withTaskGroup(of: Double.self) { group in
for host in hosts {
group.addTask { await ping(host) }
}
var total = 0.0
var count = 0.0
for await latency in group {
total += latency
count += 1
}
return count == 0 ? 0 : total / count
}
}
// cancellation is cooperative: check it in long loops
func process(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation()
await handle(item)
}
}async letstarts the child immediately; awaiting it later is when the value is collected.- A task group cancels all children if the parent is cancelled, which is the guarantee the old completion-handler world never had.
- Cancellation is not preemptive — a task keeps running until it checks. Long loops must call
Task.checkCancellation(). Task.detachedinherits nothing, including priority and task-local values. It is rarely what you want.
Actors, MainActor and Sendable
actor Counter {
private var value = 0
func increment() -> Int {
value += 1
return value
}
}
// Swift 6: a class with mutable state must be isolated or Sendable-safe
@MainActor
final class ViewModel {
private(set) var title = ""
private let counter = Counter()
func refresh() async {
title = "Loading"
// calling an actor from a MainActor context hops off and back
let current = await counter.increment()
title = "Count \(current)"
}
}
// a value type of Sendable members is Sendable automatically
struct Player: Sendable, Identifiable {
let id: UUID
let name: String
}
// a global mutable variable must be isolated or immutable
// in Swift 6 this is an error:
// var shared = 0| Declaration | Runs on | Crossing cost |
|---|---|---|
@MainActor class | Main thread | A hop if called from a background task |
actor | Its own executor | An await and possible suspension |
nonisolated func | Caller's executor | None, but no isolated state |
Sendable struct | Wherever needed | None, it copies |
Data-race safety in practice
// WRONG in Swift 6: the closure runs off the main actor
// and touches main-actor state without isolation
@MainActor
final class Feed {
var items: [String] = []
func load() async {
let fetched = await fetchItems()
items = fetched // ok: still on the main actor
}
}
// Bridging a callback API to async, keeping isolation explicit
func fetchItems() async -> [String] {
await withCheckedContinuation { continuation in
legacyClient.fetch { items in
continuation.resume(returning: items)
}
}
}
// Adopting Swift 6 incrementally: enable strict checking per target
// .enableUpcomingFeature("StrictConcurrency")
// and fix the warnings before flipping swiftLanguageModes to .v6⚠️
@unchecked Sendable tells the compiler to stop checking without proving anything. It is only defensible when the type wraps a lock or an actor; anywhere else it is a data race waiting for a release build to expose it.FAQ
How do I migrate a large codebase to Swift 6 concurrency?
Turn on the upcoming features to get warnings, keep the package in Swift 5 language mode, and fix them module by module. Isolate the UI layer on
@MainActor first; that alone resolves most of the diagnostics.Do I need to mark everything Sendable?
No. Immutable structs and enums of Sendable members are inferred. You add the conformance explicitly for classes you have made safe, and the compiler then holds you to that promise.
Related
Memory management, ARC and performance Generics, opaque types and protocol design
Last refreshed 2026-09-18.