Back home

Swift Concurrency Series 06 | Problemas comunes en la concurrencia Swift: condiciones de carrera, solicitudes repetidas y confusión de estado

El verdadero problema es que estos problemas a menudo se manifiestan como interrupciones esporádicas en el negocio en lugar de averías explícitas.

Lo más frustrante de los errores de concurrencia es que a menudo no se sienten como errores.

Con mayor frecuencia se manifiesta en línea como estas preguntas ambiguas:

  • El usuario dijo “a veces parpadea”
  • La prueba dice “Ocasionalmente aparecen datos antiguos”
  • El producto decía “Acabo de cortar el filtro, ¿por qué saltó hacia atrás?”
  • No hay un fallo claro en el registro, pero el estado de la página simplemente es incorrecto.

En otras palabras, muchos problemas de concurrencia parecen más “excepciones comerciales ocasionales” que “obviamente técnicamente rotos”.

Entonces, en este artículo, no quiero hablar solo sobre la definición de términos, sino centrarme directamente en un escenario de página de lista más real y desglosar los tres tipos de problemas más comunes:

  • Competencia
  • Repetir solicitud
  • estado de confusión

Y cómo crecen en código real.

1. Primero mira una página que es tan real que no podría ser más real.

Supongamos que hay una página de lista de artículos que admite estas operaciones:

  • Carga automática cuando la página ingresa por primera vez
  • Tire hacia abajo para actualizar
  • Cambiar categorías
  • Ingrese la búsqueda de palabras clave
  • Haga clic en “Reintentar”

Muchos proyectos están escritos así al principio:

@MainActor
final class ArticlesViewModel: ObservableObject {
  @Published var items: [Article] = []
  @Published var isLoading = false
  @Published var errorMessage: String?
  @Published var selectedCategory: String = "all"
  @Published var keyword: String = ""

  let repository: ArticlesRepository

  init(repository: ArticlesRepository) {
    self.repository = repository
  }

  func onAppear() {
    Task {
      await load()
    }
  }

  func refresh() {
    Task {
      await load()
    }
  }

  func retry() {
    Task {
      await load()
    }
  }

  func categoryChanged(to value: String) {
    selectedCategory = value
    Task {
      await load()
    }
  }

  func keywordChanged(to value: String) {
    keyword = value
    Task {
      await load()
    }
  }

  func load() async {
    isLoading = true
    errorMessage = nil

    do {
      items = try await repository.fetchArticles(
        category: selectedCategory,
        keyword: keyword
      )
    } catch {
      errorMessage = error.localizedDescription
    }

    isLoading = false
  }
}

Cuando se escribe este código por primera vez, todo el mundo suele pensar que es “bastante fluido”:

  • async/await
  • El código es sencillo.
  • Todas las entradas funcionan.

Pero mientras la página se utilice realmente, pronto surgirán problemas de concurrencia.

2. El primer tipo de problema: la condición de carrera es un orden predeterminado que no existe.

Sigue siendo este código. Su principal problema no es que abre una gran cantidad de Task, sino que, de forma predeterminada, estas cosas suceden en el orden deseado:

  • La solicitud enviada primero será devuelta primero.
  • Cuando vuelve la solicitud anterior, las condiciones de filtrado actuales no han cambiado.
  • El inicio y el final de la carga siempre corresponden uno a uno.

Pero los sistemas asincrónicos no garantizan estos pedidos para el equipo.

Por ejemplo, el usuario opera de la siguiente manera:

  1. Ingrese a la página y solicite a A que emita
  2. Cambie inmediatamente a la categoría “iOS” y solicite a B que envíe
  3. Ingrese la palabra clave swift nuevamente para solicitar a C que emita

En este momento, si la orden de devolución es:

  1. C regresa primero
  2. Vuelve después de A
  3. B regresa último

Según el código actual, los tres resultados se cambiarán a items. En otras palabras, lo que se muestra en la página final depende de quién regresa el último, no de quién corresponde a la intención actual del usuario.

Esta es la condición de carrera más típica:

El código se basa secretamente en el orden, pero el orden no está restringido en absoluto.

3. El segundo tipo de problema: la causa principal de las solicitudes repetidas suele ser que la entrada no está cerrada.

Si observa el ViewModel anterior, hay al menos cinco entradas que activarán load():

  • onAppear
  • refresh
  • retry
  • categoryChanged
  • keywordChanged

Cada entrada tiene su propio Task. Esto es ciertamente legal desde una perspectiva de sintaxis, pero desde una perspectiva de ingeniería significa:

  • No existe un punto de programación unificado para tareas similares.
  • Nadie sabe si ya se está ejecutando una tarea similar.
  • Cuando aparecen nuevas tareas, las tareas antiguas no tienen un destino claro.

Entonces las “solicitudes repetidas” ya no son accidentales, sino un producto natural de la estructura.

Entonces, en la gestión de concurrencia, rara vez pregunto:

“¿Por qué hay una solicitud adicional aquí?”

Más a menudo pregunto:

“¿Cuántas entradas hay para el mismo tipo de tareas? ¿Existen relaciones de sustitución entre ellas?”

Si no puede responder a estas dos preguntas, las solicitudes repetidas son casi inevitables.

4. El tercer tipo de problema: el estado está desordenado, a menudo porque los resultados caducados aún son elegibles para escribirse.

Una situación común es que siempre que la solicitud regrese con éxito, el resultado debe aceptarse.

Esto suele estar bien en sistemas síncronos, pero a menudo es incorrecto en sistemas concurrentes.

Porque el problema más crítico en un escenario concurrente es:

**¿Este resultado todavía se considera válido para la página actual? **

Por ejemplo:

  • La página actual ha sido cambiada a keyword = "swift".
  • El resultado es de la solicitud anterior keyword = ""

El resultado es real, exitoso y en el formato correcto, pero ha caducado. Si todavía se permite escribir la interfaz de usuario, el estado será incorrecto.

Por lo tanto, en un sistema concurrente, “el resultado es correcto” y “el resultado es válido” son dos cosas diferentes. En la superficie, muchos problemas de página parecen ser resultados incorrectos, pero de hecho, está más cerca de no poder juzgar si todavía están calificados para implementarse.

5. No se apresure a utilizar herramientas complejas primero. El primer paso es cerrar tareas similares.

Lo que más necesita el código anterior es hacer algo muy simple primero:

**Dar a tareas similares una entrada unificada. **

Por ejemplo, primero cargue la lista así:

@MainActor
final class ArticlesViewModel: ObservableObject {
  @Published private(set) var state: ViewState = .idle
  @Published private(set) var items: [Article] = []
  @Published var selectedCategory: String = "all"
  @Published var keyword: String = ""

  private let repository: ArticlesRepository
  private var loadTask: Task<Void, Never>?

  init(repository: ArticlesRepository) {
    self.repository = repository
  }

  func reload() {
    let request = RequestContext(
      category: selectedCategory,
      keyword: keyword
    )

    loadTask?.cancel()
    loadTask = Task {
      await performLoad(request: request)
    }
  }

  private func performLoad(request: RequestContext) async {
    state = .loading

    do {
      let result = try await repository.fetchArticles(
        category: request.category,
        keyword: request.keyword
      )

      guard !Task.isCancelled else { return }
      guard request.category == selectedCategory,
         request.keyword == keyword else { return }

      items = result
      state = .loaded
    } catch is CancellationError {
      // 取消不更新页面
    } catch {
      guard !Task.isCancelled else { return }
      state = .failed(error.localizedDescription)
    }
  }
}

Este código hace varias cosas muy críticas:

  • Sólo existe un punto de espera para tareas de carga similares loadTask
  • Cuando llegue una nueva tarea, la tarea anterior se cancelará primero.
  • Congelar el “contexto actual” a RequestContext al enviar una solicitud
  • Después de devolver el resultado, se verificará si todavía corresponde a la página actual

Tenga en cuenta que lo realmente importante aquí es que las relaciones entre tareas comiencen a aclararse.

6. “Congelar el contexto de la solicitud” es muy importante

Muchos artículos sobre concurrencia hablan sobre la cancelación de tareas, pero no hacen suficiente hincapié en la “instantánea del contexto”. Pero en el negocio de las páginas, es muy importante.

Por ejemplo, al solicitar:

  • selectedCategory = "ios"
  • keyword = "swift"

Entonces, estos dos valores no deberían leer dinámicamente los valores más recientes en el ViewModel actual después de que se envíe la solicitud. De lo contrario, a menudo obtendrás un estado muy extraño:

  • Al enviar una solicitud, es un conjunto de parámetros.
  • Se utiliza otro conjunto de parámetros al verificar los resultados.

Entonces un principio muy práctico es:

Al iniciar una tarea asincrónica, congelar el contexto empresarial del que realmente depende la tarea.

De esta manera, habrá una base clara para juzgar “si este resultado sigue siendo el resultado actual” más adelante.

7. Muchos errores de concurrencia terminan con “demasiadas entradas de escritura de estado”

Una situación común es que cuando se encuentra un problema de concurrencia, inmediatamente piensa en:

  • ¿Quieres cerrarlo?
  • ¿Quieres ser actor?
  • ¿Quieres cambiar de hilo?

Por supuesto, a veces estos son importantes, pero en escenarios a nivel de página, los problemas más comunes son en realidad:

  • Hay demasiados lugares para escribir items
  • Se pueden cambiar demasiados lugares isLoading
  • Demasiadas entradas pueden enviar solicitudes directamente.

Una vez que las entradas de escritura estatales se dispersan, incluso si no hay competencia de datos real, se producirá el fenómeno de “la combinación es incorrecta”.

Entonces, cuando hago este tipo de solución de problemas, normalmente hago primero las siguientes preguntas:

  • ¿Qué códigos tienen la autoridad para cambiar este estado?
  • Qué tareas tienen derecho a finalizar la carga actual.
  • ¿Qué resultados tienen derecho a sobrescribir la lista actual?

Una vez que estos problemas no se solucionan, generalmente es sólo cuestión de tiempo antes de que se desarrollen errores.

8. Una secuencia de evolución más cercana al proyecto real

Si realmente desea resolver este tipo de problema, le sugiero evolucionar en este orden en lugar de introducir demasiados mecanismos al principio:

1. Cerrar la entrada a tareas similares

Primero, permita que la “carga de lista” tenga solo una entrada unificada, en lugar de enviar su propia solicitud para cada evento de la interfaz de usuario.

2. Aclarar la relación de reemplazo de tareas

Qué tareas deben ser simultáneas y cuáles deben cancelar tareas antiguas y conservar solo la última.

3. Congelar el contexto de la solicitud

Recopile los parámetros comerciales clave en los que se basa al realizar solicitudes en un objeto claro.

4. Agregue juicio de validez al resultado.

No todos los resultados devueltos correctamente son elegibles para cambiar la página actual.

5. Finalmente, considere un aislamiento de estado compartido más complejo

Por ejemplo, caché compartido entre páginas, coordinación de recursos entre módulos, luego observe Actor, coordinador unificado y otras soluciones.

Este orden es más estable porque resuelve primero la relación de concurrencia empresarial, en lugar de introducir primero un vocabulario técnico más complejo.

9. Conclusión: la esencia de la mayoría de los problemas de concurrencia empresarial es “no modelar las relaciones de tareas”

Las condiciones de carrera, las solicitudes duplicadas y la confusión de estados parecen ser tres problemas, pero las causas reales suelen estar muy cerca:

  • Quién es la misma tarea que quién, sin modelar.
  • Vienen nuevas tareas, qué hacer con las tareas antiguas, no hay modelado
  • ¿El resultado sigue siendo válido? No hay modelaje.
  • ¿Dónde puedo escribir mi estado sin cerrarlo?

Entonces, para reformular este artículo de manera más breve, diría:

La mayoría de los problemas de concurrencia en los negocios parecen ser incompetentes con la sintaxis de concurrencia, pero de hecho están más cerca de no poder modelar claramente las relaciones de tareas, la validez de los resultados y los permisos de escritura estatales.

Una vez que estas tres cosas empiecen a aclararse, mucha “confusión accidental” desaparecerá más fácilmente de lo que cree.