Concurrencia avanzada en Swift con HoneyBee
Publicado: 2022-03-11Diseñar, probar y mantener algoritmos simultáneos en Swift es difícil y obtener los detalles correctos es fundamental para el éxito de su aplicación. Un algoritmo concurrente (también llamado programación paralela) es un algoritmo que está diseñado para realizar múltiples (quizás muchas) operaciones al mismo tiempo para aprovechar más recursos de hardware y reducir el tiempo de ejecución general.
En las plataformas de Apple, la forma tradicional de escribir algoritmos concurrentes es NSOperation. El diseño de NSOperation invita al programador a subdividir un algoritmo concurrente en tareas asíncronas individuales de larga duración. Cada tarea se definiría en su propia subclase de NSOperation y las instancias de esas clases se combinarían a través de una API objetiva para crear un orden parcial de tareas en tiempo de ejecución. Este método de diseño de algoritmos concurrentes fue el estado del arte en las plataformas de Apple durante siete años.
En 2014, Apple presentó Grand Central Dispatch (GCD) como un gran paso adelante en la expresión de operaciones simultáneas. GCD, junto con los nuevos bloques de funciones de lenguaje que lo acompañaban y potenciaban, proporcionaron una forma de describir de forma compacta un controlador de respuesta asíncrona inmediatamente después de la solicitud asíncrona de inicio. Ya no se animaba a los programadores a difundir la definición de tareas simultáneas en varios archivos en numerosas subclases de NSOperation. Ahora, un algoritmo concurrente completo podría escribirse dentro de un solo método. Este aumento en la expresividad y la seguridad tipográfica supuso un avance conceptual significativo. Un algoritmo típico de esta forma de escribir podría tener el siguiente aspecto:
func processImageData(completion: (result: Image?, error: Error?) -> Void) { loadWebResource("dataprofile.txt") { (dataResource, error) in guard let dataResource = dataResource else { completion(nil, error) return } loadWebResource("imagedata.dat") { (imageResource, error) in guard let imageResource = imageResource else { completion(nil, error) return } decodeImage(dataResource, imageResource) { (imageTmp, error) in guard let imageTmp = imageTmp else { completion(nil, error) return } dewarpAndCleanupImage(imageTmp) { imageResult in guard let imageResult = imageResult else { completion(nil, error) return } completion(imageResult, nil) } } } } }
Analicemos un poco este algoritmo. La función processImageData
es una función asíncrona que realiza cuatro llamadas asíncronas propias para completar su trabajo. Las cuatro invocaciones asíncronas están anidadas una dentro de la otra de la manera más natural para el manejo asíncrono basado en bloques. Cada uno de los bloques de resultados tiene un parámetro de error opcional y todos menos uno contienen un parámetro opcional adicional que indica el resultado de la operación aysnc.
La forma del bloque de código anterior probablemente les resulte familiar a la mayoría de los desarrolladores de Swift. Pero, ¿qué tiene de malo este enfoque? La siguiente lista de puntos débiles probablemente le resulte igualmente familiar.
- Esta forma de "pirámide de la perdición" de bloques de código anidados puede volverse difícil de manejar rápidamente. ¿Qué sucede si agregamos dos operaciones asíncronas más? ¿Cuatro? ¿Qué pasa con las operaciones condicionales? ¿Qué tal el comportamiento de reintento o las protecciones para los límites de recursos? El código del mundo real nunca es tan limpio y simple como los ejemplos en las publicaciones de blog. El efecto de “pirámide de la perdición” puede resultar fácilmente en un código que es difícil de leer, difícil de mantener y propenso a errores.
- El intento de manejo de errores en el ejemplo anterior, aunque Swifty, de hecho, está incompleto. El programador asumió que los bloques de devolución de llamada asíncronos de estilo Objective-C de dos parámetros siempre proporcionarán uno de los dos parámetros; nunca serán ambos nulos al mismo tiempo. Esta no es una suposición segura. Los algoritmos concurrentes son conocidos por ser difíciles de escribir y depurar, y las suposiciones infundadas son parte del motivo. El manejo de errores completo y correcto es una necesidad ineludible para cualquier algoritmo concurrente que pretenda operar en el mundo real.
- Llevando este pensamiento aún más lejos, quizás el programador que escribió las llamadas funciones asincrónicas no tenía tantos principios como usted. ¿Qué pasa si hay condiciones bajo las cuales las funciones llamadas no pueden devolver la llamada? ¿O devolver la llamada más de una vez? ¿Qué sucede con la corrección de processImageData en estas circunstancias? Los profesionales no se arriesgan. Las funciones de misión crítica deben ser correctas incluso cuando se basan en funciones escritas por terceros.
- Quizás lo más convincente es que el algoritmo asíncrono considerado está construido de manera subóptima. Las dos primeras operaciones asíncronas son descargas de recursos remotos. Aunque no tienen interdependencia, el algoritmo anterior ejecuta las descargas secuencialmente y no en paralelo. Las razones para esto son obvias; la sintaxis de bloques anidados fomenta tal despilfarro. Los mercados competitivos no toleran retrasos innecesarios. Si su aplicación no realiza sus operaciones asincrónicas lo más rápido posible, otra aplicación lo hará.
¿Cómo podemos hacerlo mejor? HoneyBee es una biblioteca de futuros/promesas que hace que la programación simultánea de Swift sea fácil, expresiva y segura. Reescribamos el algoritmo asíncrono anterior con HoneyBee y examinemos el resultado:
func processImageData(completion: (result: Image?, error: Error?) -> Void) { HoneyBee.start() .setErrorHandler { completion(nil, $0) } .branch { stem in stem.chain(loadWebResource =<< "dataprofile.txt") + stem.chain(loadWebResource =<< "imagedata.dat") } .chain(decodeImage) .chain(dewarpAndCleanupImage) .chain { completion($0, nil) } }
La primera línea que comienza esta implementación es una nueva receta de HoneyBee. La segunda línea establece el controlador de errores predeterminado. El manejo de errores no es opcional en las recetas de HoneyBee. Si algo puede salir mal, el algoritmo debe manejarlo. La tercera línea abre una rama que permite la ejecución en paralelo. Las dos cadenas de loadWebResource
se ejecutarán en paralelo y sus resultados se combinarán (línea 5). Los valores combinados de los dos recursos cargados se reenvían a decodeImage
y así sucesivamente hasta que se invoca la finalización.
Repasemos la lista anterior de puntos débiles y veamos cómo HoneyBee ha mejorado este código. Mantener esta función ahora es significativamente más fácil. La receta de HoneyBee se parece al algoritmo que expresa. El código es legible, comprensible y rápidamente modificable. El diseño de HoneyBee garantiza que cualquier orden incorrecto de las instrucciones resulte en un error en tiempo de compilación, no en un error en tiempo de ejecución. La función ahora es mucho menos susceptible a errores y errores humanos.
Todos los posibles errores de tiempo de ejecución se han manejado por completo. Cada firma de función que soporta HoneyBee (hay 38 de ellas) está garantizada para ser completamente manejada. En nuestro ejemplo, la devolución de llamada de dos parámetros de estilo Objective-C producirá un error no nulo que se enrutará al controlador de errores, o producirá un valor no nulo que avanzará en la cadena, o si ambos los valores son nulos HoneyBee generará un error explicando que la devolución de llamada de la función no está cumpliendo su contrato.
HoneyBee también maneja la corrección contractual para la cantidad de veces que se invocan las devoluciones de llamada de funciones. Si una función no puede invocar su devolución de llamada, HoneyBee produce una falla descriptiva. Si la función invoca su devolución de llamada más de una vez, HoneyBee suprimirá las invocaciones auxiliares y las advertencias de registro. Ambas respuestas a fallas (y otras) se pueden personalizar para las necesidades individuales del programador.
Con suerte, ya debería ser evidente que esta forma de processImageData
paraleliza correctamente las descargas de recursos para proporcionar un rendimiento óptimo. Uno de los objetivos de diseño más importantes de HoneyBee es que la receta se parezca al algoritmo que expresa.
Mucho mejor. ¿Derecha? Pero HoneyBee tiene mucho más que ofrecer.
Tenga cuidado: el próximo estudio de caso no es para los débiles de corazón. Considere la siguiente descripción del problema: su aplicación móvil usa CoreData
para conservar su estado. Tiene un modelo NSManagedObject
denominado Medios, que representa un activo de medios cargado en su servidor back-end. El usuario debe poder seleccionar docenas de elementos multimedia a la vez y cargarlos en un lote al sistema de back-end. Los medios se representan primero a través de una cadena de referencia, que debe convertirse en un objeto de medios. Afortunadamente, su aplicación ya contiene un método auxiliar que hace precisamente eso:
func export(_ mediaRef: String, completion: @escaping (Media?, Error?) -> Void) { // transcoding stuff completion(Media(context: managedObjectContext), nil) }
Después de que la referencia de medios se convierta en un objeto de medios, debe cargar el elemento de medios en el back-end. Nuevamente, tiene una función de ayuda lista para hacer las cosas de la red.
func upload(_ media: Media, completion: @escaping (Error?) -> Void) { // network stuff completion(nil) }
Debido a que el usuario puede seleccionar docenas de elementos multimedia a la vez, el diseñador de UX ha especificado una cantidad bastante sólida de comentarios sobre el progreso de la carga. Los requisitos se han destilado en las siguientes cuatro funciones:
/// Called if anything goes wrong in the upload func errorHandler(_ error: Error) { // do the right thing } /// Called once per mediaRef, after either a successful or unsuccessful upload func singleUploadCompletion(_ mediaRef: String) { // update a progress indicator } /// Called once per successful upload func singleUploadSuccess(_ media: Media) { // do celebratory things } /// Called if the entire batch was considered to be uploaded successfully. func totalProcessSuccess() { // declare victory }
Sin embargo, debido a que su aplicación obtiene referencias de medios que a veces están vencidas, los gerentes comerciales han decidido enviar al usuario un mensaje de "éxito" si al menos la mitad de las cargas son exitosas. Es decir, que el proceso simultáneo debería declarar la victoria y llamar a totalProcessSuccess
si menos de la mitad de los intentos de carga fallan. Esta es la especificación que se le entregó como desarrollador. Pero como programador experimentado, te das cuenta de que hay más requisitos que deben aplicarse.
Por supuesto, Business quiere que la carga por lotes se realice lo más rápido posible, por lo que la carga en serie está fuera de discusión. Las cargas deben realizarse en paralelo.
Pero no demasiado. Si simplemente async
indiscriminadamente todo el lote, las docenas de cargas simultáneas inundarán la NIC móvil (tarjeta de interfaz de red) y las cargas en realidad serán más lentas que en serie, no más rápidas.
Las conexiones de red móvil no se consideran estables. Incluso las transacciones cortas pueden fallar debido solo a cambios en la conectividad de la red. Para declarar verdaderamente que una carga ha fallado, debemos volver a intentar la carga al menos una vez.
La política de reintento no debe incluir la operación de exportación porque no está sujeta a fallas transitorias.
El proceso de exportación está vinculado a la computación y, por lo tanto, debe realizarse fuera del subproceso principal.
Debido a que la exportación está ligada a la computación, debe tener una cantidad menor de instancias simultáneas que el resto del proceso de carga para evitar sobrecargar el procesador.
Las cuatro funciones de devolución de llamada descritas anteriormente actualizan la interfaz de usuario, por lo que todas deben llamarse en el subproceso principal.
Media es un NSManagedObject
, que proviene de un NSManagedObjectContext
y tiene sus propios requisitos de subprocesamiento que deben respetarse.

¿La especificación de este problema parece un poco oscura? No se sorprenda si encuentra problemas como este acechando en su futuro. Encontré uno como este en mi propio trabajo. Primero intentemos resolver este problema con herramientas tradicionales. Abróchate el cinturón, esto no será bonito.
/// An enum describing specific problems that the algorithm might encounter. enum UploadingError : Error { case invalidResponse case tooManyFailures } /// A semaphore to prevent flooding the NIC let outerLimit = DispatchSemaphore(value: 4) /// A semaphore to prevent thrashing the processor let exportLimit = DispatchSemaphore(value: 1) /// The number of times to retry the upload if it fails let uploadRetries = 1 /// Dispatch group to keep track of when the entire process is finished let fullProcessDispatchGroup = DispatchGroup() /// How many of the uploads fully completed. var uploadSuccesses = 0 // this notify block is called when the full process has completed. fullProcessDispatchGroup.notify(queue: DispatchQueue.main) { let successRate = Float(uploadSuccesses) / Float(mediaReferences.count) if successRate > 0.5 { totalProcessSuccess() } else { errorHandler(UploadingError.tooManyFailures) } } // start in the background DispatchQueue.global().async { for mediaRef in mediaReferences { // alert the group that we're starting a process fullProcessDispatchGroup.enter() // wait until it's safe to start uploading outerLimit.wait() /// common cleanup operations needed later func finalizeMediaRef() { singleUploadCompletion(mediaRef) fullProcessDispatchGroup.leave() outerLimit.signal() } // wait until it's safe to start exporting exportLimit.wait() export(mediaRef) { (media, error) in // allow another export to begin exportLimit.signal() if let error = error { DispatchQueue.main.async { errorHandler(error) finalizeMediaRef() } } else { guard let media = media else { DispatchQueue.main.async { errorHandler(UploadingError.invalidResponse) finalizeMediaRef() } return } // the export was successful var uploadAttempts = 0 /// define the upload process and its retry behavior func doUpload() { // respect Media's threading requirements managedObjectContext.perform { upload(media) { error in if let error = error { if uploadAttempts < uploadRetries { uploadAttempts += 1 doUpload() // retry } else { DispatchQueue.main.async { // too many upload failures errorHandler(error) finalizeMediaRef() } } } else { DispatchQueue.main.async { uploadSuccesses += 1 singleUploadSuccess(media) finalizeMediaRef() } } } } } // kick off the first upload doUpload() } } } }
¡Guau! Sin comentarios, eso es alrededor de 75 líneas. ¿Seguiste el razonamiento hasta el final? ¿Cómo te sentirías si te encontraras con este monstruo en tu primera semana en un nuevo trabajo? ¿Se sentiría preparado para mantenerlo o modificarlo? ¿Sabrías si contiene errores? ¿Contiene errores?
Ahora, considere la alternativa HoneyBee:
HoneyBee.start(on: DispatchQueue.main) .setErrorHandler(errorHandler) .insert(mediaReferences) .setBlockPerformer(DispatchQueue.global()) .each(limit: 4, acceptableFailure: .ratio(0.5)) { elem in elem.finally { link in link.setBlockPerformer(DispatchQueue.main) .chain(singleUploadCompletion) } .limit(1) { link in link.chain(export) } .setBlockPerformer(managedObjectContext) .retry(1) { link in link.chain(upload) // subject to transient failure } .setBlockPerformer(DispatchQueue.main) .chain(singleUploadSuccess) } .setBlockPerformer(DispatchQueue.main) .drop() .chain(totalProcessSuccess)
¿Qué te parece esta forma? Vamos a trabajar a través de él pieza por pieza. En la primera línea, comenzamos la receta HoneyBee, comenzando en el hilo principal. Al comenzar en el hilo principal, nos aseguramos de que todos los errores se pasen a errorHandler (línea 2) en el hilo principal. La línea 3 inserta la matriz mediaReferences
en la cadena de procesos. A continuación, cambiamos a la cola de fondo global en preparación para cierto paralelismo. En la línea 5, comenzamos una iteración paralela sobre cada una de las mediaReferences
. Limitamos este paralelismo a un máximo de 4 operaciones simultáneas. También declaramos que la iteración completa se considerará exitosa si al menos la mitad de las subcadenas tienen éxito (sin error). La línea 6 declara un enlace finally
que se llamará si la subcadena a continuación tiene éxito o falla. En el enlace finally
, cambiamos al subproceso principal (línea 7) y llamamos a singleUploadCompletion
(línea 8). En la línea 10, establecemos una paralelización máxima de 1 (ejecución única) alrededor de la operación de exportación (línea 11). La línea 13 cambia a la cola privada propiedad de nuestra instancia de managedObjectContext
. La línea 14 declara un solo intento de reintento para la operación de carga (línea 15). La línea 17 cambia al subproceso principal una vez más y la 18 invoca singleUploadSuccess
. En el momento en que se ejecutaría la línea 20, todas las iteraciones paralelas se habrán completado. Si fallaron menos de la mitad de las iteraciones, entonces la línea 20 cambia a la cola principal por última vez (recuerde que cada una se ejecutó en la cola de fondo), 21 descarta el valor entrante (aún mediaReferences
) y 22 invoca totalProcessSuccess
.
El formulario HoneyBee es más claro, más limpio y más fácil de leer, sin mencionar que es más fácil de mantener. ¿Qué le sucedería a la forma larga de este algoritmo si se requiriera que el bucle reintegrara los objetos multimedia en una matriz como una función de mapa? Después de haber realizado el cambio, ¿qué tan seguro estaría de que se siguen cumpliendo todos los requisitos del algoritmo? En el formulario HoneyBee, este cambio sería reemplazar cada uno con el mapa para emplear una función de mapa paralelo. (Sí, también tiene reducción).
HoneyBee es una poderosa biblioteca de futuros para Swift que hace que la escritura de algoritmos asincrónicos y concurrentes sea más fácil, segura y expresiva. En este artículo, hemos visto cómo HoneyBee puede hacer que sus algoritmos sean más fáciles de mantener, más correctos y más rápidos. HoneyBee también es compatible con otros paradigmas asincrónicos clave, como soporte de reintento, controladores de errores múltiples, protección de recursos y procesamiento de recopilación (formas asincrónicas de mapa, filtro y reducción). Para obtener una lista completa de funciones, consulte el sitio web. Para obtener más información o hacer preguntas, consulte los nuevos foros de la comunidad.
Apéndice: Garantizar la corrección contractual de las funciones asíncronas
Garantizar la corrección contractual de las funciones es un principio fundamental de la informática. Tanto es así que prácticamente todos los compiladores modernos tienen controles para garantizar que una función que declara devolver un valor, lo hace exactamente una vez. Devolver menos o más de una vez se trata como un error y evita una compilación completa.
Pero esta asistencia del compilador generalmente no se aplica a las funciones asíncronas. Considere el siguiente ejemplo (juguetón):
func generateIcecream(from int: Int, completion: (String) -> Void) { if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } completion("Pistachio") } else if int < 2 { completion("Vanilla") } }
La función generateIcecream
acepta un Int y devuelve una Cadena de forma asíncrona. El compilador rápido acepta felizmente el formulario anterior como correcto, aunque contiene algunos problemas obvios. Dadas ciertas entradas, esta función podría llamar a la finalización cero, una o dos veces. Los programadores que han trabajado con funciones asíncronas a menudo recordarán ejemplos de este problema en su propio trabajo. ¿Qué podemos hacer? Ciertamente, podríamos refactorizar el código para que sea más ordenado (aquí funcionaría un interruptor con casos de rango). Pero a veces la complejidad funcional es difícil de reducir. ¿No sería mejor si el compilador pudiera ayudarnos a verificar la corrección tal como lo hace con las funciones que regresan regularmente?
Resulta que hay una manera. Observa el siguiente encantamiento Swifty:
func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } // else completion("Pistachio") } else if int < 2 { completion("Vanilla") } }
Las cuatro líneas insertadas en la parte superior de esta función obligan al compilador a verificar que la devolución de llamada de finalización se invoque exactamente una vez, lo que significa que esta función ya no compila. ¿Qué está sucediendo? En la primera línea, declaramos pero no inicializamos el resultado que finalmente queremos que produzca esta función. Al dejarlo sin definir, nos aseguramos de que debe asignarse una vez antes de que pueda usarse, y al declararlo, nos aseguramos de que nunca se pueda asignar dos veces. La segunda línea es un aplazamiento que se ejecutará como la acción final de esta función. Invoca el bloque de finalización con finalResult
, después de que el resto de la función lo haya asignado. La línea 3 crea una nueva constante llamada finalización que sombrea el parámetro de devolución de llamada. La nueva finalización es de tipo Void, que no declara ninguna API pública. Esta línea asegura que cualquier uso de finalización después de esta línea será un error del compilador. El aplazamiento en la línea 2 es el único uso permitido del bloque de finalización. La línea 4 elimina una advertencia del compilador que de otro modo estaría presente sobre la nueva constante de finalización sin usar.
Así que forzamos con éxito al compilador rápido a informar que esta función asíncrona no está cumpliendo con su contrato. Repasemos los pasos para corregirlo. Primero, reemplacemos todos los accesos directos a la devolución de llamada con una asignación a finalResult
.
func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } // else finalResult = "Pistachio" } else if int < 2 { finalResult = "Vanilla" } }
Ahora el compilador informa dos problemas:
error: AsyncCorrectness.playground:1:8: error: constant 'finalResult' used before being initialized defer { completion(finalResult) } ^ error: AsyncCorrectness.playground:11:3: error: immutable value 'finalResult' may only be initialized once finalResult = "Pistachio"
Como era de esperar, la función tiene una ruta en la que finalResult
se asigna cero veces y también una ruta en la que se asigna más de una vez. Resolvemos estos problemas de la siguiente manera:
func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } else { finalResult = "Pistachio" } } else if int < 2 { finalResult = "Vanilla" } else { finalResult = "Neapolitan" } }
El "pistacho" se ha movido a una cláusula else adecuada y nos damos cuenta de que no cubrimos el caso general, que por supuesto es "napolitano".
Los patrones que se acaban de describir se pueden ajustar fácilmente para devolver valores opcionales, errores opcionales o tipos complejos como la enumeración Result común. Al obligar al compilador a verificar que las devoluciones de llamada se invoquen exactamente una vez, podemos afirmar la corrección y la integridad de las funciones asíncronas.