1. ¿A qué llamamos programación concurrente?
2. Problemas de la programación concurrente
3. La concurrencia estricta de Swift 6
4. Herramientas de Swift 6 para aplicar la concurrencia estricta
5. ¿Cómo afrontar la migración a Swift 6 de nuestros proyectos?
6. Bibliografía
En la WorldWide Developers Conference (WWDC) de Apple, celebrada en junio de 2024, se anunció una de las actualizaciones más esperadas para el ecosistema de desarrollo de Apple: la llegada de Swift 6.
Esta nueva versión no solo trae consigo mejoras significativas en rendimiento y estabilidad, sino que también introduce un cambio fundamental en la manera en la que los desarrolladores podrán manejar la concurrencia en sus aplicaciones.
Con la concurrencia estricta como una de sus principales características, Swift 6 marca un hito en la evolución del lenguaje, brindando a los programadores herramientas más poderosas y seguras para gestionar tareas simultáneas de manera más eficiente.
En este blog, exploraremos qué implica esta nueva característica, cómo impacta el desarrollo de aplicaciones y por qué representa un paso crucial en la modernización del lenguaje.
¿A qué llamamos programación concurrente?
El paradigma de programación más básico es la programación estructurada, que utiliza subrutinas o instrucciones y tres estructuras esenciales:
- Selectiva: es el caso de las estructuras condicionales, como if-else o switch-case.
- Iterativa: o en ciclos, como, por ejemplo, for, while, etc.
- Secuencial: las instrucciones se ejecutan en secuencia, una detrás de otra, en el orden estricto en el que se han definido.
La programación concurrente, por definición, es la que nos permite manejar varias tareas de forma simultánea. Aunque no es ciertamente así. En realidad, la CPU que trabaja de forma concurrente lo que hace es dedicar un espacio de tiempo muy corto a una tarea y va cambiando rápidamente a otras tareas, por lo que aparenta estar ejecutando tareas a la vez.
La programación paralela se basa en dos o más CPUs o, en nuestro caso, varios núcleos, que sí pueden ejecutar diversas tareas de forma simultánea en el tiempo.
Sin embargo, Swift no es capaz por sí solo de decidir qué partes de código pueden ser divididas para ser ejecutadas de forma paralela. Es tarea del desarrollador indicar qué tareas se pueden dividir y cómo debe continuar la ejecución de la aplicación una vez que se hayan terminado.
Los dispositivos en los que se ejecutan nuestras aplicaciones son cada vez más potentes, por ejemplo, para el iPhone 16 se ha creado un procesador específico, el A18, con un Neural Engine de 16 núcleos. Si nuestro código no se adapta a la programación paralela, estaremos utilizando una mínima parte del rendimiento que nos ofrece el dispositivo.
Además, aplicar la programación paralela hará que nuestra aplicación no sólo sea más rápida en su rendimiento, si no que siempre estará operativa, sin bloqueos ni retardos de ejecución indeseados.
Problemas de la programación concurrente
El mayor problema al que nos enfrentamos cuando trabajamos con programación concurrente es el acceso compartido a un mismo dato desde varias tareas simultáneas.
Se conoce como data race o condición de carrera y se refiere a lo que sucede cuando dos tareas acceden a un dato de forma simultánea. Imaginemos un stock de un almacén con 10 productos, lanzamos dos tareas simultáneas para comprobar que hay 10 productos disponibles y venderlos todos a un cliente distinto en cada tarea. Si la lectura de los productos disponibles se realiza a la vez, ambas tareas verán que hay 10 en stock y se venderán 20, aunque disponibles solo hay 10. Esta inconsistencia en la lectura de datos en nuestras aplicaciones puede derivar en datos inconsistentes, comportamientos inesperados o incluso en crashes durante la ejecución.
Otro problema que puede derivarse es el interbloqueo o deadlock que se produce cuando un hilo espera a que un segundo hilo termine y a su vez el segundo espera a que termine el primero, bloqueándose mutuamente.
A su vez, la inanición o starvation se produce cuando la ejecución de un hilo queda siempre pospuesta a la espera de que otros hilos con mayor prioridad terminen y no se completa nunca.
Además, no debemos obviar que trabajar con concurrencia en nuestro código supone añadir un extra de complejidad que puede resultar difícil de mantener y escalar. Depurar código concurrente también es más complejo, puesto que puede dar errores que no sean fácilmente identificables. Por último, una programación concurrente deficiente, puede provocar que el uso de los recursos de memoria o CPU no sea óptimo y que la ejecución de nuestra aplicación se vea lastrada.
La concurrencia estricta de Swift 6
Con vistas a que el código de los desarrolladores se adapte al creciente rendimiento de los dispositivos que Apple ofrece, la última versión del lenguaje que han publicado, Swift 6, adopta el paradigma de la concurrencia estricta de forma imperativa.
La concurrencia estricta consiste en la búsqueda automática por parte del compilador en aquellas partes del código donde se puedan dar condiciones de carrera o data races. Es decir, Swift 6 asegura que el código es seguro frente a data races.
Dado que la migración puede resultar bastante compleja y alargarse en el tiempo, Apple ofrece la posibilidad de activar desde Swift 5.5 las comprobaciones de concurrencia de forma paulatina, separadas en 3 bloques independientes:
DisableOutwardActorInference: supone eliminar la inferencia de aislamiento del actor que viene causada por los empaquetadores de propiedad. Hasta Swift 6 los property wrappers que tienen marcado su wrapped value con @MainActor se infiere que son también @MainActor. Por ejemplo, al utilizar un @StateObject en una View de SwiftUI automáticamente la View completa será @MainActor. Al activar esta comprobación en Swift 5.5 los property wrappers dejan de aplicar este comportamiento.
GlobalConcurrency: aplica concurrencia estricta a variables globales. El problema que subyace a este tipo de variables es que pueden ser accedidas y modificadas desde cualquier contexto, por lo que son susceptibles de provocar condiciones de carrera. Tras activar esta comprobación es recomendable aislar la variable a un global actor o hacerla inmutable y de tipo Sendable.
InferSendableFromCaptures: hay algunos supuestos residuales de funciones y rutas de acceso literales que pueden colisionar con la aplicación de concurrencia.
Funciones parcialmente aplicadas: son propias de la programación funcional y se definen por tomar múltiples argumentos y transformarlos en otra función con menos argumentos. El problema se produce cuando este tipo de funciones captura variables que pueden ser accedidas desde varios contextos y puede producir condiciones de carrera. La propuesta para conformarlas a concurrencia es marcarlas como tipo Sendable.
Las rutas de acceso literales (key path literals) pueden capturar cualquier valor que sea de tipo no Sendable, provocando potencialmente problemas de concurrencia. Por ello, la activación de la comprobación generará warnings indicando que los valores capturados deben ser de tipo Sendable.
Si realizamos las comprobaciones desde Swift 5.5, se mostrarán como avisos y no como errores de compilación, lo cual nos permitirá continuar desarrollando, sin necesidad de estar bloqueados mientras resolvemos todas las incidencias de concurrencia detectadas.
Herramientas de Swift 6 para aplicar la concurrencia estricta
- Funciones asíncronas: son las funciones que se pueden pausar en su ejecución. Un ejemplo sencillo de entender serían aquellas funciones que hacen peticiones a backend para recibir una respuesta. Hay que marcarlas con async para indicar que la ejecución se tiene que parar hasta que el método devuelva lo que se espera de él. Cuando lo invocamos, lo marcamos siempre con await, ya que la suspensión de la ejecución nunca está implícita. Haciendo esto, el hilo en el que se está ejecutando, se libera para poder continuar con otras tareas, hasta que retorna. En ese momento se retomará su ejecución en el mismo hilo o en otro. Es importante utilizar async-await en nuestras funciones asíncronas ya que no hay forma segura de llamarlas desde código síncrono. Aunque utilicemos bloques try-catch para capturar posibles errores o Result para encapsularlos, no estaremos cumpliendo con la concurrencia estricta.
- Tasks y Task Groups: un Task es una unidad de trabajo que puede ser ejecutada de forma asíncrona. Un Task en sí es secuencial, pero si tenemos varios Task, pueden ser ejecutados de manera simultánea. Los Tasks pueden pertenecer a un TaskGroup, esto nos da la ventaja de poder añadirle Tasks de forma dinámica, ordenarlos por prioridad o cancelarlos. Se ordenan de forma jerárquica. Además, cada Task puede tener Tasks hijos asociados. El uso de Tasks en Task Groups es lo que se denomina concurrencia estructurada. Swift también nos permite utilizarlos independientemente, por lo tanto tenemos la flexibilidad de aplicar igualmente una concurrencia desestructurada.
- Actores: A diferencia de los Tasks, que son unidades aisladas de código, los Actores nos permiten compartir información entre código concurrente y lo hacen habilitando el acceso a sus estados mutables sólo a una tarea cada vez. Así se evitan las condiciones de carrera. Para ello, cuando se quiere acceder a una propiedad o método de un actor, hay que marcar con await (suspensión temporal de la ejecución del código), lo que hará que se quede a la espera si en otro lugar del código se está accediendo a esas propiedades o métodos en ese momento.
(Ejemplo de las herramientas explicadas anteriormente)
Secuencias asíncronas: Serían aquellos casos en los que varias funciones asíncronas dependientes de la finalización de ejecución de otras funciones anteriores es aconsejable utilizar un bucle for-await—in. En este caso, el bucle detendrá la ejecución en cada iteración hasta que termine para después continuar con la siguiente:
- Tipos Sendable: un dominio de concurrencia es la parte de un actor o task que contiene estados mutables, como las variables y las propiedades. Algunos datos no pueden ser compartidos entre dominios de concurrencia porque no se protege el solapamiento de accesos a esos datos. Para que ese tipo de datos se pueda compartir tiene que implementar el protocolo @Sendable. Para ello es necesario que sus variables y propiedades sean inmutables o, si son mutables, debemos garantizar que sean accesibles de forma segura por varios hilos.
¿Cómo afrontar la migración a Swift 6 de nuestros proyectos?
Es aconsejable abordar la migración desde Swift 5.5, activando la comprobación de concurrencia en el compilador de forma paulatina.
Es importante entender muy bien los conceptos de concurrencia estricta y los diversos supuestos a los que nos vamos a enfrentar. También debemos conocer bien el código que vamos a migrar, saber de dónde partimos y a dónde queremos llegar. Es por ello por lo que no debemos abordarla con prisa ni atropelladamente. Si lo hacemos es posible que el código resultante sea menos eficiente, legible y escalable.
Si el código está modularizado, conviene empezar por algún módulo pequeño e ir migrando poco a poco.
Bibliografía
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/
https://developer.apple.com/documentation/swift/adoptingswift6
https://www.hackingwithswift.com/quick-start/concurrency/concurrency-vs-parallelism
https://www.massicotte.org/concurrency-swift-6-se-401
https://paul-samuels.com/blog/2018/01/31/swift-partially-applied-functions/