Serie de concurrencia Swift 04 | Límites de uso de tareas
La tarea no es la entrada universal al código asincrónico. Lo que realmente importa es quién lo crea, quién lo cancela y quién es responsable de los resultados.
El mal hábito más común que desarrollan muchos equipos cuando encuentran por primera vez la concurrencia Swift a escala es abusar de Task.
Porque es muy conveniente.
No puede directamente await en la devolución de llamada del botón y luego escribir un Task. No puede await directamente en el método de proxy UIKit y luego escribir un Task. Si desea colarse en el mundo asincrónico mediante un determinado método de sincronización, la forma más sencilla es escribir Task.
Con el tiempo, Task pasará de ser “una puerta de enlace que une la sincronización y la asincronía” a “una capa de cinta de concurrencia que cubre cualquier problema”.
Lo que este artículo realmente quiere responder son tres preguntas más cercanas a la ingeniería:
- ¿Qué tipo de problema resuelve?
- ¿En qué circunstancias se trata de una selección natural y en qué circunstancias simplemente oculta problemas estructurales?
- ¿Qué preguntas debería hacerse primero al decidir abrir un
Task?
1. Primero, dejemos claro el posicionamiento: Task es el punto de creación de tareas asincrónicas, no una herramienta de diseño de procesos.
Cuando vi por primera vez Task {}, lo que pensé fue en “ejecución asincrónica de un fragmento de código”.
Esta comprensión no es errónea, pero no es suficiente.
Más precisamente, lo que hace Task es:
- Crear una nueva tarea simultánea
- Poner un fragmento de código en un contexto asincrónico.
- Vincular responsabilidades como ejecución, cancelación, prioridad, resultados, etc. a esta tarea.
Por lo tanto, Task nunca se trata simplemente de “dejar el código en segundo plano y ejecutarlo”.
Una vez que lo escribí, en realidad tomé varias decisiones al mismo tiempo:
- Este trabajo ahora comienza a valerse por sí solo.
- Puede finalizar más tarde que el punto de llamada actual.
- Puede cancelarse o no
- Su resultado se consume o se descarta.
- Establece una cierta relación con el objeto actual, la página actual y la acción actual del usuario.
Esto también muestra que Task no se puede entender sólo gramaticalmente.
Sintácticamente es sólo un bloque de código, pero desde el punto de vista de ingeniería significa “se crea el ciclo de vida de la tarea”.
2. El escenario más adecuado para Task: realmente es necesario pasar del mundo sincrónico al mundo asincrónico
El escenario de uso más natural de Task es realmente muy simple:
El contexto actual no es
async, pero es necesario activar un proceso asincrónico.
Como este tipo de situaciones.
1. Devolución de llamada de interacción del usuario
Button("保存") {
Task {
await viewModel.save()
}
}
Es razonable usar Task aquí, porque la acción de Button en sí no es async, pero la acción de guardar es obviamente un proceso asincrónico.
2. Método de proxy de sincronización de UIKit/AppKit
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
Task {
await viewModel.search(keyword: searchText)
}
}
La firma de devolución de llamada del proxy la determina el marco, no si se puede cambiar a async. Para ingresar a un proceso asincrónico, se requiere un punto de enlace.
3. Ciclo de vida de la aplicación o devolución de llamada de notificación
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
Task {
await refreshIfNeeded()
}
}
El valor de Task aquí sigue siendo el mismo: convertir un evento sincrónico en una tarea asincrónica.
Si observan estos ejemplos juntos, encontrarán una cosa en común:
- Los eventos provienen de la API de sincronización.
- Se espera que el procesamiento empresarial sea asíncrono.
Taskes solo la entrada, no el cuerpo principal
En este momento Task es una buena herramienta.
3. El peligro real: trate a Task como un método de reparación para “arreglar cualquier error que se informe”
El problema más común en el equipo es “usarlo con demasiada naturalidad”.
Las posturas incorrectas más típicas son las siguientes.
1. Si el compilador no permite await, incluirá una capa de Task
func refresh() {
Task {
await loadData()
}
}
Es posible que este código no sea incorrecto cuando se ve de forma aislada. El problema es que en muchos casos, el propio refresh() se puede diseñar como async, y luego la capa superior decide cuándo llamarlo.
Una vez que el pensamiento predeterminado sea “no puedo await, luego abra Task”, perderá el control de los límites de la tarea.
2. Ya en la función async, necesitamos agregar otro Task
func loadPage() async {
Task {
await loadUser()
}
Task {
await loadFeed()
}
}
El problema con este tipo de código es que interrumpe el flujo de control que originalmente pertenece a una función.
Encontrará varios problemas inmediatamente:
- Quién garantiza el orden final de estas dos tareas.
- Cómo manejar las fallas de manera uniforme.
- ¿Cómo sabe la persona que llama cuándo se completó realmente el
loadPage()completo? - Si se cancela la tarea externa, ¿las dos subtareas se detendrán juntas?
Si la intención es ejecutar en paralelo, suele ser más claro escribirlo como async let o grupo de tareas, en lugar de crear dos Task opacos adicionales.
3. Cuando me encuentro con competencia por estatus, quiero “cambiar el pico” abriendo algunos Task más.
Parte del código se escribirá así:
Task {
self.loading = true
}
Task {
let data = await service.fetch()
self.items = data
}
Task {
self.loading = false
}
En la superficie parece desarmar cosas, pero en realidad deja la consistencia del estado directamente a la suerte.
No sé si la tercera tarea se completará antes que la segunda y no sé en qué estado se detendrá la interfaz cuando se produzca la cancelación.
La causa fundamental de este tipo de problema suele ser que el flujo de estado que debería conectarse está interrumpido.
4. Para juzgar si debes abrir un Task, primero haz estas cuatro preguntas
Este es el conjunto de listas de verificación de ingeniería más útil. Mucho más útil que memorizar gramática.
1. ¿Quién creó esta tarea?
¿Se crea haciendo clic en un botón? ¿Creado cuando aparece la página? ¿Creado durante la inicialización de ViewModel? ¿O fue creado en secreto por una capa de servicio?
Si la respuesta a “quién lo creó” no está clara, será casi imposible determinar “quién debería cancelarlo” más adelante.
2. ¿A quién pertenece esta tarea?
Si la misión es simplemente disparar y olvidar, normalmente eso significa que nadie la está gestionando.
Pero muchas empresas no son aptas para disparar y olvidar.
Por ejemplo, buscar, paginar, guardar, cargar y sondear, estas tareas a menudo deben ser realizadas explícitamente por un objeto:
final class SearchViewModel: ObservableObject {
private var searchTask: Task<Void, Never>?
func updateKeyword(_ keyword: String) {
searchTask?.cancel()
searchTask = Task {
await search(keyword)
}
}
}
Lo realmente valioso aquí es:
-Solo hay una entrada para tareas similares.
- Cuando aparezcan nuevas tareas, las antiguas se cancelarán.
- La tarea pertenece a
SearchViewModel
Task que no es “dueño” normalmente se convertirá en una misión fantasma más adelante.
3. Si el usuario abandona la página, ¿debe continuar ejecutándose?
Esta cuestión es particularmente importante porque determina directamente dónde debe limitarse el ciclo de vida de la tarea.
Por ejemplo:
- Solicitud de página en la mitad superior de la página: los usuarios normalmente no tienen que continuar después de abandonar la página.
- Envío de pedidos: es posible que sea necesario continuar completándolo incluso si la página está cerrada
- Captación previa de imágenes: la prioridad puede ser muy baja y se debe cancelar al salir de la página.
Los diferentes tipos de tareas tienen diseños completamente diferentes.
Sin responder primero a esta pregunta, es fácil escribir todas las tareas de esta manera:
Task {
await doSomething()
}
En la superficie están unificados, pero en realidad la semántica está completamente confusa.
4. ¿Quién consumirá los resultados de esta tarea?
Los resultados de algunas tareas se volverán a escribir en la interfaz de usuario, los resultados de algunas tareas deberán actualizar el caché y algunas tareas simplemente se informarán.
Cuando los resultados no tienen un destino claro suelen surgir dos tipos de malos olores:
- Se abrió la tarea, pero nadie respondió el error
- La tarea se completó, pero nadie la usó.
Así que no soy un gran admirador de disparar y olvidar desenfrenado. La mayoría de las tareas comerciales no son “simplemente enviarlas”.
5. Cuando ya esté en el mundo async, dé prioridad a la concurrencia estructurada en lugar de crear Task adicionales.
Este es un punto que muchos artículos no abordan con sinceridad.
Ya en la función async significa que ya tienes flujo de control asíncrono. Escribir Task adicional en este momento a menudo implica eludir las restricciones impuestas por la concurrencia estructurada.
Mira las dos comparaciones.
Tendencia al error: utilizar múltiples demoliciones duras Task en paralelo
func loadDashboard() async {
let userTask = Task { await api.loadUser() }
let statsTask = Task { await api.loadStats() }
let noticesTask = Task { await api.loadNotices() }
let user = await userTask.value
let stats = await statsTask.value
let notices = await noticesTask.value
self.state = .loaded(user, stats, notices)
}
Este código no es del todo incorrecto, pero no es lo suficientemente explícito. Porque lo que ve la persona que llama es “Creé activamente tres tareas” en lugar de “Aquí hay tres dependencias paralelas”.
Mejor expresión: async let
func loadDashboard() async throws {
async let user = api.loadUser()
async let stats = api.loadStats()
async let notices = api.loadNotices()
self.state = try .loaded(user: user, stats: stats, notices: notices)
}
La ventaja de este tipo de escritura es que la semántica es más clara:
- Estos trabajos pertenecen a la función actual.
- Están encadenados a la misma estructura que la llamada actual.
- La función actual esperará el resultado antes de finalizar.
- Cuando se cancela la capa externa, también se cancela la concurrencia interna.
En otras palabras, la diferencia entre Task y la concurrencia estructurada radica en quién es responsable del ciclo de vida.
6. El desastre más común a nivel de página: abre un Task para cada entrada
Tomando como ejemplo una página de lista muy real, generalmente existen estos puntos desencadenantes:
- Carga de la primera página de entrada
- Tire hacia abajo para actualizar
- Buscar cambios de palabras clave
- Cambiar filtros
- Haga clic en “Reintentar”
- Pase a la página siguiente para cargar más automáticamente
Si cada entrada está escrita por sí misma:
Task {
await load()
}
Después de una o dos iteraciones, lo más probable es que la página presente estos fenómenos:
- Varias solicitudes salen al mismo tiempo
- Los resultados antiguos sobrescriben los nuevos resultados.
- Obviamente la última palabra clave es
swift, pero la interfaz muestra los resultados deswi. - Después de que el usuario sale de la página, la devolución de llamada sigue escribiendo.
loading,isRefreshing,errorpelean entre sí
Una situación común en esta etapa es pensar erróneamente que lo que se encuentra es una “concurrencia compleja”.
De hecho, el problema es más específico: ** La entrada de tareas está demasiado dispersa y no hay un cierre unificado de cambios de estado. **
Un enfoque más estable suele ser concentrar “qué tareas abrir” en un objeto de estado, en lugar de dejar que la capa de vista cree nuevas tareas en todas partes.
Por ejemplo:
@MainActor
final class ArticlesViewModel: ObservableObject {
@Published private(set) var state: ViewState = .idle
private var reloadTask: Task<Void, Never>?
func reload() {
reloadTask?.cancel()
reloadTask = Task {
state = .loading
do {
let articles = try await repository.fetchArticles()
guard !Task.isCancelled else { return }
state = .loaded(articles)
} catch is CancellationError {
// 忽略取消
} catch {
state = .failed(error)
}
}
}
}
Hay tres problemas que este código realmente resuelve:
- Hay una entrada única para tareas similares.
- La relación de sustitución entre tareas similares es clara. -Escritura de estado en un solo lugar
Aquí todavía se utiliza Task, pero ya es una “entrada controlada a la gestión de tareas”.
7. ¿Cuándo es apropiado mantener la referencia Task y cuándo no es necesaria?
Esta también es una señal para juzgar la madurez del código.
Adecuado para escenarios donde se llevan a cabo referencias.
- Búsqueda de entrada anti-vibración
- Tarea de actualización de página
- Solicitudes que pueden activarse repetidamente y las nuevas tareas deben reemplazar las tareas antiguas.
- Procesos de sondeo, escucha y sincronización de larga duración.
Porque estos escenarios naturalmente implican cancelación o reemplazo.
No es necesario mantener la escena referenciada.
- Tareas cortas que los usuarios solo hacen una vez después de hacer clic una vez
- Es claramente el punto de enterramiento, la limpieza de registros y cachés de “disparar y olvidar”.
- Tareas cuyo ciclo de vida ha sido gestionado por el framework externo del equipo.
La atención no se centra en “si debe ser más avanzado o no”, sino en si la tarea se gestiona correctamente.
Si una tarea puede cancelarse, reemplazarse o afectar la visibilidad del usuario, lo más probable es que no deba activarse como fuegos artificiales anónimos.
8. Task.detached es una declaración de aislamiento más fuerte
Aunque este artículo habla principalmente de Task, muchos equipos pronto seguirán utilizando Task.detached después de abrir Task aleatoriamente.
Aquí hay un recordatorio rápido:
Task {}heredará parte del contexto actualTask.detached {}es más como "separado del contexto actual y ejecutado de forma independiente"Por lo tanto, si la atribución y cancelación delTaskordinario no se aclaran,detachedno debería utilizarse para ampliar el grado de libertad.
Muchos Task.detached acaban siendo una evasión de responsabilidad.
9. Un criterio de juicio práctico: ¿Estás creando tareas o escapando del modelado?
Esta es la pregunta más frecuente en mis reseñas.
Cuando esté listo para escribir:
Task {
...
}
Detente por dos segundos y pregúntate:
- ¿Estoy creando una tarea con un ciclo de vida claro?
- ¿O es simplemente porque es demasiado problemático cambiarlo a
async, por lo que se cubre temporalmente con una capa? - ¿Sé cuándo termina, quién lo cancela y a quién termina?
Si no puede responder a estas preguntas, en la mayoría de los casos el nivel actual de abstracción no se ha solucionado.
10. Conclusión: Vale la pena usar Task con frecuencia, pero no de manera casual.
Task es ciertamente importante en Swift Concurrency y se usa con frecuencia.
Pero su valor correcto es:
- Ingrese de forma segura al proceso asincrónico en la entrada sincrónica
- Crear explícitamente tareas cuando se requiera un ciclo de vida independiente.
- Proporcionar límites claros de concurrencia cuando se necesitan cancelación, reemplazo y aislamiento.
Entonces prefiero entenderlo de esta manera:
Taskes una declaración explícita del ciclo de vida de la tarea.
Cuando se usa como declaración, el código se vuelve cada vez más claro. Al usarlo como parche, tarde o temprano el código se convertirá en “todas las capas se pueden ejecutar, pero nadie puede decir por qué se ejecuta así”.
What to read next
Want more posts about Swift Concurrency?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to keep following #Swift Concurrency?
Tags are useful for related tools, specific problems, and similar troubleshooting notes.
View same tagWant to explore another direction?
If you are not sure what to read next, return to the homepage and start from categories, topics, or latest updates.
Back home