wandres.dev
CONCURRENCIA ESTRUCTURADA · tasks y grupos

Task y Task.detached: salir del árbol y qué cuesta

Las dos formas de crear trabajo fuera de la concurrencia estructurada: qué hereda una `Task` sin estructura, qué renuncia `Task.detached`, por qué el manejador es la única forma de esperarla o cancelarla, y los pocos casos en los que salir del árbol está justificado.

⏱ 18 min

Toda la maquinaria de la concurrencia estructurada descansa sobre una condición previa: para crear una hija hace falta un padre, y para tener un padre hace falta estar ya dentro de un contexto asíncrono. Pero los programas reales empiezan siempre en algún punto síncrono —el arranque de la aplicación, el cuerpo de un botón, el método de un delegado del sistema— y ese punto necesita una puerta de entrada al mundo asíncrono. Esa puerta es Task, y su precio es exacto y conocido: lo que se crea con ella no es una hija sino una raíz nueva, un árbol independiente cuyo ciclo de vida nadie vigila salvo el manejador que decidas guardar. Task.detached va un paso más allá y renuncia también a lo poco que la raíz aún heredaba. Entender qué se pierde en cada escalón es lo que separa el uso legítimo del uso que reintroduce, con sintaxis moderna, los problemas de 2015.

🎯 Al terminar esta lección sabrás
  • Distinguir tarea hija, tarea sin estructura y tarea desligada por lo que heredan y por quién las posee.
  • Usar el manejador de una Task para esperar su valor, capturar su error y cancelarla.
  • Enumerar con precisión qué pierde Task.detached y qué consecuencias tiene cada pérdida.
  • Reconocer los casos legítimos de salida del árbol y los antipatrones que se disfrazan de ellos.

La raíz nueva: qué hereda y qué no

Crear una Task desde un contexto síncrono es lo esperable y correcto. Lo que conviene tener claro es que el resultado no cuelga de nadie.

@MainActor
final class VistaModelo: ObservableObject {
    @Published var perfil: Perfil?
    private var carga: Task<Void, Never>?          // guardar el manejador

    func cargar() {                                 // metodo sincrono
        carga = Task {                              // raiz nueva, no hija
            let p = try? await api.perfil()
            self.perfil = p                         // hereda MainActor: sin salto
        }
    }

    func cancelar() { carga?.cancel(); carga = nil }
}

Una Task creada así hereda del contexto: el actor en el que se declaró, la prioridad efectiva y los valores locales de tarea. Por eso la asignación a self.perfil no necesita ningún salto explícito al actor principal. Lo que no hereda es la relación de propiedad: nadie espera su final, su error no se propaga a ninguna parte y, sobre todo, no se cancela cuando se cancela el contexto que la creó.

De ahí la regla práctica: si creas una Task, guarda el manejador o justifica por escrito por qué no hace falta. El manejador es lo único que te devuelve las tres capacidades que el árbol daba gratis.

let t = Task { try await calcular() }
let valor = try await t.value      // esperar y recoger el error
t.cancel()                         // cancelar
⚠️
El manejador descartado es la fuga moderna

Task está marcada como descartable, así que ignorar su resultado no produce ningún aviso del compilador. Un Task sin manejador guardado dentro de una vista es indistinguible, en comportamiento, de un hilo suelto: sigue vivo cuando la vista desaparece, sigue consumiendo red y batería, y puede escribir sobre un modelo que ya nadie observa.

Task.detached: la renuncia completa

La variante desligada corta también la herencia de contexto. No hereda el actor, no hereda la prioridad y no hereda los valores locales de tarea; nace en el estado por defecto del sistema, como si la hubiera creado el arranque del proceso.

Task.detached(priority: .background) {
    await indexador.reconstruirIndice()     // no hereda MainActor ni prioridad
}

Cada renuncia tiene una consecuencia concreta. Perder el actor significa que todo acceso a estado aislado exige ahora await explícito y que el compilador dejará de ayudarte con las suposiciones que hacía dentro del actor. Perder la prioridad significa que el trabajo puede ejecutarse a la prioridad por defecto aunque lo lanzara la interfaz, y que no habrá elevación de prioridad si alguien importante espera. Perder los valores locales significa que los identificadores de traza, de petición o de sesión desaparecen, con el efecto práctico de que los registros del subárbol quedan desconectados del suceso que los originó.

flowchart TB
P[Contexto padre aislado y con prioridad] --> H[Hija: async let o addTask]
P --> T[Task sin estructura]
P --> D[Task detached]
H --> H1[Hereda todo y es esperada por el ambito]
T --> T1[Hereda actor prioridad y locales]
T --> T2[No es esperada ni cancelada por el padre]
D --> D1[No hereda nada]
D --> D2[Solo el manejador la controla]
style H1 fill:#a6e3a1,color:#11111b
style D1 fill:#f38ba8,color:#11111b

Un matiz que se olvida a menudo: la única diferencia relevante en coste entre ambas es la herencia, no la creación. Task.detached no es «más ligera» ni «más rápida»; simplemente empieza con menos contexto. Elegirla por rendimiento es elegirla por el motivo equivocado.

Hay, eso sí, una propiedad que ambas comparten y que a veces es justo lo que se busca: una tarea raíz no nace cancelada aunque el contexto que la crea esté cancelado. Esa es la razón por la que la limpieza asíncrona se escribe con una tarea nueva y no con una hija.

// dentro de una clausura de terminacion o de un defer en tarea cancelada
Task { await sesion.cerrar() }      // raiz nueva: no hereda la marca

El precio de esa inmunidad es coherente con todo lo anterior: nadie espera ese cierre. Si el programa necesita garantizar que la sesión quedó cerrada antes de continuar, la tarea suelta no basta y hace falta un ámbito que la contenga.

Cuándo salir del árbol está justificado

Los casos legítimos son pocos y reconocibles. La puerta de entrada: un contexto síncrono que debe iniciar trabajo asíncrono, como el cuerpo de una acción de interfaz o un método de un delegado del sistema; aquí Task es obligatoria y correcta. El trabajo que debe sobrevivir a su origen deliberadamente: enviar una analítica, confirmar una escritura o cerrar una sesión cuando la pantalla que lo pidió ya se ha ido; aquí el desacoplamiento es el objetivo, no un descuido. La limpieza asíncrona desde un contexto cancelado: dentro de una clausura de terminación, una Task nueva no nace cancelada y puede completar su trabajo, cosa que una hija no podría.

El caso propio de Task.detached es aún más estrecho: trabajo de fondo, largo y verdaderamente independiente, que no debe robar prioridad a la interfaz ni quedar atado a un actor. Reindexar una base de datos local o comprimir un archivo grande encajan; casi nada más lo hace.

Los antipatrones, en cambio, son abundantes. Usar Task.detached para «salir del actor principal» es innecesario: el cuerpo de una tarea no aislada ya se ejecuta fuera de él, y basta con no marcarlo. Usar Task dentro de un bucle para paralelizar es reinventar el grupo sin sus garantías, y además sin límite de concurrencia.

// antipatron: n raices sin dueno, sin espera, sin cancelacion comun
for url in urls { Task { await descargar(url) } }

// correcto: n hijas de un unico ambito
await withTaskGroup(of: Void.self) { grupo in
    for url in urls { grupo.addTask { await descargar(url) } }
}

Y capturar self de forma fuerte en una tarea desligada de larga duración prolonga la vida del objeto exactamente hasta donde no querías: la tarea es una raíz, nadie la cancela, y el objeto vive mientras ella viva.

Devolverle un dueño a la raíz

Como la raíz no tiene padre, hay que darle uno a mano, y el catálogo de formas de hacerlo es corto. La primera y mejor es delegar en un ámbito que ya exista: en SwiftUI, el modificador de tarea ata la raíz al ciclo de vida de la vista y la cancela al desaparecer, sin ninguna variable de por medio.

.task { await modelo.cargar() }      // creada y cancelada por la vista

La segunda es guardar el manejador y cancelarlo en el punto donde el trabajo deja de importar, que casi nunca es el destructor del objeto sino un suceso del dominio: cerrar la sesión, cambiar de documento, salir de la pantalla. Conviene además cancelar la anterior antes de empezar una nueva, porque el error de dejar dos cargas compitiendo por el mismo campo es más frecuente que el de no cancelar ninguna.

func recargar() {
    carga?.cancel()                   // no dejes dos vivas escribiendo lo mismo
    carga = Task { await cargar() }
}

La tercera, para trabajo que debe convivir con varias raíces, es sustituirlas por un único ámbito propio: una función asíncrona de larga duración que abra un grupo y añada hijas según hagan falta. Cuesta más escribirla, pero devuelve de golpe las tres garantías perdidas.

🚪

Puerta, no atajo

Task existe para cruzar de lo síncrono a lo asíncrono una sola vez, en el borde del sistema. Dentro ya del mundo asíncrono, prefiere hijas.

🧾

Guarda el recibo

El manejador es el único vínculo con el trabajo. Sin él no hay espera, ni error recogido, ni cancelación posible.

✂️

Desligar es afirmar

Task.detached declara que este trabajo no pertenece a nadie. Si esa frase no describe tu caso, no es la herramienta.

Toda estructura necesita una puerta, y toda puerta necesita un portero

Ningún sistema de garantías es cerrado: siempre existe un borde donde el mundo verificado se encuentra con el que no lo está, y el diseño maduro no consiste en negar ese borde sino en hacerlo estrecho, visible y nominal. Es el mismo patrón que aparece en unsafe dentro de Rust, en las primitivas de asignación bajo un recolector de basura o en la interfaz con C bajo cualquier lenguaje seguro: una zona pequeña, marcada con una palabra que se ve al leer, donde las invariantes se sostienen por argumento humano en lugar de por comprobación mecánica. Task y Task.detached son esa zona en la concurrencia de Swift, y lo importante no es evitarlas —serían inevitables, porque un programa empieza siempre en algún sitio síncrono— sino tratarlas como lo que son: obligaciones de prueba trasladadas al programador. Cada vez que escribes una de las dos estás afirmando tres cosas que el compilador ya no verificará: que sabes quién es el dueño del trabajo, que sabes en qué punto del programa dejará de tener sentido, y que existe alguien que lo cancelará entonces. Si guardas el manejador y lo cancelas en el momento correcto, has cumplido la obligación a mano y el sistema sigue siendo razonable; si lo descartas, no has escrito código concurrente sino una promesa vacía, y su incumplimiento aparecerá tarde, como suele: como consumo de batería que nadie explica, como un modelo que se actualiza después de cerrarse la pantalla, como un registro que ya no puede correlacionarse con la petición que lo originó. De ahí que la disciplina útil no sea prohibir la salida del árbol sino exigirle una justificación explícita, escrita, en el propio código, con la misma seriedad con que se documenta un bloque no seguro. La medida de la calidad de un sistema concurrente no es cuántas tareas sin estructura contiene, sino cuántas de ellas su autor sabría defender frase por frase, y ese número, en la mayoría de las bases de código que se examinan de cerca, es sorprendentemente bajo.

⚔️ Auditar las raíces
  1. Recorre tu proyecto, localiza cada Task y clasifícala en puerta de entrada, supervivencia deliberada, limpieza o antipatrón.
  2. Escribe una tarea sin estructura dentro de otra tarea y demuestra con trazas que cancelar la exterior no cancela la interior.
  3. Compara una Task y una Task.detached creadas desde un contexto aislado: registra en ambas el actor efectivo, la prioridad y un valor @TaskLocal.
  4. Convierte un bucle que crea tareas por elemento en un grupo y enumera las garantías que ganas en el cambio.
  5. Toma la tarea desligada de más larga duración de tu código y añade una prueba que falle si sobrevive a su dueño lógico.