This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
1399 lines
39 KiB
Markdown
1399 lines
39 KiB
Markdown
---
|
|
name: axiom-swift-concurrency-ref
|
|
description: Swift concurrency API reference — actors, Sendable, Task/TaskGroup, AsyncStream, continuations, isolation patterns, DispatchQueue-to-actor migration with gotcha tables
|
|
license: MIT
|
|
metadata:
|
|
version: "1.0.0"
|
|
last-updated: "2026-02-26"
|
|
---
|
|
|
|
# Swift Concurrency API Reference
|
|
|
|
Complete Swift concurrency API reference for copy-paste patterns and syntax lookup.
|
|
|
|
Complements `axiom-swift-concurrency` (which covers *when* and *why* to use concurrency — progressive journey, decision trees, @concurrent, isolated conformances).
|
|
|
|
**Related skills**: `axiom-swift-concurrency` (progressive journey, decision trees), `axiom-synchronization` (Mutex, locks), `axiom-assume-isolated` (assumeIsolated patterns)
|
|
|
|
## Part 1: Actor Patterns
|
|
|
|
### Actor Definition
|
|
|
|
```swift
|
|
actor ImageCache {
|
|
private var cache: [URL: UIImage] = [:]
|
|
|
|
func image(for url: URL) -> UIImage? {
|
|
cache[url]
|
|
}
|
|
|
|
func store(_ image: UIImage, for url: URL) {
|
|
cache[url] = image
|
|
}
|
|
}
|
|
|
|
// Usage — must await across isolation boundary
|
|
let cache = ImageCache()
|
|
let image = await cache.image(for: url)
|
|
```
|
|
|
|
All properties and methods on an actor are isolated by default. Callers outside the actor's isolation domain must use `await` to access them.
|
|
|
|
### Actor Isolation Rules
|
|
|
|
Every actor's stored properties and methods are isolated to that actor. Access from outside the isolation boundary requires `await`, which suspends the caller until the actor can process the request.
|
|
|
|
```swift
|
|
actor Counter {
|
|
var count = 0 // Isolated — external access requires await
|
|
let name: String // let constants are implicitly nonisolated
|
|
|
|
func increment() { // Isolated — await required from outside
|
|
count += 1
|
|
}
|
|
|
|
nonisolated func identity() -> String {
|
|
name // OK: accessing nonisolated let
|
|
}
|
|
}
|
|
|
|
let counter = Counter(name: "main")
|
|
await counter.increment() // Must await across isolation boundary
|
|
let id = counter.identity() // No await needed — nonisolated
|
|
```
|
|
|
|
### nonisolated Keyword
|
|
|
|
Opt out of isolation for synchronous access to non-mutable state.
|
|
|
|
```swift
|
|
actor MyActor {
|
|
let id: UUID // let constants are implicitly nonisolated
|
|
|
|
nonisolated var description: String {
|
|
"Actor \(id)" // Can only access nonisolated state
|
|
}
|
|
|
|
nonisolated func hash(into hasher: inout Hasher) {
|
|
hasher.combine(id) // Only nonisolated properties
|
|
}
|
|
}
|
|
```
|
|
|
|
`nonisolated` methods cannot access any isolated stored properties. Use this for protocol conformances (like `Hashable`, `CustomStringConvertible`) that require synchronous access.
|
|
|
|
### Actor Reentrancy
|
|
|
|
Suspension points (`await`) inside an actor allow other callers to interleave. State may change between any two `await` expressions.
|
|
|
|
```swift
|
|
actor BankAccount {
|
|
var balance: Double = 0
|
|
|
|
func transfer(amount: Double, to other: BankAccount) async {
|
|
guard balance >= amount else { return }
|
|
balance -= amount
|
|
// REENTRANCY HAZARD: another caller could modify balance here
|
|
// while we await the deposit on the other actor
|
|
await other.deposit(amount)
|
|
}
|
|
|
|
func deposit(_ amount: Double) {
|
|
balance += amount
|
|
}
|
|
}
|
|
```
|
|
|
|
**Pattern**: Re-check state after every `await` inside an actor:
|
|
|
|
```swift
|
|
actor BankAccount {
|
|
var balance: Double = 0
|
|
|
|
func transfer(amount: Double, to other: BankAccount) async -> Bool {
|
|
guard balance >= amount else { return false }
|
|
balance -= amount
|
|
|
|
await other.deposit(amount)
|
|
|
|
// Re-check invariants after await if needed
|
|
return true
|
|
}
|
|
}
|
|
```
|
|
|
|
### Global Actors
|
|
|
|
A global actor provides a single shared isolation domain accessible from anywhere.
|
|
|
|
```swift
|
|
@globalActor
|
|
actor MyGlobalActor {
|
|
static let shared = MyGlobalActor()
|
|
}
|
|
|
|
@MyGlobalActor
|
|
func doWork() { /* isolated to MyGlobalActor */ }
|
|
|
|
@MyGlobalActor
|
|
class MyService {
|
|
var state: Int = 0 // Isolated to MyGlobalActor
|
|
}
|
|
```
|
|
|
|
### @MainActor
|
|
|
|
The built-in global actor for UI work. All UI updates must happen on `@MainActor`.
|
|
|
|
```swift
|
|
@MainActor
|
|
class ViewModel: ObservableObject {
|
|
@Published var items: [Item] = []
|
|
|
|
func loadItems() async {
|
|
let data = await fetchFromNetwork()
|
|
items = data // Safe: already on MainActor
|
|
}
|
|
}
|
|
|
|
// Annotate individual members
|
|
class MixedService {
|
|
@MainActor var uiState: String = ""
|
|
|
|
@MainActor
|
|
func updateUI() {
|
|
uiState = "Done"
|
|
}
|
|
|
|
func backgroundWork() async -> String {
|
|
await heavyComputation()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Subclass inheritance**: If a class is `@MainActor`, all subclasses inherit that isolation.
|
|
|
|
### Actor Init
|
|
|
|
Actor initializers are NOT isolated to the actor. You cannot call isolated methods from init.
|
|
|
|
```swift
|
|
actor DataManager {
|
|
var data: [String] = []
|
|
|
|
init() {
|
|
// Cannot call isolated methods here
|
|
// self.loadDefaults() // ERROR: actor-isolated method in non-isolated init
|
|
}
|
|
|
|
// Use a factory method instead
|
|
static func create() async -> DataManager {
|
|
let manager = DataManager()
|
|
await manager.loadDefaults()
|
|
return manager
|
|
}
|
|
|
|
func loadDefaults() {
|
|
data = ["default"]
|
|
}
|
|
}
|
|
```
|
|
|
|
### Actor Gotcha Table
|
|
|
|
| Gotcha | Symptom | Fix |
|
|
|---|---|---|
|
|
| Actor reentrancy | State changes between awaits | Re-check state after each await |
|
|
| nonisolated accessing isolated state | Compiler error | Remove nonisolated or make property nonisolated |
|
|
| Calling actor method from sync context | "Expression is 'async'" | Wrap in Task {} or make caller async |
|
|
| Global actor inheritance | Subclass inherits @MainActor | Be intentional about which methods need isolation |
|
|
| Actor init not isolated | Can't call isolated methods in init | Use factory method or populate after init |
|
|
| Actor protocol conformance | "Non-isolated" conformance error | Use nonisolated for protocol methods, or isolated conformance (Swift 6.2+) |
|
|
| Using actor for ViewModel | @Published won't work, UI updates require await | Use @MainActor class for UI-facing code, actor only for non-UI shared state |
|
|
| GCD queue-hopping inside actor | Breaks isolation guarantees, risks thread explosion | Remove GCD — actor isolation already serializes access |
|
|
|
|
---
|
|
|
|
## Part 2: Sendable Patterns
|
|
|
|
### Automatic Sendable Conformance
|
|
|
|
Value types are Sendable when all stored properties are Sendable.
|
|
|
|
```swift
|
|
// Structs: Sendable when all stored properties are Sendable
|
|
struct UserProfile: Sendable {
|
|
let name: String
|
|
let age: Int
|
|
}
|
|
|
|
// Enums: Sendable when all associated values are Sendable
|
|
enum LoadState: Sendable {
|
|
case idle
|
|
case loading
|
|
case loaded(String) // String is Sendable
|
|
case failed(Error) // ERROR: Error is not Sendable
|
|
}
|
|
|
|
// Fix: use a Sendable error type
|
|
enum LoadState: Sendable {
|
|
case idle
|
|
case loading
|
|
case loaded(String)
|
|
case failed(any Error & Sendable)
|
|
}
|
|
```
|
|
|
|
### @Sendable Closures
|
|
|
|
Closures passed across isolation boundaries must be `@Sendable`. A `@Sendable` closure cannot capture mutable local state.
|
|
|
|
```swift
|
|
func runInBackground(_ work: @Sendable () -> Void) {
|
|
Task.detached { work() }
|
|
}
|
|
|
|
// All captured values must be Sendable
|
|
var count = 0
|
|
runInBackground {
|
|
// ERROR: capture of mutable local variable
|
|
// count += 1
|
|
}
|
|
|
|
let snapshot = count
|
|
runInBackground {
|
|
print(snapshot) // OK: let binding of Sendable type
|
|
}
|
|
```
|
|
|
|
### @unchecked Sendable
|
|
|
|
Manual guarantee of thread safety. Use only when you provide synchronization yourself.
|
|
|
|
```swift
|
|
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
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Requirements for @unchecked Sendable
|
|
- Class must be `final`
|
|
- All mutable state must be protected by a synchronization primitive (lock, queue, Mutex)
|
|
- You are responsible for correctness — the compiler will not check
|
|
|
|
### Conditional Conformance
|
|
|
|
```swift
|
|
struct Box<T> {
|
|
let value: T
|
|
}
|
|
|
|
// Box is Sendable only when T is Sendable
|
|
extension Box: Sendable where T: Sendable {}
|
|
|
|
// Standard library uses this extensively:
|
|
// Array<Element>: Sendable where Element: Sendable
|
|
// Dictionary<Key, Value>: Sendable where Key: Sendable, Value: Sendable
|
|
// Optional<Wrapped>: Sendable where Wrapped: Sendable
|
|
```
|
|
|
|
### sending Parameter Modifier (SE-0430)
|
|
|
|
Transfer ownership of a value across isolation boundaries. The caller gives up access.
|
|
|
|
```swift
|
|
func process(_ value: sending String) async {
|
|
// Caller can no longer access value after this call
|
|
await store(value)
|
|
}
|
|
|
|
// Useful for transferring non-Sendable types when caller won't use them again
|
|
func handOff(_ connection: sending NetworkConnection) async {
|
|
await manager.accept(connection)
|
|
}
|
|
```
|
|
|
|
### Build Settings
|
|
|
|
Control the strictness of Sendable checking in Xcode:
|
|
|
|
| Setting | Value | Behavior |
|
|
|---|---|---|
|
|
| `SWIFT_STRICT_CONCURRENCY` | `minimal` | Only explicit Sendable annotations checked |
|
|
| `SWIFT_STRICT_CONCURRENCY` | `targeted` | Inferred Sendable + closure checking |
|
|
| `SWIFT_STRICT_CONCURRENCY` | `complete` | Full strict concurrency (Swift 6 default) |
|
|
|
|
### Sendable Gotcha Table
|
|
|
|
| Gotcha | Symptom | Fix |
|
|
|---|---|---|
|
|
| Class can't be Sendable | "Class cannot conform to Sendable" | Make final + immutable, or @unchecked Sendable with locks |
|
|
| Closure captures non-Sendable | "Capture of non-Sendable type" | Copy value before capture, or make type Sendable |
|
|
| Protocol can't require Sendable | Generic constraints complex | Use `where T: Sendable` |
|
|
| @unchecked Sendable hides bugs | Data races at runtime | Only use when lock/queue guarantees safety |
|
|
| Array/Dictionary conditional | Collection is Sendable only if Element is | Ensure element types are Sendable |
|
|
| Error not Sendable | "Type does not conform to Sendable" | Use `any Error & Sendable` or typed errors |
|
|
|
|
---
|
|
|
|
## Part 3: Task Management
|
|
|
|
### Task { }
|
|
|
|
Creates an unstructured task that inherits the current actor context and priority.
|
|
|
|
```swift
|
|
// Inherits actor context — if called from @MainActor, runs on MainActor
|
|
let task = Task {
|
|
try await fetchData()
|
|
}
|
|
|
|
// Get the result
|
|
let result = try await task.value
|
|
|
|
// Get Result<Success, Failure>
|
|
let outcome = await task.result
|
|
```
|
|
|
|
### Task.detached { }
|
|
|
|
Creates a task with no inherited context. Does not inherit the actor or priority.
|
|
|
|
```swift
|
|
Task.detached(priority: .background) {
|
|
// NOT on MainActor even if created from MainActor
|
|
await processLargeFile()
|
|
}
|
|
```
|
|
|
|
**When to use**: Background work that must NOT run on the calling actor. Prefer `Task {}` in most cases — `Task.detached` is rarely needed.
|
|
|
|
### Task Cancellation
|
|
|
|
Cancellation is cooperative. Setting cancellation is a request; the task must check and respond.
|
|
|
|
```swift
|
|
let task = Task {
|
|
for item in largeCollection {
|
|
// Option 1: Check boolean
|
|
if Task.isCancelled { break }
|
|
|
|
// Option 2: Throw CancellationError
|
|
try Task.checkCancellation()
|
|
|
|
await process(item)
|
|
}
|
|
}
|
|
|
|
// Request cancellation
|
|
task.cancel()
|
|
```
|
|
|
|
### Task.sleep
|
|
|
|
Suspends the current task for a duration. Supports cancellation — throws `CancellationError` if cancelled during sleep.
|
|
|
|
```swift
|
|
// Duration-based (preferred)
|
|
try await Task.sleep(for: .seconds(2))
|
|
try await Task.sleep(for: .milliseconds(500))
|
|
|
|
// Nanoseconds (older API)
|
|
try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
```
|
|
|
|
### Task.yield
|
|
|
|
Voluntarily yields execution to allow other tasks to run. Use in long-running synchronous loops.
|
|
|
|
```swift
|
|
for i in 0..<1_000_000 {
|
|
if i.isMultiple(of: 1000) {
|
|
await Task.yield()
|
|
}
|
|
process(i)
|
|
}
|
|
```
|
|
|
|
### Task Priority
|
|
|
|
| Priority | Use Case |
|
|
|---|---|
|
|
| `.userInitiated` | Direct user action, visible result |
|
|
| `.high` | Same as .userInitiated |
|
|
| `.medium` | Default when not specified |
|
|
| `.low` | Prefetching, non-urgent work |
|
|
| `.utility` | Long computation, progress shown |
|
|
| `.background` | Maintenance, cleanup, not time-sensitive |
|
|
|
|
```swift
|
|
Task(priority: .userInitiated) {
|
|
await loadVisibleContent()
|
|
}
|
|
|
|
Task(priority: .background) {
|
|
await cleanupTempFiles()
|
|
}
|
|
```
|
|
|
|
### @TaskLocal
|
|
|
|
Task-scoped values that propagate to child tasks automatically.
|
|
|
|
```swift
|
|
enum RequestContext {
|
|
@TaskLocal static var requestID: String?
|
|
@TaskLocal static var userID: String?
|
|
}
|
|
|
|
// Set values for a scope
|
|
RequestContext.$requestID.withValue("req-123") {
|
|
RequestContext.$userID.withValue("user-456") {
|
|
// Both values available here and in child tasks
|
|
Task {
|
|
print(RequestContext.requestID) // "req-123"
|
|
print(RequestContext.userID) // "user-456"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Outside scope — values are nil
|
|
print(RequestContext.requestID) // nil
|
|
```
|
|
|
|
**Propagation rules**: `@TaskLocal` values propagate to child tasks created with `Task {}`. They do NOT propagate to `Task.detached {}`.
|
|
|
|
### Task Timeout Pattern
|
|
|
|
Enforce a deadline on any async operation using a task group race:
|
|
|
|
```swift
|
|
func withTimeout<T: Sendable>(
|
|
_ duration: Duration,
|
|
operation: @Sendable @escaping () async throws -> T
|
|
) async throws -> T {
|
|
try await withThrowingTaskGroup(of: T.self) { group in
|
|
group.addTask { try await operation() }
|
|
group.addTask {
|
|
try await Task.sleep(for: duration)
|
|
throw TimeoutError()
|
|
}
|
|
guard let result = try await group.next() else {
|
|
throw TimeoutError()
|
|
}
|
|
group.cancelAll() // Cancel the loser — without this it keeps running
|
|
return result
|
|
}
|
|
}
|
|
```
|
|
|
|
`group.cancelAll()` is critical. Without it, the losing task (either the timeout or the operation) continues running until the group scope exits.
|
|
|
|
### Task Retain Cycles
|
|
|
|
Tasks capture variables like closures. Stored tasks that reference `self` create retain cycles.
|
|
|
|
```swift
|
|
// ❌ Retain cycle: self → task → self
|
|
task = Task {
|
|
while true { await self.poll() }
|
|
}
|
|
|
|
// ✅ Weak capture breaks the cycle
|
|
task = Task { [weak self] in
|
|
while let self, !Task.isCancelled {
|
|
await self.poll()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Rule**: Use `[weak self]` when the Task is stored as a property or iterates an infinite async sequence. Short-lived Tasks that complete quickly can use strong captures.
|
|
|
|
### Thread.current in Swift 6
|
|
|
|
`Thread.current` is unavailable from async contexts in Swift 6 language mode:
|
|
|
|
```swift
|
|
// ❌ Compiler error in Swift 6 mode
|
|
func check() async { print(Thread.current) }
|
|
|
|
// ✅ Workaround for debugging only
|
|
extension Thread {
|
|
static var currentThread: Thread { Thread.current }
|
|
}
|
|
```
|
|
|
|
Don't rely on thread identity for correctness — tasks move between threads at suspension points. Reason about isolation domains instead.
|
|
|
|
### Task Gotcha Table
|
|
|
|
| Gotcha | Symptom | Fix |
|
|
|---|---|---|
|
|
| Task never cancelled | Resource leak, work continues after view disappears | Store task, cancel in deinit/onDisappear |
|
|
| Ignoring cancellation | Task runs to completion even when cancelled | Check Task.isCancelled in loops, use checkCancellation() |
|
|
| Task.detached loses actor context | "Not isolated to MainActor" | Use Task {} when you need actor isolation |
|
|
| Capturing self in stored Task | Retain cycle, deinit never called | Use [weak self] for long-lived or stored tasks |
|
|
| Assuming async = background | Code stays on calling actor | Use @concurrent to force background execution |
|
|
| TaskLocal not propagated | Value is nil in detached task | TaskLocal only propagates to child tasks, not detached |
|
|
| Task priority inversion | Low-priority task blocks high-priority | System handles most cases; avoid awaiting low-priority from high |
|
|
| Thread.current in async context | Compiler error in Swift 6 mode | Don't rely on thread identity — use isolation domains |
|
|
|
|
---
|
|
|
|
## Part 4: Structured Concurrency
|
|
|
|
### async let
|
|
|
|
Run a fixed number of operations in parallel. All `async let` bindings are implicitly awaited when the scope exits.
|
|
|
|
```swift
|
|
async let images = fetchImages()
|
|
async let metadata = fetchMetadata()
|
|
async let config = loadConfig()
|
|
|
|
// All three run concurrently, await together
|
|
let (imgs, meta, cfg) = try await (images, metadata, config)
|
|
```
|
|
|
|
**Semantics**: If one `async let` throws, the others are cancelled. All must complete (or be cancelled) before the enclosing scope exits.
|
|
|
|
### TaskGroup — Non-Throwing
|
|
|
|
Dynamic number of parallel tasks where none throw.
|
|
|
|
```swift
|
|
let results = await withTaskGroup(of: String.self) { group in
|
|
for name in names {
|
|
group.addTask {
|
|
await fetchGreeting(for: name)
|
|
}
|
|
}
|
|
|
|
var greetings: [String] = []
|
|
for await greeting in group {
|
|
greetings.append(greeting)
|
|
}
|
|
return greetings
|
|
}
|
|
```
|
|
|
|
### TaskGroup — Throwing
|
|
|
|
Dynamic number of parallel tasks that can throw.
|
|
|
|
```swift
|
|
let images = try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
|
|
for url in urls {
|
|
group.addTask {
|
|
let image = try await downloadImage(url)
|
|
return (url, image)
|
|
}
|
|
}
|
|
|
|
var results: [URL: UIImage] = [:]
|
|
for try await (url, image) in group {
|
|
results[url] = image
|
|
}
|
|
return results
|
|
}
|
|
```
|
|
|
|
### withDiscardingTaskGroup (iOS 17+)
|
|
|
|
For when you need concurrency but don't need to collect results. More memory-efficient than regular TaskGroup — no result storage.
|
|
|
|
```swift
|
|
try await withThrowingDiscardingTaskGroup { group in
|
|
for connection in connections {
|
|
group.addTask {
|
|
try await connection.monitor()
|
|
// Results are discarded — useful for long-running services
|
|
}
|
|
}
|
|
// Group stays alive until all tasks complete or one throws
|
|
}
|
|
```
|
|
|
|
#### Real-world pattern — merge multiple notification streams
|
|
|
|
```swift
|
|
extension NotificationCenter {
|
|
func notifications(named names: [Notification.Name]) -> AsyncStream<Void> {
|
|
AsyncStream { continuation in
|
|
let task = Task {
|
|
await withDiscardingTaskGroup { group in
|
|
for name in names {
|
|
group.addTask {
|
|
for await _ in self.notifications(named: name) {
|
|
continuation.yield()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
continuation.finish()
|
|
}
|
|
continuation.onTermination = { _ in task.cancel() }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### TaskGroup Control
|
|
|
|
```swift
|
|
await withTaskGroup(of: Data.self) { group in
|
|
// Add tasks conditionally
|
|
group.addTaskUnlessCancelled {
|
|
await fetchData()
|
|
}
|
|
|
|
// Cancel remaining tasks
|
|
group.cancelAll()
|
|
|
|
// Wait without collecting
|
|
await group.waitForAll()
|
|
|
|
// Iterate one at a time
|
|
while let result = await group.next() {
|
|
process(result)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Task Tree Semantics
|
|
|
|
Structured concurrency forms a tree:
|
|
- **Parent cancellation cancels all children** — cancelling a task cancels all `async let` and TaskGroup children
|
|
- **Child error propagates to parent** — in throwing groups, a child error cancels siblings and propagates up
|
|
- **All children must complete before parent returns** — the scope awaits all children, even cancelled ones
|
|
|
|
```swift
|
|
// If fetchImages() throws, fetchMetadata() is automatically cancelled
|
|
async let images = fetchImages()
|
|
async let metadata = fetchMetadata()
|
|
let result = try await (images, metadata)
|
|
```
|
|
|
|
### Structured Concurrency Gotcha Table
|
|
|
|
| Gotcha | Symptom | Fix |
|
|
|---|---|---|
|
|
| async let unused | Work still executes but result is discarded silently | Assign all async let results or use withDiscardingTaskGroup |
|
|
| TaskGroup accumulating memory | Memory grows with 10K+ tasks | Process results as they arrive, don't collect all |
|
|
| Capturing mutable state in addTask | "Mutation of captured var" | Use let binding or actor |
|
|
| Not handling partial failure | Some tasks succeed, some fail | Use group.next() and handle errors individually |
|
|
| async let in loop | Compiler error — async let must be in fixed positions | Use TaskGroup instead |
|
|
| Returning from group early | Remaining tasks still run | Call group.cancelAll() before returning |
|
|
|
|
---
|
|
|
|
## Part 5: Async Sequences
|
|
|
|
### AsyncStream
|
|
|
|
Non-throwing stream for producing values over time.
|
|
|
|
```swift
|
|
let stream = AsyncStream<Int> { continuation in
|
|
for i in 0..<10 {
|
|
continuation.yield(i)
|
|
}
|
|
continuation.finish()
|
|
}
|
|
|
|
for await value in stream {
|
|
print(value)
|
|
}
|
|
```
|
|
|
|
### AsyncThrowingStream
|
|
|
|
Stream that can fail with an error.
|
|
|
|
```swift
|
|
let stream = AsyncThrowingStream<Data, Error> { continuation in
|
|
let monitor = NetworkMonitor()
|
|
monitor.onData = { data in
|
|
continuation.yield(data)
|
|
}
|
|
monitor.onError = { error in
|
|
continuation.finish(throwing: error)
|
|
}
|
|
monitor.onComplete = {
|
|
continuation.finish()
|
|
}
|
|
|
|
continuation.onTermination = { @Sendable _ in
|
|
monitor.stop()
|
|
}
|
|
|
|
monitor.start()
|
|
}
|
|
|
|
do {
|
|
for try await data in stream {
|
|
process(data)
|
|
}
|
|
} catch {
|
|
handleStreamError(error)
|
|
}
|
|
```
|
|
|
|
### Continuation API
|
|
|
|
```swift
|
|
let stream = AsyncStream<Value> { continuation in
|
|
// Emit a value
|
|
continuation.yield(value)
|
|
|
|
// End the stream normally
|
|
continuation.finish()
|
|
|
|
// Cleanup when consumer cancels or stream ends
|
|
continuation.onTermination = { @Sendable termination in
|
|
switch termination {
|
|
case .cancelled:
|
|
cleanup()
|
|
case .finished:
|
|
finalCleanup()
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// For throwing streams
|
|
let stream = AsyncThrowingStream<Value, Error> { continuation in
|
|
continuation.yield(value)
|
|
continuation.finish() // Normal end
|
|
continuation.finish(throwing: error) // End with error
|
|
}
|
|
```
|
|
|
|
### Buffering Policies
|
|
|
|
Control what happens when values are produced faster than consumed.
|
|
|
|
```swift
|
|
// Keep all values (default) — memory can grow unbounded
|
|
let stream = AsyncStream<Int>(bufferingPolicy: .unbounded) { continuation in
|
|
// ...
|
|
}
|
|
|
|
// Keep oldest N values, drop new ones when buffer is full
|
|
let stream = AsyncStream<Int>(bufferingPolicy: .bufferingOldest(100)) { continuation in
|
|
// ...
|
|
}
|
|
|
|
// Keep newest N values, drop old ones when buffer is full
|
|
let stream = AsyncStream<Int>(bufferingPolicy: .bufferingNewest(100)) { continuation in
|
|
// ...
|
|
}
|
|
```
|
|
|
|
| Policy | Behavior | Use When |
|
|
|---|---|---|
|
|
| `.unbounded` | Keeps all values | Consumer keeps up, or bounded producer |
|
|
| `.bufferingOldest(N)` | Drops new values when full | Order matters, older values have priority |
|
|
| `.bufferingNewest(N)` | Drops old values when full | Latest state matters (UI updates, sensor data) |
|
|
|
|
### Custom AsyncSequence
|
|
|
|
```swift
|
|
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 }
|
|
return current
|
|
}
|
|
}
|
|
|
|
func makeAsyncIterator() -> AsyncIterator {
|
|
AsyncIterator(limit: limit)
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
for await number in Counter(limit: 5) {
|
|
print(number) // 0, 1, 2, 3, 4
|
|
}
|
|
```
|
|
|
|
### AsyncSequence Operators
|
|
|
|
Standard operators work on any `AsyncSequence`:
|
|
|
|
```swift
|
|
// Map
|
|
for await name in users.map(\.name) { }
|
|
|
|
// Filter
|
|
for await adult in users.filter({ $0.age >= 18 }) { }
|
|
|
|
// CompactMap
|
|
for await image in urls.compactMap({ await tryLoadImage($0) }) { }
|
|
|
|
// Prefix
|
|
for await first5 in stream.prefix(5) { }
|
|
|
|
// first(where:)
|
|
let match = await stream.first(where: { $0 > threshold })
|
|
|
|
// Contains
|
|
let hasMatch = await stream.contains(where: { $0 > threshold })
|
|
|
|
// Reduce
|
|
let sum = await numbers.reduce(0, +)
|
|
```
|
|
|
|
### Built-in Async Sequences
|
|
|
|
```swift
|
|
// NotificationCenter
|
|
for await notification in NotificationCenter.default.notifications(named: .didUpdate) {
|
|
handleUpdate(notification)
|
|
}
|
|
|
|
// URLSession bytes
|
|
let (bytes, response) = try await URLSession.shared.bytes(from: url)
|
|
for try await byte in bytes {
|
|
process(byte)
|
|
}
|
|
|
|
// FileHandle bytes
|
|
for try await line in FileHandle.standardInput.bytes.lines {
|
|
process(line)
|
|
}
|
|
```
|
|
|
|
### Async Sequence Gotcha Table
|
|
|
|
| Gotcha | Symptom | Fix |
|
|
|---|---|---|
|
|
| Continuation yielded after finish | Runtime warning, value lost | Track finished state, guard before yield |
|
|
| Stream never finishing | for-await loop hangs forever | Always call continuation.finish() in all code paths |
|
|
| No onTermination handler | Resource leak when consumer cancels | Set continuation.onTermination for cleanup |
|
|
| Unbounded buffer | Memory growth under load | Use .bufferingNewest(N) or .bufferingOldest(N) |
|
|
| Multiple consumers | Only first consumer gets values | AsyncStream is single-consumer; create separate streams per consumer |
|
|
| for-await on MainActor | UI freezes waiting for values | Use Task {} to consume off the main path |
|
|
|
|
---
|
|
|
|
## Part 6: Isolation Patterns
|
|
|
|
### @MainActor on Functions
|
|
|
|
```swift
|
|
@MainActor
|
|
func updateUI() {
|
|
label.text = "Done"
|
|
}
|
|
|
|
// Call from async context
|
|
func doWork() async {
|
|
let result = await computeResult()
|
|
await updateUI() // Hops to MainActor
|
|
}
|
|
```
|
|
|
|
### MainActor.run
|
|
|
|
Explicitly execute a closure on the main actor from any context.
|
|
|
|
```swift
|
|
func processData() async {
|
|
let result = await heavyComputation()
|
|
|
|
await MainActor.run {
|
|
self.label.text = result
|
|
self.progressView.isHidden = true
|
|
}
|
|
}
|
|
```
|
|
|
|
### MainActor.assumeIsolated (iOS 17+)
|
|
|
|
Assert that code is already running on the main actor. Crashes at runtime if the assertion is false.
|
|
|
|
```swift
|
|
func legacyCallback() {
|
|
// We KNOW this is called on main thread (UIKit guarantee)
|
|
MainActor.assumeIsolated {
|
|
self.viewModel.update() // Access @MainActor state
|
|
}
|
|
}
|
|
```
|
|
|
|
See `axiom-assume-isolated` for comprehensive patterns.
|
|
|
|
### nonisolated
|
|
|
|
Opt out of the enclosing actor's isolation.
|
|
|
|
```swift
|
|
@MainActor
|
|
class ViewModel {
|
|
let id: UUID // Implicitly nonisolated (let)
|
|
|
|
nonisolated var analyticsID: String { // Explicitly nonisolated
|
|
id.uuidString
|
|
}
|
|
|
|
var items: [Item] = [] // Isolated to MainActor
|
|
}
|
|
```
|
|
|
|
### nonisolated(unsafe)
|
|
|
|
Compiler escape hatch. Tells the compiler to treat a property as if it's not isolated, without any safety guarantees.
|
|
|
|
```swift
|
|
// Use only when you have external guarantees of thread safety
|
|
nonisolated(unsafe) var legacyState: Int = 0
|
|
|
|
// Common for global constants that the compiler can't verify
|
|
nonisolated(unsafe) let formatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateStyle = .medium
|
|
return f
|
|
}()
|
|
```
|
|
|
|
**Warning**: `nonisolated(unsafe)` provides zero runtime protection. Data races will not be caught. Use only as a last resort for bridging legacy code.
|
|
|
|
### @preconcurrency
|
|
|
|
Suppress concurrency warnings for pre-concurrency APIs during migration.
|
|
|
|
```swift
|
|
// Suppress warnings for entire module
|
|
@preconcurrency import MyLegacyFramework
|
|
|
|
// Suppress for specific protocol conformance
|
|
class MyDelegate: @preconcurrency SomeLegacyDelegate {
|
|
func delegateCallback() {
|
|
// No Sendable warnings for this conformance
|
|
}
|
|
}
|
|
```
|
|
|
|
### #isolation (Swift 6.0+)
|
|
|
|
Capture the caller's isolation context so a function runs on whatever actor the caller is on.
|
|
|
|
```swift
|
|
func doWork(isolation: isolated (any Actor)? = #isolation) async {
|
|
// Runs on caller's actor — no hop if caller is already isolated
|
|
performWork()
|
|
}
|
|
|
|
// Called from @MainActor — runs on MainActor
|
|
@MainActor
|
|
func setup() async {
|
|
await doWork() // doWork runs on MainActor
|
|
}
|
|
|
|
// Called from custom actor — runs on that actor
|
|
actor MyActor {
|
|
func run() async {
|
|
await doWork() // doWork runs on MyActor
|
|
}
|
|
}
|
|
```
|
|
|
|
### #isolation Capture in Task Closures (SE-0420)
|
|
|
|
When spawning `Task` closures that need to work with non-Sendable types, capture the isolation parameter to inherit the caller's context.
|
|
|
|
```swift
|
|
func process(
|
|
delegate: NonSendableDelegate,
|
|
isolation: isolated (any Actor)? = #isolation
|
|
) {
|
|
Task {
|
|
_ = isolation // Forces capture — Task inherits caller's isolation
|
|
delegate.doWork() // ✅ Safe: running on caller's actor
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why `_ = isolation` is required**: Per SE-0420, `Task` closures only inherit isolation when a non-optional binding of an isolated parameter is captured by the closure. The `_ = isolation` statement forces this capture. Without it, the Task runs on the default executor and the non-Sendable capture is a compiler error.
|
|
|
|
**When to use**: Spawning Tasks that work with non-Sendable delegate objects, fire-and-forget async work that needs access to caller's state, or bridging callback-based APIs while keeping delegates alive.
|
|
|
|
### Isolation Gotcha Table
|
|
|
|
| Gotcha | Symptom | Fix |
|
|
|---|---|---|
|
|
| MainActor.run from MainActor | Unnecessary hop, potential deadlock risk | Check context or use assumeIsolated |
|
|
| nonisolated(unsafe) data race | Crash at runtime, corrupted state | Use proper isolation or Mutex |
|
|
| @preconcurrency hiding real issues | Runtime crashes in production | Migrate to proper concurrency before shipping |
|
|
| #isolation not available pre-5.9 | Compiler error | Use traditional @MainActor annotation |
|
|
| #isolation not captured in Task | Non-Sendable capture error | Add `_ = isolation` inside Task closure (SE-0420) |
|
|
| nonisolated on actor method | Can't access any isolated state | Only use for computed properties from non-isolated state |
|
|
| Thread.current in async context | Compiler error in Swift 6 mode | Don't rely on thread identity — reason about isolation domains |
|
|
|
|
---
|
|
|
|
## Part 7: Continuations
|
|
|
|
Bridge callback-based APIs to async/await.
|
|
|
|
### withCheckedContinuation
|
|
|
|
Non-throwing bridge.
|
|
|
|
```swift
|
|
func currentLocation() async -> CLLocation {
|
|
await withCheckedContinuation { continuation in
|
|
locationManager.requestLocation { location in
|
|
continuation.resume(returning: location)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### withCheckedThrowingContinuation
|
|
|
|
Throwing bridge.
|
|
|
|
```swift
|
|
func fetchUser(id: String) async throws -> User {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
api.fetchUser(id: id) { result in
|
|
switch result {
|
|
case .success(let user):
|
|
continuation.resume(returning: user)
|
|
case .failure(let error):
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Continuation Resume Methods
|
|
|
|
```swift
|
|
// Return a value
|
|
continuation.resume(returning: value)
|
|
|
|
// Throw an error
|
|
continuation.resume(throwing: error)
|
|
|
|
// From a Result type
|
|
continuation.resume(with: result) // Result<T, Error>
|
|
```
|
|
|
|
### Resume-Exactly-Once Rule
|
|
|
|
A continuation MUST be resumed exactly once:
|
|
- **Resuming twice** crashes with `"Continuation already resumed"` (checked) or undefined behavior (unsafe)
|
|
- **Never resuming** causes the awaiting task to hang forever — a silent leak
|
|
|
|
```swift
|
|
// DANGEROUS: callback might not be called
|
|
func riskyBridge() async throws -> Data {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
api.fetch { data, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
return
|
|
}
|
|
if let data {
|
|
continuation.resume(returning: data)
|
|
return
|
|
}
|
|
// BUG: if both are nil, continuation is never resumed
|
|
// Fix: add a fallback
|
|
continuation.resume(throwing: BridgeError.noResponse)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Bridging Delegates
|
|
|
|
```swift
|
|
class LocationBridge: NSObject, CLLocationManagerDelegate {
|
|
private var continuation: CheckedContinuation<CLLocation, Error>?
|
|
private let manager = CLLocationManager()
|
|
|
|
func requestLocation() async throws -> CLLocation {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
self.continuation = continuation
|
|
manager.delegate = self
|
|
manager.requestLocation()
|
|
}
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
continuation?.resume(returning: locations[0])
|
|
continuation = nil // Prevent double resume
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
continuation?.resume(throwing: error)
|
|
continuation = nil
|
|
}
|
|
}
|
|
```
|
|
|
|
### Unsafe Continuations
|
|
|
|
Skip runtime checks for performance. Same API as checked, but misuse causes undefined behavior instead of a diagnostic crash.
|
|
|
|
```swift
|
|
func fastBridge() async -> Data {
|
|
await withUnsafeContinuation { continuation in
|
|
// No runtime check for double-resume or missing resume
|
|
fastCallback { data in
|
|
continuation.resume(returning: data)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Use checked continuations during development, switch to unsafe only after thorough testing and when profiling shows the check is a bottleneck.**
|
|
|
|
### Continuation Gotcha Table
|
|
|
|
| Gotcha | Symptom | Fix |
|
|
|---|---|---|
|
|
| Resume called twice | "Continuation already resumed" crash | Set continuation to nil after resume |
|
|
| Resume never called | Task hangs indefinitely | Ensure all code paths resume — including error/nil cases |
|
|
| Capturing continuation | Continuation escapes scope | Store in property, ensure single resume |
|
|
| Unsafe continuation in debug | No diagnostics for misuse | Use withCheckedContinuation during development |
|
|
| Delegate called multiple times | Crash on second resume | Use AsyncStream instead of continuation for repeated callbacks |
|
|
| Callback on wrong thread | Doesn't matter for continuation | Continuations can be resumed from any thread |
|
|
|
|
---
|
|
|
|
## Part 8: Migration Patterns
|
|
|
|
Common migrations from GCD and completion handlers to Swift concurrency.
|
|
|
|
### DispatchQueue to Actor
|
|
|
|
```swift
|
|
// BEFORE: DispatchQueue for thread safety
|
|
class ImageCache {
|
|
private let queue = DispatchQueue(label: "cache", attributes: .concurrent)
|
|
private var cache: [URL: UIImage] = [:]
|
|
|
|
func get(_ url: URL, completion: @escaping (UIImage?) -> Void) {
|
|
queue.async { completion(self.cache[url]) }
|
|
}
|
|
|
|
func set(_ url: URL, image: UIImage) {
|
|
queue.async(flags: .barrier) { self.cache[url] = image }
|
|
}
|
|
}
|
|
|
|
// AFTER: Actor
|
|
actor ImageCache {
|
|
private var cache: [URL: UIImage] = [:]
|
|
|
|
func get(_ url: URL) -> UIImage? {
|
|
cache[url]
|
|
}
|
|
|
|
func set(_ url: URL, image: UIImage) {
|
|
cache[url] = image
|
|
}
|
|
}
|
|
```
|
|
|
|
### DispatchGroup to TaskGroup
|
|
|
|
```swift
|
|
// BEFORE: DispatchGroup
|
|
let group = DispatchGroup()
|
|
var results: [Data] = []
|
|
for url in urls {
|
|
group.enter()
|
|
fetch(url) { data in
|
|
results.append(data)
|
|
group.leave()
|
|
}
|
|
}
|
|
group.notify(queue: .main) { use(results) }
|
|
|
|
// AFTER: TaskGroup
|
|
let results = await withTaskGroup(of: Data.self) { group in
|
|
for url in urls {
|
|
group.addTask { await fetch(url) }
|
|
}
|
|
var collected: [Data] = []
|
|
for await data in group {
|
|
collected.append(data)
|
|
}
|
|
return collected
|
|
}
|
|
use(results)
|
|
```
|
|
|
|
### Completion Handler to async
|
|
|
|
```swift
|
|
// BEFORE
|
|
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
|
|
URLSession.shared.dataTask(with: url) { data, _, error in
|
|
if let error { completion(.failure(error)); return }
|
|
guard let data else { completion(.failure(FetchError.noData)); return }
|
|
completion(.success(data))
|
|
}.resume()
|
|
}
|
|
|
|
// AFTER
|
|
func fetchData() async throws -> Data {
|
|
let (data, _) = try await URLSession.shared.data(from: url)
|
|
return data
|
|
}
|
|
```
|
|
|
|
### @objc Delegates with @MainActor
|
|
|
|
```swift
|
|
@MainActor
|
|
class ViewController: UIViewController, UITableViewDelegate {
|
|
// @objc delegate methods inherit @MainActor isolation from the class
|
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
// Already on MainActor — safe to update UI
|
|
updateSelection(indexPath)
|
|
}
|
|
}
|
|
```
|
|
|
|
### NotificationCenter to AsyncSequence
|
|
|
|
```swift
|
|
// BEFORE
|
|
let observer = NotificationCenter.default.addObserver(
|
|
forName: .didUpdate, object: nil, queue: .main
|
|
) { notification in
|
|
handleUpdate(notification)
|
|
}
|
|
// Must remove observer in deinit
|
|
|
|
// AFTER
|
|
let task = Task {
|
|
for await notification in NotificationCenter.default.notifications(named: .didUpdate) {
|
|
await handleUpdate(notification)
|
|
}
|
|
}
|
|
// Cancel task in deinit — no manual observer removal needed
|
|
```
|
|
|
|
### Timer to AsyncSequence
|
|
|
|
```swift
|
|
// BEFORE
|
|
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
|
updateUI()
|
|
}
|
|
// Must invalidate in deinit
|
|
|
|
// AFTER
|
|
let task = Task {
|
|
while !Task.isCancelled {
|
|
await updateUI()
|
|
try? await Task.sleep(for: .seconds(1))
|
|
}
|
|
}
|
|
// Cancel task in deinit
|
|
```
|
|
|
|
### DispatchSemaphore to Actor
|
|
|
|
```swift
|
|
// BEFORE: Semaphore to limit concurrent operations
|
|
let semaphore = DispatchSemaphore(value: 3)
|
|
for url in urls {
|
|
DispatchQueue.global().async {
|
|
semaphore.wait()
|
|
defer { semaphore.signal() }
|
|
download(url)
|
|
}
|
|
}
|
|
|
|
// AFTER: TaskGroup with limited concurrency
|
|
await withTaskGroup(of: Void.self) { group in
|
|
var inFlight = 0
|
|
for url in urls {
|
|
if inFlight >= 3 {
|
|
await group.next() // Wait for one to finish
|
|
inFlight -= 1
|
|
}
|
|
group.addTask { await download(url) }
|
|
inFlight += 1
|
|
}
|
|
await group.waitForAll()
|
|
}
|
|
```
|
|
|
|
### Migration Gotcha Table
|
|
|
|
| Gotcha | Symptom | Fix |
|
|
|---|---|---|
|
|
| DispatchQueue.sync to actor | Deadlock potential | Remove .sync, use await |
|
|
| Global dispatch to actor contention | Slowdown from serialization | Profile with Concurrency Instruments |
|
|
| Legacy delegate + Sendable | "Cannot conform to Sendable" | Use @preconcurrency import or @MainActor isolation |
|
|
| Callback called multiple times | Continuation crash | Use AsyncStream instead of continuation |
|
|
| Semaphore.wait in async context | Thread starvation, potential deadlock | Use TaskGroup with manual concurrency limiting |
|
|
| DispatchQueue.main.async to MainActor | Subtle timing differences | MainActor.run is the equivalent — test edge cases |
|
|
| Replacing structured tasks with top-level Tasks | Losing cancellation propagation and error handling | Use async let or TaskGroup for related parallel work |
|
|
| Batch @unchecked Sendable to fix warnings | Hiding real data races throughout codebase | Fix one type at a time with proper Sendable, actor, or sending |
|
|
|
|
---
|
|
|
|
## API Quick Reference
|
|
|
|
| Task | API | Swift Version |
|
|
|---|---|---|
|
|
| Define isolated type | `actor MyActor { }` | 5.5+ |
|
|
| Run on main thread | `@MainActor` | 5.5+ |
|
|
| Mark as safe to share | `: Sendable` | 5.5+ |
|
|
| Mark closure safe to share | `@Sendable` | 5.5+ |
|
|
| Parallel tasks (fixed) | `async let` | 5.5+ |
|
|
| Parallel tasks (dynamic) | `withTaskGroup` | 5.5+ |
|
|
| Stream values | `AsyncStream` | 5.5+ |
|
|
| Bridge callback | `withCheckedContinuation` | 5.5+ |
|
|
| Check cancellation | `Task.checkCancellation()` | 5.5+ |
|
|
| Task-scoped values | `@TaskLocal` | 5.5+ |
|
|
| Assert isolation | `MainActor.assumeIsolated` | 5.9+ (iOS 17+) |
|
|
| Capture caller isolation | `#isolation` | 6.0+ |
|
|
| Lock-based sync | `Mutex` | 6.0+ (iOS 18+) |
|
|
| Discard results | `withDiscardingTaskGroup` | 5.9+ (iOS 17+) |
|
|
| Transfer ownership | `sending` parameter | 6.0+ |
|
|
| Force background | `@concurrent` | 6.2+ |
|
|
| Isolated conformance | `extension: @MainActor Proto` | 6.2+ |
|
|
|
|
## Resources
|
|
|
|
**WWDC**: 2021-10132, 2021-10134, 2022-110350, 2025-268
|
|
|
|
**Docs**: /swift/concurrency, /swift/actor, /swift/sendable, /swift/taskgroup
|
|
|
|
**Skills**: swift-concurrency, assume-isolated, synchronization, concurrency-profiling
|