Threading
Use this when:
- You need to understand the relationship between tasks and threads.
- You are debugging suspension points, actor reentrancy, or unexpected execution contexts.
- You need Swift 6.2 behavior guidance (
nonisolated async,@concurrent,nonisolated(nonsending)).
Skip this file if:
- You mainly need to protect mutable state. Use
actors.md. - You need to make types safe to transfer. Use
sendable.md.
Jump to:
- Core Concepts (Tasks vs Threads)
- Cooperative Thread Pool
- Suspension Points and Actor Reentrancy
- Swift 6.2 Changes (SE-461, SE-466)
- Default Isolation Domain
- Debugging Thread Execution
- Common Misconceptions
- Migration Strategy
Core Concepts
What is a Thread?
System-level resource that runs instructions. High overhead for creation and switching. Swift Concurrency abstracts thread management away.
Tasks vs Threads
Tasks are units of async work, not tied to specific threads. Swift dynamically schedules tasks on available threads from a cooperative pool.
Key insight: No direct relationship between one task and one thread.
Course Deep Dive: This topic is covered in detail in Lesson 7.1: How Threads relate to Tasks
Important (Swift 6+): Avoid using Thread.current inside async contexts. In Swift 6 language mode, Thread.current is unavailable from asynchronous contexts and will fail to compile. Prefer reasoning in terms of isolation domains; use Instruments and the debugger to observe execution when needed.
Cooperative Thread Pool
Swift creates only as many threads as CPU cores. Tasks share these threads efficiently.
How it works
- Limited threads: Number matches CPU cores
- Task scheduling: Tasks scheduled onto available threads
- Suspension: At
await, task suspends, thread freed for other work - Resumption: Task resumes on any available thread (not necessarily the same one)
func example() async {
print("Started on: \(Thread.current)")
try await Task.sleep(for: .seconds(1))
print("Resumed on: \(Thread.current)") // Likely different thread
}Benefits over GCD
Prevents thread explosion:
- No excessive thread creation
- No high memory overhead from idle threads
- No excessive context switching
- No priority inversion
Better performance:
- Fewer threads = less context switching
- Continuations instead of blocking
- CPU cores stay busy efficiently
Threading Mindset → Isolation Mindset
Old way (GCD)
// Thinking about threads
DispatchQueue.main.async {
// Update UI on main thread
}
DispatchQueue.global(qos: .background).async {
// Heavy work on background thread
}New way (Swift Concurrency)
// Thinking about isolation domains
@MainActor
func updateUI() {
// Runs on main actor (usually main thread)
}
func heavyWork() async {
// Runs on any available thread in pool
}Think in isolation domains
Don't ask: "What thread should this run on?"
Ask: "What isolation domain should own this work?"
@MainActorfor UI updates- Custom actors for specific state
- Nonisolated for general async work
Provide hints, not commands
Task(priority: .userInitiated) {
await doWork()
}You're describing the nature of work, not assigning threads. Swift optimizes execution.
Course Deep Dive: This topic is covered in detail in Lesson 7.2: Getting rid of the "Threading Mindset"
Suspension Points
What is a suspension point?
Moment where task may pause to allow other work. Marked by await.
let data = await fetchData() // Potential suspensionCritical: await marks *possible* suspension, not guaranteed. If operation completes synchronously, no suspension occurs.
Why suspension points matter
- Code may pause unexpectedly - resumes later, possibly different thread
- State can change - mutable state may be modified during suspension
- Actor reentrancy - other tasks can access actor during suspension
The same entry-isolation rule applies to any unstructured task: choose startup isolation by what the synchronous prefix needs. If nothing before the first await needs the main actor—whether that first operation is Task.sleep, an actor hop, a print, or a Sendable computation—prefer Task { @concurrent in ... } and hop back with MainActor.run only for the UI mutation. If the synchronous prefix already needs main actor for one statement, keep nearby cheap lines on main with it instead of splitting them out.
Actor reentrancy example
actor BankAccount {
private var balance: Int = 0
func deposit(amount: Int) async {
balance += amount
print("Balance: \(balance)")
await logTransaction(amount) // ⚠️ Suspension point
balance += 10 // Bonus
print("After bonus: \(balance)")
}
func logTransaction(_ amount: Int) async {
try? await Task.sleep(for: .seconds(1))
}
}
// Two concurrent deposits
async let _ = account.deposit(amount: 100)
async let _ = account.deposit(amount: 100)
// Unexpected: 100 → 200 → 210 → 220
// Expected: 100 → 110 → 210 → 220Why: During logTransaction, second deposit runs, modifying balance before first completes.
Avoiding reentrancy bugs
Complete actor work before suspending:
func deposit(amount: Int) async {
balance += amount
balance += 10 // Bonus applied first
print("Final balance: \(balance)")
await logTransaction(amount) // Suspend after state changes
}Rule: Don't mutate actor state after suspension points.
Course Deep Dive: This topic is covered in detail in Lesson 7.3: Understanding Task suspension points
Choosing Task entry isolation
For unstructured Task { ... }, choose entry isolation based on the synchronous prefix (everything before the first await), not on where the task was created.
Two common reasons a bare Task { ... } starts on @MainActor:
- The task is spawned from a
@MainActorcontext. - The module enables default main-actor isolation (for example,
defaultIsolation(MainActor.self)).
Rule:
- If the synchronous prefix contains any main-actor work, keep inherited main-actor entry.
- If the synchronous prefix contains no main-actor work, start with
Task { @concurrent in ... }and hop back toMainActoronly when needed.
// ❌ Synchronous prefix is empty; first work hops away
Task {
await hopToOtherIsolationDomain()
}
// ❌ Synchronous prefix is only `print` (trivial, non-main); first await hops away
Task {
print("Also not main-thread-bound")
await hopToOtherIsolationDomain()
}
// ✅ Start off the main actor, hop back only for UI work
Task { @concurrent in
await hopToOtherIsolationDomain()
await MainActor.run { updateUI() }
}
// ✅ Synchronous prefix DOES contain main-actor work — keep inheritance
Task {
print("debug") // trivial, non-main — rides along
self.isLoading = true // needs @MainActor, before any await
await fetchData()
}The delayed-retry Task.sleep pattern (see performance.md "Match Task entry isolation to its synchronous prefix") is a specialization of this same rule: the wait is usually not UI-owned, while the final mutation is.
Note that Task { @concurrent in ... } changes the closure's isolation, so any capture of non-Sendable state from the enclosing actor must move inside the MainActor.run { ... } hop, or be captured weakly (e.g., [weak self] plus a guard let self) before being used there. The examples above stay safe by keeping self use inside MainActor.run. If the body needs to touch non-Sendable state directly, see sendable.md before reaching for @concurrent.
Thread Execution Patterns
Default: Background threads
Tasks run on cooperative thread pool (background threads):
Task {
print(Thread.current) // Background thread
}Main thread execution
Use @MainActor for main thread:
@MainActor
func updateUI() {
Task {
print(Thread.current) // Main thread
}
}Inheritance example
@MainActor
func updateUI() {
print("Main thread: \(Thread.current)")
await backgroundTask() // Switches to background
print("Back on main: \(Thread.current)") // Returns to main
}
func backgroundTask() async {
print("Background: \(Thread.current)")
}Swift 6.2 Changes
Nonisolated async functions (SE-461)
Old behavior: Nonisolated async functions always switch to background.
New behavior: Inherit caller's isolation by default.
class NotSendable {
func performAsync() async {
print(Thread.current)
}
}
@MainActor
func caller() async {
let obj = NotSendable()
await obj.performAsync()
// Old: Background thread
// New: Main thread (inherits @MainActor)
}Enabling new behavior
In Xcode 16+:
// Build setting or swift-settings
.enableUpcomingFeature("NonisolatedNonsendingByDefault")Opting out with @concurrent
Force function to switch away from caller's isolation:
@concurrent
func performAsync() async {
print(Thread.current) // Always background
}nonisolated(nonsending)
Prevent sending non-Sendable values across isolation:
nonisolated(nonsending) func storeTouch(...) async {
// Runs on caller's isolation, no value sending
}Course Deep Dive: This topic is covered in detail in Lesson 7.4: Dispatching to different threads using nonisolated(nonsending) and @concurrent (Updated for Swift 6.2)
Use when: Method doesn't need to switch isolation, avoiding Sendable requirements.
Default Isolation Domain (SE-466)
Configuring default isolation
Build setting (Xcode 16+):
- Default Actor Isolation:
MainActororNone
Swift Package:
.target(
name: "MyTarget",
swiftSettings: [
.defaultIsolation(MainActor.self)
]
)Why change default?
Most app code runs on main thread. Setting @MainActor as default:
- Reduces false warnings
- Avoids "concurrency rabbit hole"
- Makes migration easier
Inference with @MainActor default
// With @MainActor as default:
func f() {} // Inferred: @MainActor
class C {
init() {} // Inferred: @MainActor
static var value = 10 // Inferred: @MainActor
}
@MyActor
struct S {
func f() {} // Inferred: @MyActor (explicit override)
}
> **Course Deep Dive**: This topic is covered in detail in [Lesson 7.5: Controlling the default isolation domain (Updated for Swift 6.2)](https://www.swiftconcurrencycourse.com?utm_source=github&utm_medium=agent-skill&utm_campaign=lesson-reference)Per-module setting
Must opt in for each module/package. Not global across dependencies.
Backward compatibility
Opt-in only. Default remains nonisolated if not specified.
Debugging Thread Execution
Print current thread
⚠️ Important: Thread.current is unavailable in Swift 6 language mode from async contexts. The compiler error states: "Class property 'current' is unavailable from asynchronous contexts; Thread.current cannot be used from async contexts."
Workaround (Swift 6+ mode only):
extension Thread {
public static var currentThread: Thread {
Thread.current
}
}
print("Thread: \(Thread.currentThread)")Debug navigator
- Set breakpoint in task
- Debug → Pause
- Check Debug Navigator for thread info
Verify main thread
assert(Thread.isMainThread)Common Misconceptions
❌ Each Task runs on new thread
Wrong. Tasks share limited thread pool, reuse threads.
❌ await blocks the thread
Wrong. await suspends task without blocking thread. Other tasks can use the thread.
❌ Task execution order is guaranteed
Wrong. Tasks execute based on system scheduling. Use await to enforce order.
❌ Same task = same thread
Wrong. Task can resume on different thread after suspension.
Why Sendable Matters
Since tasks move between threads unpredictably:
func example() async {
print("Thread 1: \(Thread.current)")
await someWork()
print("Thread 2: \(Thread.current)") // Different thread
}Values crossing suspension points may cross threads. Sendable ensures safety.
Best Practices
- Stop thinking about threads - think isolation domains
- Trust the system - Swift optimizes thread usage
- Use @MainActor for UI - clear, explicit main thread execution
- Minimize suspension points in actors - avoid reentrancy bugs
- Complete state changes before suspending - prevent inconsistent state
- Use priorities as hints - not guarantees
- Make types Sendable - safe across thread boundaries
- Enable Swift 6.2 features - easier migration, better defaults
- Set default isolation for apps - reduce false warnings
- Don't force thread switching - let Swift optimize
Migration Strategy
For new projects (Xcode 16+)
- Set default isolation to
@MainActor - Enable
NonisolatedNonsendingByDefault - Use
@concurrentfor explicit background work
For existing projects
- Gradually enable Swift 6 language mode
- Consider default isolation change
- Use
@concurrentto maintain old behavior where needed - Migrate module by module
Decision Tree
Need to control execution?
├─ UI updates? → @MainActor
├─ Specific state isolation? → Custom actor
├─ Background work? → Regular async (trust Swift)
└─ Need to force background? → @concurrent (Swift 6.2+)
Seeing Sendable warnings?
├─ Can make type Sendable? → Add conformance
├─ Same isolation OK? → nonisolated(nonsending)
└─ Need different isolation? → Make Sendable or refactorGCD to Isolation Domain Migration
Instead of asking "what thread should this run on?" ask "what isolation domain should own this work?"
DispatchQueue.main.async { }→@MainActor func updateUI()DispatchQueue.global().async { }→func work() async(or@concurrentif it must leave caller isolation)DispatchQueue(label:).sync { }→actororMutexfor protecting state- Serial queue for ordering →
actor(guarantees serial access)
Decision Rules
- UI state → usually
@MainActor - Mutable shared state → usually an
actor - Plain async work with no isolated state →
asyncAPI with explicit ownership - Work that must hop away from caller isolation under Swift 6.2-era behavior → consider
@concurrent
Common Mistakes Agents Make
- Recommending GCD queue hopping when actor isolation already expresses the ownership model.
- Debugging correctness by thread ID instead of by isolation and ordering.
- Treating
awaitas a blocking call — it suspends the task, freeing the thread. - Mapping each
Taskto a conceptual thread. - Picking task entry isolation by the enclosing context instead of by the task's synchronous prefix. A
Task { ... }from@MainActorwhose firstawaitimmediately hops away (with no main-actor work before it) should usually beTask { @concurrent in ... }.
Performance Insights
Why fewer threads = better performance
- Less context switching: CPU spends more time on actual work
- Better cache utilization: Threads stay on same cores longer
- No thread explosion: Predictable resource usage
- Forward progress: Threads never block, always productive
Cooperative pool advantages
- Matches hardware (one thread per core)
- Prevents oversubscription
- Efficient task scheduling
- Automatic load balancing
Further Learning
For migration strategies, real-world examples, and advanced threading patterns, see Swift Concurrency Course.