Saltar al contenido
Development

Cuando @MainActor y TaskGroup no se llevan bien en Swift 6

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

Esta app se renombró después a Deep Cut Atlas. Más abajo se la llama “Discoverer” en todo momento, porque así se llamaba el día que pasó esto.

Tengo una pantalla en mi app de música que básicamente es un diff gigante. Recorre cada artista de la biblioteca, le pide a Apple Music los lanzamientos de cada artista, y muestra los que todavía no están en la colección. Para una biblioteca chica son un puñado de llamadas de red. Para una biblioteca de 200 artistas son 200, y dispararlas una por una haría que la pantalla se arrastrara.

Entonces quise repartirlas de a pocas a la vez. Fácil, ¿no? Agrupar los artistas en bloques, correr cada bloque en paralelo con un task group, y seguir. Escribí lo obvio:

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

Mi service es una clase @MainActor (toca MusicKit, que exige el main actor), así que anoté el closure del task como @MainActor para que coincidiera. Parece razonable. No compila. Y el error no es el diagnóstico educado de siempre en Swift, es el compilador tirando la toalla:

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

Leer “Please file a bug” es gracioso al principio de lo que parecía una tarea de cinco minutos.

Qué está pasando en realidad

El problema está en lo que el closure captura. group.addTask recibe un closure @Sendable, pensado para correr en el cooperative thread pool, así que cualquier cosa que capture tiene que ser segura para pasar entre límites de actor. Estoy capturando self.service, que es un objeto no-Sendable, aislado al main actor.

En teoría, marcar el closure como @MainActor debería resolver eso: un closure de main actor que captura estado de main actor es seguro, porque todo se queda en el mismo actor. En la práctica, el verificador de aislamiento basado en regiones de Swift 6 no puede seguir esta forma en particular y se rinde en vez de razonarla. Así que no es que mi código esté mal, es que el verificador no puede probar que está bien.

La solución: un Task no estructurado

Lo que sí funciona es la herramienta más vieja y menos de moda: un Task {} no estructurado.

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
}

La diferencia es chica pero es toda la historia: un Task {} creado dentro de un contexto @MainActor hereda ese contexto. Su cuerpo corre en el main actor, así que capturar self está bien por construcción, no hay que probar ningún problema de sendability, porque nada sale del actor. TaskGroup.addTask no hereda el aislamiento de esa forma, y por eso justamente fallaba.

Pero un momento, ¿esto es siquiera concurrente?

Esta fue mi primera preocupación. Si cada task corre en el main actor, ¿no estoy haciendo el mismo trabajo serial con pasos extra?

No, y la razón es la parte de async/await que es fácil de olvidar: await es un punto de suspensión. Cuando uno de estos tasks llega a la llamada de red y se suspende, libera el main actor. El siguiente task del bloque corre hasta su propio await, se suspende, libera, y así. Así que las cinco solicitudes de un bloque terminan en vuelo al mismo tiempo. El main actor solo se retiene para la contabilidad barata entre suspensiones, no para los segundos que se esperan por la red.

Eso también significa que el tamaño del bloque no es un requisito del actor, es puramente un límite de cortesía para no disparar 200 solicitudes a los servidores de Apple al mismo tiempo y terminar con rate limit. Cinco a la vez, después las siguientes cinco.

Una sorpresa más de aislamiento, en las pruebas

Este proyecto compila con el aislamiento de actor por defecto en MainActor, algo cada vez más común en apps de SwiftUI. Una consecuencia que no esperaba: los structs comunes y sus inicializadores también quedan aislados al main actor. Así que una suite de Swift Testing que no está en el main actor ni siquiera puede construir mis tipos de modelo:

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

La solución es una línea: anotar el struct de prueba como @MainActor para que viva en el mismo dominio de aislamiento que el código que está probando. Obvio en retrospectiva, pero es el tipo de cosa que hace releer las definiciones del modelo buscando un error que no existe.

Qué me llevo de esto

La concurrencia estructurada es la opción correcta por defecto, y la mayoría de las veces un task group es lo que hace falta. Pero “la herramienta moderna” y “la herramienta que compila para esta forma exacta” no siempre son lo mismo, y un Task no estructurado no es un code smell, acá fue la respuesta más limpia justamente porque hereda el contexto del actor.

Y cuando el verificador de Swift 6 dice que hay que reportar un bug, hay que tomarlo tal cual: no siempre es un veredicto sobre el código. A veces solo significa que se encontró una forma que todavía no puede razonar, y hay un camino perfectamente correcto a pocos caracteres de distancia.

Lecturas relacionadas