Task vs Task.detached, and what is actor reentrancy?
These are the areas where the mental model of “isolation vs suspension” actually gets tested.
1. Task { } inherits the actor context
When you create a Task from within a @MainActor context, the task inherits @MainActor isolation by default:
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func onButtonTap() {
Task {
// this closure is @MainActor-isolated automatically
items = await repository.fetchItems() // no explicit await needed for `items` access
}
}
}
This is a common source of confusion: people expect Task { } to always jump to a background thread, but it doesn’t — it just creates a new unit of concurrency that starts on whatever actor it was created from. If onButtonTap() is main-actor code, the task body starts on the main actor too, and only the internal await repository.fetchItems() call potentially hops off.
2. Task.detached breaks that inheritance
func onButtonTap() {
Task.detached {
// NOT @MainActor here — no inherited isolation, no priority inheritance either
let items = await repository.fetchItems()
await MainActor.run {
self.items = items // must explicitly hop back
}
}
}
Task.detached is rarely what you want. It doesn’t inherit actor context, task-local values, or priority. Apple’s own guidance (and most Swift 6 style) is: prefer plain Task { } unless you specifically need to escape the current context — e.g. a fire-and-forget background job that shouldn’t be tied to the caller’s actor or priority.
3. Reentrancy — the sharp edge
Actors (including MainActor) are not like locks. Suspension points are reentrancy points: while a @MainActor function is suspended at an await, other @MainActor code can run and mutate shared state before your function resumes. This trips people up:
@MainActor
class Counter {
var value = 0
var isProcessing = false
func process() async {
guard !isProcessing else { return }
isProcessing = true
let result = await slowComputation() // <- suspension point!
// Between the line above and this one, other @MainActor
// methods could have run, e.g. someone else called process()
// and read isProcessing before you set it back to false...
value += result
isProcessing = false
}
}
In this example the guard flag protects you correctly (since isProcessing is set to true before the await, and checked at the top), but the general lesson stands: any state you read before an await might be stale by the time you resume, because other work interleaved on the same actor during the suspension.
A more dangerous version:
func process() async {
let oldValue = value
let result = await slowComputation()
value = oldValue + result // ⚠️ if something else mutated `value`
// during the await, this clobbers it
}
Rule of thumb: re-read actor state after an await if you’re going to use it, don’t cache it across a suspension point unless you’re sure nothing else can touch it.
4. Practical implications for Swift 6 strict concurrency
- The compiler won’t catch reentrancy bugs — they’re a logic error, not a data race, so
Sendable/isolation checking doesn’t save you here. - If you need atomic multi-step actor mutations, keep them synchronous (no
awaitin between) — synchronous code on an actor genuinely can’t be interrupted. - If you must
awaitin the middle of a stateful operation, consider guard flags (likeisProcessingabove), or restructure so the async part happens first and the actor-state mutation happens as one final synchronous step after.