SwiftQA Random

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 await in between) — synchronous code on an actor genuinely can’t be interrupted.
  • If you must await in the middle of a stateful operation, consider guard flags (like isProcessing above), or restructure so the async part happens first and the actor-state mutation happens as one final synchronous step after.