Skip to content
Development

When @MainActor and TaskGroup don't mix in Swift 6

By Victor Da Luz
iosswiftswift-concurrencydev-logdeep-cut-atlas

This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.

I have a screen in my music app that’s basically one big diff. It walks every artist in your library, asks Apple Music for each artist’s releases, and shows you the ones you don’t own yet. For a small library that’s a handful of network calls. For a 200-artist library it’s 200, and firing them one at a time would make the screen crawl.

So I wanted to fan them out a few at a time. Easy, right? Chunk the artists, run each chunk concurrently with a task group, move on. I wrote the obvious thing:

await withTaskGroup(of: [Release].self) { group in
    for artist in chunk {
        group.addTask { @MainActor in
            (try? await self.service.fetchReleases(for: artist)) ?? []
        }
    }
    // collect...
}

My service is a @MainActor class (it touches MusicKit, which wants the main actor), so I annotated the task closure @MainActor to match. Looks reasonable. It does not compile. And the error isn’t the usual polite Swift diagnostic - it’s the compiler throwing up its hands:

pattern that the region-based isolation checker does not
understand how to check. Please file a bug
    group.addTask { @MainActor in
    ^

“Please file a bug” is a fun thing to read at the start of what you thought was a five-minute task.

What’s actually going on

The catch is what the closure captures. group.addTask takes a @Sendable closure - it’s meant to run on the cooperative thread pool, so anything it captures has to be safe to hand across actor boundaries. I’m capturing self.service, which is a non-Sendable, main-actor-isolated object.

In theory, marking the closure @MainActor should resolve that: a main-actor closure capturing main-actor state is safe, because it all stays on the same actor. In practice, the Swift 6 region-based isolation checker can’t follow this particular shape and bails instead of reasoning it through. So it’s not that my code is wrong - it’s that the checker can’t prove it’s right.

The fix: an unstructured Task

The thing that does work is the older, less fashionable tool: an unstructured Task {}.

private func fetchAll(_ artists: [Artist]) async -> [Release] {
    var out: [Release] = []
    for chunk in artists.chunked(into: 5) {
        let tasks = chunk.map { artist in
            Task { () -> [Release] in
                (try? await self.service.fetchReleases(for: artist)) ?? []
            }
        }
        for task in tasks { out.append(contentsOf: await task.value) }
    }
    return out
}

The difference is small but it’s the whole story: a Task {} created inside a @MainActor context inherits that context. Its body runs on the main actor, so capturing self is fine by construction - no sendability problem to prove, because nothing leaves the actor. TaskGroup.addTask doesn’t inherit isolation that way, which is exactly why it tripped.

But wait, is this even concurrent?

This was my first worry. If every task runs on the main actor, am I just doing the same serial work with extra steps?

No, and the reason is the part of async/await that’s easy to forget: await is a suspension point. When one of these tasks hits the network call and suspends, it releases the main actor. The next task in the chunk gets to run up to its own await, suspends, releases, and so on. So all five requests in a chunk end up in flight at once. The main actor is only ever held for the cheap bookkeeping between suspensions, not for the seconds spent waiting on the network.

That also means the chunk size isn’t an actor requirement - it’s purely a politeness limit so I don’t fire 200 requests at Apple’s servers simultaneously and get rate-limited. Five at a time, then the next five.

One more isolation surprise, in the tests

This project builds with default actor isolation set to MainActor, which is increasingly common for SwiftUI apps. A consequence I didn’t expect: plain structs and their initializers become main-actor-isolated too. So a Swift Testing suite that isn’t on the main actor can’t even construct my model types:

call to main actor-isolated initializer '...'
in a synchronous nonisolated context

The fix is a one-liner - annotate the test struct @MainActor so it lives in the same isolation domain as the code it’s testing. Obvious in hindsight, but it’s the kind of thing that sends you re-reading your model definitions looking for a mistake that isn’t there.

What I took away

Structured concurrency is the right default, and most of the time a task group is what you want. But “the modern tool” and “the tool that compiles for this exact shape” aren’t always the same, and an unstructured Task isn’t a code smell - here it was the cleaner answer specifically because it inherits actor context.

And when the Swift 6 checker tells you to file a bug, take it at face value: it’s not always a verdict on your code. Sometimes it just means you’ve found a shape it can’t reason about yet, and there’s a perfectly correct path a few characters away.

Related reading