Swift Concurrency Series 09 | Problema de invalidación semántica de cancelación en Swift Concurrency
Lo que es realmente difícil de recopilar es si la señal de cancelación puede atravesar la tarea, la capa puente y los límites de los efectos secundarios, y no permitir que los resultados anteriores se retroalimenten en la página.
Después de que el proyecto cambia la devolución de llamada a async/await, una situación común es que existe la ilusión de que el problema de concurrencia se ha contenido.
La firma de la función se ha vuelto más limpia, la cadena de llamadas se puede ver en await y hay incluso menos advertencias en Xcode. Pero los problemas online más molestos suelen empezar a aparecer en esta etapa: se ha abandonado la página, pero la solicitud no se ha detenido; los términos de búsqueda han cambiado y los resultados anteriores han regresado; el usuario cancela manualmente la carga, pero las tareas subyacentes continúan ejecutándose.
Este tipo de problema se atribuye más fácilmente a “una determinada interfaz es demasiado lenta” o “el hilo principal se actualiza en el momento equivocado”. Pero si realmente desmontas el enlace y lo miras, el núcleo suele ser que la señal de cancelación no se transmite a lo largo del árbol de tareas, la capa de puente y los límites de los efectos secundarios hasta el final.
Mi opinión es: ** Una vez completada la migración de Swift Concurrency, el error de concurrencia más común es que la gente piensa que “la tarea principal se cancela y las siguientes tareas se detendrán naturalmente”. En realidad, mientras exista una capa no controlada de Task, un contenedor que une la API anterior o un efecto secundario que no verifique el estado de cancelación, la semántica de cancelación se romperá en esa capa. Al final, parece que la página tiembla ocasionalmente, pero en realidad significa que el estado se ha bifurcado. **
Este tipo de problema suele quedar expuesto principalmente en los comentarios del estado.
Esta es la primera vez que me ocupo sistemáticamente de este problema. A primera vista parece un bloqueo, pero en realidad se parece más a una página de búsqueda. Algunas personas siempre informan que “los resultados volverán por sí solos”.
La lógica de la página no es complicada:
- El usuario ingresa palabras clave;
- ViewModel inicia la búsqueda;
- Cancelar la tarea anterior cuando llegue una nueva palabra clave;
- Actualizar la lista después de que regrese la solicitud.
En la superficie, este proceso es completamente consistente con el método de escritura recomendado de Swift Concurrency. El problema es que en la grabación de pantalla online se puede ver un fenómeno muy extraño:
- El usuario primero busca
swift; - Luego cámbielo a
swift concurrency; - Los nuevos resultados aparecen primero en la interfaz;
- Después de medio segundo, los resultados antiguos sobrescriben la lista nuevamente.
Esto no se puede explicar simplemente “solicitando fuera de servicio”. Porque searchTask?.cancel() está claramente en el código y cancelar también se puede ver en el registro.
El verdadero problema radica en: ** La tarea del nivel superior se canceló, pero la capa inferior no consideró la “cancelación” como un cambio de estado que deba cerrarse de inmediato. **
Mientras haya otra capa en el sistema que continúe enviando resultados antiguos, la interfaz de usuario lo aceptará como un resultado legítimo.
Muchas cancelaciones fallaron, rotas en la capa de código puente de apariencia más inofensiva.
El punto de interrupción más común es que al empaquetar la antigua API de devolución de llamada en una función asíncrona, solo “espera a que regrese el resultado” y no hace “qué hacer cuando el resultado no debería regresar”.
Por ejemplo, una situación común es empaquetar una solicitud de red de esta manera:
func loadUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
apiClient.loadUser(id: id) { result in
continuation.resume(with: result)
}
}
}
La sintaxis está bien y las funciones funcionan. Pero este código tiene dos premisas fatales por defecto:
- Incluso si se cancela la tarea externa, la solicitud subyacente se detendrá por sí sola;
- Incluso si la capa inferior no se detiene, la devolución de llamada no afectará el estado actual si regresa más tarde.
Estas dos premisas muchas veces no se cumplen en proyectos reales.
Si apiClient todavía está debajo de URLSessionDataTask, un SDK de terceros o su propia capa de almacenamiento de devolución de llamada, entonces la cancelación de la capa externa Task no se transferirá automáticamente. El contenedor asíncrono anterior solo cambia el método de llamada a await, pero no permite que la capa subyacente obtenga semántica de cancelación.
Lo que realmente necesita hacer la capa puente es “traducir la cancelación de la capa externa en acciones de cancelación ejecutables subyacentes”. Algo como esto:
func loadUser(id: String) async throws -> User {
var request: Cancellable?
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
request = apiClient.loadUser(id: id) { result in
continuation.resume(with: result)
}
}
} onCancel: {
request?.cancel()
}
}
Este código apenas comienza a acercarse a “la cancelación realmente se puede transmitir”.
Pero escribirlo aquí no es suficiente, porque solo resuelve “intentar no seguir ejecutando”, pero no resuelve “cómo cerrar los resultados tardíos”. Si cancel() del SDK subyacente no tiene una fuerte cancelación semántica, sino que simplemente termina tanto como sea posible, es posible que la devolución de llamada aún regrese en la condición de carrera. El nivel superior deberá continuar realizando una verificación de cancelación antes de recibir los resultados.
Lo que realmente arruina la página es que los resultados antiguos todavía se consideran resultados válidos.
Muchos equipos se sienten aliviados cuando ven Task.isCancelled, pero solo pueden responder “si la tarea actual se ha marcado como cancelada”, pero no pueden responder “¿debería este resultado aún aparecer en la página actual?”
En escenarios como búsqueda, asociación y cambio de detalles, lo que realmente hay que proteger es la propiedad de los resultados.
La siguiente forma de escribir ViewModel es muy común:
final class SearchViewModel: ObservableObject {
@Published private(set) var items: [Item] = []
private var searchTask: Task<Void, Never>?
func search(keyword: String) {
searchTask?.cancel()
searchTask = Task {
do {
let items = try await repository.search(keyword: keyword)
self.items = items
} catch {
self.items = []
}
}
}
}
El problema parece ser solo una llamada de cancelación, pero lo que realmente falta son dos capas de protección:
- Después de un regreso exitoso, confirme que la tarea actual aún es válida;
- La cancelación no puede tratarse como un error normal cuando falla.
Una forma más estable de escribirlo sería así:
final class SearchViewModel: ObservableObject {
@MainActor @Published private(set) var items: [Item] = []
private var searchTask: Task<Void, Never>?
func search(keyword: String) {
searchTask?.cancel()
searchTask = Task { [weak self] in
guard let self else { return }
do {
let items = try await repository.search(keyword: keyword)
try Task.checkCancellation()
await MainActor.run {
self.items = items
}
} catch is CancellationError {
// 取消不是失败,不清空 UI,不弹错误
} catch {
await MainActor.run {
self.items = []
}
}
}
}
}
Lo que realmente importa aquí es la actitud detrás de esto: **La cancelación es un flujo de control normal, no un accidente anormal. **
Muchas páginas tiemblan porque el código cambia “el usuario se fue” por “la solicitud falló, así que borre la interfaz de usuario”. Como resultado, la nueva tarea aún no se ha procesado y la rama incorrecta de la tarea anterior primero devuelve la página a un estado vacío, lo que visualmente parece un parpadeo aleatorio.
Otro problema más oculto es que el árbol de tareas ha estado roto durante mucho tiempo y todos piensan que están en concurrencia estructurada.
Uno de los beneficios de Swift Concurrency es que la concurrencia estructurada hace que la relación del ciclo de vida entre las tareas principales y secundarias sea mucho más clara. Pero lo más fácil de perder en el proyecto es el Task {} que todos recogen al azar sólo para “evitar problemas”.
Por ejemplo, cuando se ingresa a una página de lista para obtener detalles, obtener recomendaciones y resaltar aspectos destacados, una gran cantidad de código se dividirá en esto:
func refresh() async {
Task {
async let detail = repository.loadDetail()
async let recommendation = repository.loadRecommendation()
let result = try await (detail, recommendation)
render(result)
}
}
Parece ser asíncrono/en espera, pero el problema más crítico con este código es: el propio refresh() y la capa Task {} interna ya no tienen una relación estructurada entre padres e hijos.
Es decir:
- La capa superior que llama a
refresh()finaliza inmediatamente; - Incluso si la página se destruye;
- Incluso si se cancela la tarea exterior;
El recién inaugurado Task en esta planta todavía puede seguir funcionando.
Esta es la razón por la que muchas páginas siguen realizando solicitudes incluso después de haber cerrado. Es el código que evita activamente la concurrencia estructurada.
Si este tipo de escenario es solo para obtener resultados en paralelo, basta con escribir directamente en el contexto asíncrono actual:
func refresh() async throws -> ScreenData {
async let detail = repository.loadDetail()
async let recommendation = repository.loadRecommendation()
return try await ScreenData(
detail: detail,
recommendation: recommendation
)
}
De esta forma, la semántica de cancelación se recopilará junto con la cadena de llamadas. Quien lo inicie será responsable; Quien lo cancele, lo detendrá junto.
Si no se verifica la cancelación del límite del efecto secundario, aparecerá el estado sucio más difícil de explicar.
Si la solicitud no se detiene, es simplemente un desperdicio de recursos. Si los efectos secundarios no se detienen, el estado se escribirá como sucio.
Más tarde investigué específicamente un tipo de problema que es difícil de reproducir: después de que un usuario cambia rápidamente de cuenta, ocasionalmente aparecen datos de la cuenta anterior en el caché. Finalmente convergió y la semántica de cancelación se detuvo antes de “obtener datos” y no continuó con el paso de “escribir efectos secundarios”.
Un código como este es peligroso:
func refreshProfile() async throws {
let profile = try await repository.fetchProfile()
cache.save(profile)
analytics.trackProfileLoaded(profile.id)
state = .loaded(profile)
}
Si la tarea se canceló cuando regresa fetchProfile(), pero no hay verificación de cancelación, las escrituras de caché posteriores, los puntos enterrados y las actualizaciones de estado seguirán produciéndose.
Lo que ve en la interfaz de usuario en este momento puede ser solo un rebote ocasional, pero dentro del sistema, los datos sucios se han colocado en el disco y el costo de la solución de problemas aumentará mucho de repente.
Un enfoque más prudente suele ser realizar otra comprobación explícita antes del límite de los efectos secundarios:
func refreshProfile() async throws {
let profile = try await repository.fetchProfile()
try Task.checkCancellation()
cache.save(profile)
analytics.trackProfileLoaded(profile.id)
state = .loaded(profile)
}
Este paso puede parecer un poco mecánico, pero resuelve un problema muy real: **La cancelación no sólo cancela la “espera”, sino que también cancela el “enviar”. **
Lo que realmente hay que proteger son a menudo las próximas acciones que reescribirán el viejo mundo.
El malentendido más común en casos de falla es manejar todos los errores de manera uniforme.
La razón por la que muchas migraciones simultáneas dejan colas largas es porque a los equipos les gusta escribir cierres de errores en una plantilla unificada:
do {
let data = try await service.load()
state = .loaded(data)
} catch {
state = .error(error)
}
Esto no es un problema en escenarios de fallas normales, pero una vez que se implementa en escenarios como cambio de página de alta frecuencia, búsqueda de Lenovo, antivibración de entrada y cancelación de carga, CancellationError no es lo mismo que una falla comercial real.
Mezclar los dos traerá al menos tres consecuencias:
- El usuario abandonó activamente la página, pero se registró como un fracaso;
- La tasa de error en los puntos ocultos es artificialmente alta, lo que induce a error en el juicio de estabilidad;
- Debido al error unificado en la UI, aparecen brindis, estados vacíos o botones de reintento que no deberían aparecer.
Siempre que la cancelación se muestre como un error una vez en el proyecto, más adelante aparecerán un montón de comentarios extraños y aparentemente no relacionados:
- La lista se borra repetidamente durante la búsqueda;
- Ocasionalmente se produce un error después de que se completa la actualización desplegable;
- Cuando la página vuelva al nivel anterior, el estado de error de carga parpadeará.
Estos fenómenos son muy fragmentarios, pero la raíz es el mismo problema: ** la cancelación del flujo de control se confunde con una excepción comercial. **
Límites aplicables: no todas las funciones asíncronas necesitan estar llenas de controles de cancelación
La semántica de cancelación es importante, pero no todas las capas tienen que escribir Task.checkCancellation() mecánicamente.
Hay tres puestos que valoro más ahora:
- Unir la entrada a la antigua API: esto es responsable de traducir la capa externa a las capacidades subyacentes;
- Puntos de cambio de fase para enlaces que requieren mucho tiempo: por ejemplo, después de completar la red, prepararse para decodificar y prepararse para escribir el caché;
- Efectos secundarios antes del envío: Vale la pena comprobar nuevamente cualquier lugar que cambie de estado, almacene en caché, publique o escriba en la base de datos.
Por otro lado, si una función es sólo cálculo puro, no tiene punto de suspensión y no tiene efectos secundarios, entonces no tiene mucho sentido insertar específicamente un cheque de cancelación. Porque la verdadera solución a la cancelación siempre ha sido “No seguir escribiendo sobre el viejo mundo”.
Resumen
La ilusión más fácil creada por Swift Concurrency es que el código se ha movido de la devolución de llamada a await y, naturalmente, el sistema ha entrado en una era de concurrencia más confiable.
Pero los proyectos reales no obtendrán automáticamente la semántica de cancelación solo porque la sintaxis sea nueva.
Si la tarea principal aún puede controlar la tarea secundaria, si la capa puente puede pasar la cancelación y si los resultados anteriores se bloquearán antes de que se envíen los efectos secundarios. Si se omite una de estas tres cosas, lo que verá en la página será un sistema de estado bifurcado.
Por lo tanto, lo que realmente hay que examinar en este tipo de cuestiones es en qué nivel se detiene la cancelación. Mientras esta pregunta no se responda claramente, cuanto más nueva sea la gramática, más fácil será para las personas pensar erróneamente que han escrito la concurrencia correctamente.
What to read next
Want more posts about iOS?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to keep following #iOS?
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