La trampa de aislamiento de actores en "simplemente sácalo del hilo principal"
Esta app fue renombrada más tarde a Deep Cut Atlas. A continuación se llama “Discoverer” porque así se llamaba el día en que esto pasó.
Pasé parte de esta semana arreglando trabajo desperdiciado en Discover, la pestaña que muestra rarezas de los artistas de tu biblioteca. Un arreglo que parecía trivial en el papel se convirtió en un pequeño recorrido por las reglas de concurrencia más estrictas de Swift 6.
El contexto: el caché en disco de Discover hacía una decodificación JSON bloqueante directo en el hilo principal, cada vez que la pestaña revisaba si su caché había quedado desactualizado. No es enorme por sí solo, pero pasaba en cada regreso a primer plano, y el archivo crece con tu biblioteca. El arreglo parecía obvio. Envolver la decodificación en una tarea separada (detached task) para que corra fuera del actor principal.
No compiló.
main actor-isolated conformance of 'DiscoveryFeed' to 'Decodable' cannot be used in nonisolated context
Esta app está construida con aislamiento de actor por defecto configurado a MainActor, lo que significa que cada tipo plano del módulo está implícitamente atado al actor principal a menos que se indique lo contrario. Esa parte ya la sabía. Lo que no había internalizado es que también aplica al código que sintetiza el compilador. La conformidad Codable generada automáticamente para un struct plano, los métodos encode y decode que nunca se escriben directamente, heredan ese mismo aislamiento. Las propiedades almacenadas de mi struct eran todas Sendable. No importó. El struct en sí, y por lo tanto su decodificador sintetizado, estaba aislado al actor principal, y una tarea separada por definición no está en el actor principal.
El arreglo es una sola palabra: marcar el tipo como nonisolated. No un método, el tipo completo. Y es contagioso en la dirección que esperarías: mi struct de caché de nivel superior contenía un arreglo de un struct anidado, que a su vez contenía un arreglo de un modelo de álbum usado en otras partes de la app. Los tres necesitaban la anotación, porque Swift revisa el aislamiento de cualquier tipo que en realidad esté haciendo la codificación, no solo el de sus propiedades finales.
Después vino un segundo muro, sin relación con el primero. Quería que dos llamadas de red independientes arrancaran de forma concurrente en vez de una tras otra, así que recurrí a async let, la forma de libro de texto de hacer justamente eso. Otro error de compilación, esta vez sobre un “non-Sendable type… cannot exit main actor-isolated context.” Resulta que async let siempre levanta una tarea hija real por debajo, y una tarea hija tiene los mismos requisitos que cualquier otra tarea concurrente: lo que sea que capture necesita ser demostrablemente seguro de entregar. Mi servicio estaba aislado al actor principal por diseño, lo que normalmente lo hace seguro de llamar desde cualquier parte de la app, pero esa seguridad no se transfiere automáticamente a una tarea nueva, incluso una que va a llamar de vuelta inmediatamente al mismo actor. El arreglo que funcionó fue más feo de lo que quería: un Task { } plano y sin estructura, que hereda el contexto del actor que llama en vez de pedir uno nuevo. Menos elegante que async let, pero es el patrón que el resto de esta base de código ya usa por la misma razón, así que al menos ahora es consistente.
Ninguno de los dos errores era realmente sobre código mal escrito. Ambos eran sobre código que “se ve puro” pero depende en secreto de una configuración de build que casi nadie revisa. La lección que sigo reaprendiendo con la concurrencia de Swift 6: no razones sobre el aislamiento a partir de cómo se ve un tipo en la página. Hay que razonar a partir del valor por defecto real del proyecto y revisar cada frontera que cruza un valor, porque el compilador deja escribir sin problema algo con lo que el sistema de tipos ya no estaba de acuerdo, hasta el momento en que se intenta correrlo en un lugar nuevo.
Lecturas relacionadas
Cuando @MainActor y TaskGroup no se llevan bien en Swift 6
El verificador de aislamiento basado en regiones me dice que reporte un bug, y el Task no estructurado, pasado de moda, resulta ser la herramienta correcta.
Cierre de las brechas de pruebas: lógica pura, una rama simulada inalcanzable y un cambio a Swift 6
Extracción de algoritmos fuera del alcance de MusicKit, un parámetro simulado que se ignoraba en silencio, y dos pruebas que habían estado pasando por la razón equivocada todo este tiempo.
Carreras en el main actor: cuando await es la condición de carrera
@MainActor elimina las condiciones de carrera de datos, no las de intercalado. Un token de generación, una protección contra fallos obsoletos, y una prueba de carrera determinista sin sleeps.