DEV Community

GoyesDev
GoyesDev

Posted on

[GCD] Problemas de GCD

GCD resolvió el problema que motivó toda esta serie de artículos: administrar hilos a mano es peligroso y difícil de acertar. Pero GCD trae sus propios problemas, que no desaparecen solo por usar colas en lugar de hilos directamente. Este artículo repasa siete de ellos.

Complejidad conceptual: hilo y cola son dos cosas distintas

Trabajar con GCD obliga a razonar sobre dos conceptos al mismo tiempo: la cola a la que se despacha trabajo, y el hilo en el que ese trabajo termina corriendo. No son lo mismo, y la relación entre ambos no siempre es obvia. Hace falta tener en cuenta, para cada tarea despachada:

  • Si la cola es serial o concurrente (cambia si hay garantía de exclusión mutua).
  • Si se despacha con sync o async (cambia si el llamador espera, y en qué hilo puede terminar reutilizándose el trabajo).
  • Qué QoS tiene la tarea (cambia con qué prioridad compite por CPU).
  • Si el código ya está corriendo en esa misma cola (una llamada sync reentrante produce un deadlock, cubierto en el artículo sobre DQ Serial: ejecución síncrona).

Ese modelo mental hay que reconstruirlo en cada punto del código donde se despacha trabajo. Un error tan simple como llamar DispatchQueue.main.sync desde código que ya corre en el hilo principal, sin darse cuenta de en qué cola se está parado, produce un deadlock silencioso que no aparece hasta que el código corre.

Completion handlers anidados

Cuando una operación asíncrona depende del resultado de otra, y esa a su vez de otra, el código con closures de finalización se anida cada vez más hacia la derecha.

El siguiente fragmento encadena tres operaciones asíncronas, cada una dentro del closure de finalización de la anterior:

func fetchUser(id: Int, completion: @escaping (User?) -> Void) { /* ... */ }
func fetchPosts(for user: User, completion: @escaping ([Post]?) -> Void) { /* ... */ }
func fetchComments(for post: Post, completion: @escaping ([Comment]?) -> Void) { /* ... */ }

fetchUser(id: 1) { user in
  guard let user = user else { return }
  fetchPosts(for: user) { posts in
    guard let firstPost = posts?.first else { return }
    fetchComments(for: firstPost) { comments in
      print(comments ?? [])
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Con tres pasos ya es notorio. Con cinco o seis (una secuencia de llamadas de red real no es rara que llegue ahí), el código se vuelve difícil de leer en orden: cada nivel de indentación introduce su propio alcance, sus propias variables capturadas, y su propio punto de retorno anticipado.

Manejo de errores doloroso

Los closures de finalización no tienen una forma estándar de propagar errores hacia arriba, como sí la tiene throws/catch. Cada nivel de anidamiento tiene que manejar su propio error por separado, normalmente repitiendo el mismo patrón una y otra vez.

Retomando el fragmento anterior, con manejo de errores explícito en cada nivel:

func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) { /* ... */ }
func fetchPosts(for user: User, completion: @escaping (Result<[Post], Error>) -> Void) { /* ... */ }
func fetchComments(for post: Post, completion: @escaping (Result<[Comment], Error>) -> Void) { /* ... */ }

fetchUser(id: 1) { userResult in
  switch userResult {
  case .failure(let error):
    print("Error obteniendo el usuario: \(error)")
  case .success(let user):
    fetchPosts(for: user) { postsResult in
      switch postsResult {
      case .failure(let error):
        print("Error obteniendo los posts: \(error)")
      case .success(let posts):
        guard let firstPost = posts.first else { return }
        fetchComments(for: firstPost) { commentsResult in
          switch commentsResult {
          case .failure(let error):
            print("Error obteniendo los comentarios: \(error)")
          case .success(let comments):
            print(comments)
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

No hay ningún mecanismo del lenguaje que junte los tres switch en un solo punto de manejo de errores. Cada nivel repite la misma estructura, y olvidar un caso en cualquiera de ellos compila sin advertencia.

Propenso a olvidar llamar el closure de callback

El compilador no obliga a que un closure de finalización se llame en todos los caminos posibles de una función. Si una rama del código retorna sin invocar el completion, el código que llamó a esa función se queda esperando una respuesta que nunca llega, sin ningún error ni advertencia.

Este otro fragmento ilustra una función con una rama que olvida llamar a completion:

func fetchValue(completion: @escaping (Int?) -> Void) {
  guard someCondition else {
    return // ⚠️ Nunca se llama completion(): quien llame a fetchValue() espera para siempre
  }
  completion(42)
}
Enter fullscreen mode Exit fullscreen mode

Este es el mismo tipo de bug, en esencia, que olvidar group.leave() en DispatchGroup (cubierto en el artículo sobre DispatchGroup): el código compila perfectamente, y el problema solo se manifiesta en tiempo de ejecución, como un colgado que puede ser difícil de reproducir si someCondition casi siempre es verdadera.

Carrera de datos sin protección del compilador

El compilador de Swift no impide compilar código como el de UnsafeCounter (artículo sobre DQ Concurrente: ejecución asíncrona), donde varias tareas modifican el mismo estado desde hilos distintos sin ninguna sincronización. GCD no tiene ningún mecanismo propio que detecte esto en tiempo de compilación: hace falta usar herramientas externas, como el Thread Sanitizer de Xcode, para encontrar estas carreras, y solo detectan las que efectivamente ocurren durante una ejecución concreta, no todas las que son posibles.

Explosión de threads

El pool de hilos que GCD administra para las colas concurrentes tiene un límite práctico, típicamente alrededor de 64 hilos por proceso para el pool por defecto. Cada tarea que se bloquea (con sync, con wait() de un DispatchGroup o un DispatchSemaphore, o durmiendo un hilo) sigue contando como un hilo ocupado desde la perspectiva del sistema operativo, aunque no esté haciendo trabajo útil. Si se despachan muchas tareas que se bloquean unas a otras sobre colas globales, GCD puede llegar a crear un hilo nuevo por cada una hasta agotar ese límite, y quedarse sin hilos disponibles para la tarea de la que las demás dependían. El resultado es indistinguible de un deadlock desde el punto de vista del usuario, aunque técnicamente el sistema sigue "funcionando": simplemente ya no tiene con qué avanzar.

Este riesgo crece con patrones ya vistos en este módulo, como despachar una tarea bloqueante (sync, wait()) dentro de otra tarea que ya está corriendo en una cola concurrente compartida: cada nivel de anidamiento consume un hilo más del pool mientras espera. La forma de evitarlo es limitar cuántas colas concurrentes propias existen en la app y evitar bloquear hilos del pool global innecesariamente.

Inversión de prioridad

Una tarea de prioridad alta puede terminar esperando por un recurso (un lock, un semáforo) que en ese momento tiene tomado una tarea de prioridad baja. Si el scheduler del sistema le da tiempo de CPU preferentemente a tareas de prioridad alta o media, y ninguna de esas tareas es la que tiene el recurso tomado, la tarea de baja prioridad que sí lo tiene puede tardar en recibir tiempo de CPU para terminar y liberarlo. El resultado es que la tarea de prioridad alta, a pesar de su prioridad, termina esperando indefinidamente por una tarea de prioridad baja que ni siquiera está compitiendo directamente con ella.

Lo que viene

Estos siete problemas (más los ya cubiertos en artículos anteriores, como la ambigüedad entre orden de entrega y orden real de inicio) son la motivación para buscar herramientas de más alto nivel para expresar concurrencia, algo que queda fuera del alcance de este módulo sobre GCD.


Bibliografía

Top comments (0)