wandres.dev
SWIFT MULTIPLATAFORMA · Linux, Windows, Wasm

Swift en Linux: toolchain, Foundation y las divergencias silenciosas

Cómo se instala y se elige un toolchain en Linux, qué desaparece cuando no hay runtime de Objective-C, cuáles son las diferencias reales de `Foundation` y `Dispatch`, y el catálogo de casos en los que el mismo código compila en las dos plataformas y produce resultados distintos.

⏱ 20 min

Linux es el segundo ciudadano de pleno derecho del ecosistema de Swift y el primero en importancia si lo que escribes corre en un servidor. Que sea de pleno derecho no significa que sea idéntico: significa que el compilador, el depurador, el gestor de paquetes y las bibliotecas núcleo se construyen y se prueban allí con el mismo rigor. Las dificultades que aparecen al mover código desde macOS casi nunca son fallos de compilación —esas se ven enseguida y se arreglan en una tarde— sino divergencias de comportamiento en programas que compilan sin una sola advertencia y devuelven otra cosa. Ese es el material que conviene conocer de antemano, porque se manifiesta en producción y a menudo solo bajo una configuración regional concreta o un sistema de ficheros concreto.

🎯 Al terminar esta lección sabrás
  • Instalar y gestionar toolchains con swiftly, con contenedores o con un SDK de Swift, y saber cuándo conviene cada uno.
  • Enumerar con precisión qué desaparece en ausencia del runtime de Objective-C y qué lo sustituye.
  • Anticipar las divergencias de Foundation, Dispatch y el sistema de ficheros antes de que aparezcan en producción.
  • Producir binarios enlazados estáticamente y razonar sobre glibc, Musl y la superficie de despliegue.

El toolchain y sus tres caminos

Hay tres formas legítimas de tener Swift en Linux y elegir mal cuesta días. La primera es swiftly, el instalador oficial de toolchains: resuelve la distribución, descarga la versión pedida y permite alternar entre varias sin tocar el sistema.

curl -O https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz
tar zxf swiftly-$(uname -m).tar.gz && ./swiftly init
swiftly install latest && swiftly use 6.2
swift --version

Conviene saber que las compilaciones oficiales no cubren cualquier distribución: se publican para las familias que el proyecto construye y prueba —Ubuntu y Debian, Fedora, Amazon Linux y las imágenes universales de Red Hat—, y en el resto se recurre a la más cercana o al contenedor. La diferencia relevante entre ellas no es el gestor de paquetes sino la versión de glibc, que es lo que determina si un binario construido en una máquina arranca en otra.

La segunda vía es el contenedor. Las imágenes oficiales traen el toolchain completo, y las variantes ligeras traen solo lo necesario para ejecutar. El patrón de dos etapas —construir con la imagen completa, copiar el binario a la ligera— es el estándar de facto en despliegue.

FROM swift:6.2 AS build
WORKDIR /src
COPY . .
RUN swift build -c release --static-swift-stdlib

FROM swift:6.2-slim
COPY --from=build /src/.build/release/servidor /usr/local/bin/
CMD ["servidor"]

La tercera, y la más interesante, es el SDK de Swift: un paquete de artefactos que permite compilar para Linux desde cualquier anfitrión, incluido macOS, y que en su variante estática enlaza contra Musl en lugar de contra glibc.

swift sdk install <url-del-artifactbundle>          # SDK estatico para Linux
swift build --swift-sdk x86_64-swift-linux-musl -c release
ldd .build/release/servidor                          # not a dynamic executable

El resultado es un ejecutable sin dependencias dinámicas: cabe en una imagen vacía, arranca en milisegundos y elimina de un golpe la clase entera de fallos por versión de glibc incompatible entre la máquina de construcción y la de ejecución. Si en cambio prefieres seguir en glibc, la opción --static-swift-stdlib empotra solo la biblioteca estándar de Swift y deja fuera la libc.

💡
El fallo más común en despliegue no es de Swift

Un binario construido en una distribución con glibc reciente y ejecutado en otra más antigua falla al cargar, con un mensaje sobre versiones de símbolos que no menciona a Swift en ninguna parte. Es un problema de enlazado dinámico de toda la vida, y tiene dos soluciones limpias: construir en la misma imagen base que ejecutas, o pasarte al SDK estático con Musl y olvidarte del asunto.

flowchart TB
A[Necesito Swift en Linux] --> B[Desarrollo interactivo en la maquina]
A --> C[Construccion reproducible o despliegue]
B --> D[swiftly con varias toolchains]
C --> E[Contenedor en dos etapas]
C --> F[SDK estatico con Musl]
E --> G[Depende de glibc de la imagen base]
F --> H[Binario sin dependencias dinamicas]
style D fill:#89b4fa,color:#11111b
style H fill:#a6e3a1,color:#11111b
style G fill:#f9e2af,color:#11111b

Lo que desaparece y lo que lo sustituye

La diferencia estructural no es de bibliotecas: es que en Linux no hay runtime de Objective-C. De ahí se sigue, en cascada, casi todo lo que echarás de menos. No hay @objc ni dynamic, y por tanto no hay intercambio de métodos ni despacho por selector. No hay observación de claves. No hay archivado compatible con los formatos de Apple, de modo que un fichero escrito por un cliente de macOS con codificación de claves no se puede leer tal cual desde el servidor. Y no hay puenteado sin coste entre los tipos de Swift y los de CoreFoundation, aunque desde la reescritura del núcleo de Foundation esa pérdida ya no importa: no hay nada que puentear porque los tipos ya son de Swift.

Dispatch sí está, y con la misma API, pero con un detalle que sorprende a todo el mundo la primera vez: la cola principal no se ejecuta sola. En una aplicación de Apple hay un bucle de eventos que la atiende; en un proceso de Linux no lo hay, así que cualquier trabajo despachado a la cola principal se queda esperando para siempre salvo que cedas el hilo explícitamente.

DispatchQueue.main.async { print("esto no se imprime nunca") }
dispatchMain()   // sin esta linea, el trabajo encolado en main no corre jamas

Lo mismo ocurre con RunLoop y con Timer, que dependen de un bucle en marcha. En código nuevo el problema desaparece por otra vía: la concurrencia de Swift trae su propio ejecutor, Task y await funcionan igual en las dos plataformas, y el módulo Synchronization de la biblioteca estándar ofrece Mutex y Atomic de forma portable, sin recurrir a las primitivas de bloqueo de Foundation ni a las exclusivas de Darwin.

⚠️
Recuerda importar los módulos separados

Fuera de Darwin, URLSession vive en FoundationNetworking y XMLParser en FoundationXML. El código que solo se ha compilado en macOS nunca los importa, así que el primer intento en Linux siempre falla con un error de símbolo desconocido que no explica la causa. Envuelve esas importaciones en canImport y el problema no vuelve.

Divergencias silenciosas

Este es el catálogo que de verdad hace daño, porque nada de lo que sigue produce un error de compilación.

La sensibilidad a mayúsculas del sistema de ficheros encabeza la lista. macOS monta por defecto un volumen insensible y Linux es sensible, así que un recurso abierto como Config.json cuando el fichero se llama config.json funciona en el portátil y falla en el servidor. Es, con diferencia, el fallo de portabilidad más frecuente y el más fácil de evitar.

La configuración regional es la segunda. Un contenedor mínimo suele arrancar con la región C, no con la del usuario, y eso cambia el formateo de fechas y números, el resultado de comparaciones sensibles a la región y el de operaciones sobre cadenas que dependen del idioma. La regla es no dejarlo al azar: fija Locale(identifier: "en_US_POSIX") para todo lo que sea interoperabilidad de datos y reserva la región del usuario para lo que se muestra en pantalla.

La tercera es la gestión de procesos y señales. Lanzar un proceso hijo, esperar su salida, reaccionar a una señal de terminación o instalar un manejador ordenado de apagado son operaciones cuya semántica difiere lo bastante como para que el código escrito en macOS necesite revisión. En un servidor esto no es un detalle: el apagado limpio ante la señal de terminación es lo que separa un despliegue sin pérdida de peticiones de uno que corta conexiones a medias.

Después vienen las demás diferencias de sistema. La zona horaria se resuelve desde la base de datos del sistema y desde la variable de entorno correspondiente, que en un contenedor mínimo puede no existir. Los atributos extendidos de fichero no están disponibles en todos los sistemas de ficheros, de modo que fijar un valor de recurso sobre una URL puede fallar silenciosamente. Las rutas temporales difieren. El tamaño de pila de los hilos secundarios es menor, y un algoritmo recursivo que sobrevive en macOS puede desbordar en Linux. Y CryptoKit no existe: el sustituto es swift-crypto, con la misma API sobre BoringSSL, pero con una lista de algoritmos disponibles que no coincide del todo.

🔠

Mayúsculas y minúsculas

El sistema de ficheros sensible de Linux convierte en error lo que en macOS era una imprecisión tolerada. Comprueba los nombres de recursos antes de desplegar.

🌐

Región explícita

Para datos, en_US_POSIX siempre; para interfaz, la del usuario. Mezclarlas produce ficheros que un sistema escribe y el otro no puede leer.

🧵

La cola principal duerme

Sin dispatchMain ni bucle de eventos, nada encolado en la cola principal llega a ejecutarse. En código moderno, usa Task y evita el problema.

Probar y depurar allí donde corre

La única defensa contra las divergencias silenciosas es ejecutar las pruebas en Linux, y hoy eso es barato: swift-testing y XCTest funcionan en las dos plataformas y el descubrimiento automático de pruebas hace innecesarios los antiguos ficheros de registro manual. Un contenedor con la orden de prueba montando el directorio de trabajo da un ciclo de realimentación de segundos sin salir de la máquina de desarrollo.

Para depurar, LLDB viene en el toolchain y funciona como en macOS. Y hay una herramienta que conviene activar desde el primer día: el capturador de trazas de retroceso del runtime, que convierte un aborto opaco en una pila legible con nombres desmangados.

export SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all
swift test --enable-code-coverage
Portar a Linux no es un problema de compilación sino de supuestos no escritos

Cuando un equipo mueve por primera vez su código a Linux espera una batalla contra el compilador y encuentra, casi siempre, algo mucho más incómodo: el compilador calla y el programa miente. La razón es que el trabajo real de portar no consiste en satisfacer un conjunto de reglas sintácticas sino en hacer explícitos los supuestos que el entorno original satisfacía gratis y que nadie escribió nunca porque nadie los percibió como supuestos. Que el sistema de ficheros perdone las mayúsculas, que exista una región de usuario configurada, que haya un bucle de eventos atendiendo la cola principal, que la pila tenga ocho megabytes, que el sistema traiga una pila de red con su propio almacén de certificados: nada de eso está en el código, todo eso es el ambiente en el que el código creció. Portar es, literalmente, un ejercicio de arqueología sobre ese ambiente. De ahí que la técnica más rentable no sea aprender de memoria el catálogo de diferencias —envejece con cada versión— sino adquirir el hábito de preguntarse, ante cada operación que toca el mundo exterior, qué está dando por hecho y quién se lo garantiza. Ese hábito produce además un efecto secundario que suele sorprender: el código que sobrevive al viaje a Linux es mejor código también en macOS, porque las dependencias que antes eran implícitas pasan a estar declaradas, y una dependencia declarada se puede probar, sustituir y falsear en un test. La segunda plataforma no es una carga que se añade al proyecto; es el instrumento más barato que existe para descubrir qué parte de tu programa dependía de la suerte.

📝
Lo esencial

Usa swiftly para desarrollar, contenedores en dos etapas para construir y el SDK estático con Musl cuando quieras un binario sin dependencias. Sin runtime de Objective-C desaparecen @objc, la observación de claves y el archivado compatible. La cola principal de Dispatch no corre sin dispatchMain. Y las divergencias que hacen daño son silenciosas: mayúsculas en rutas, región por defecto, zona horaria, atributos extendidos y tamaño de pila.

⚔️ Rompe tu código en Linux a propósito
  1. Construye tu proyecto en un contenedor oficial y anota cuántos errores son importaciones que faltan y cuántos son de verdad.
  2. Renombra un recurso cambiando una mayúscula y comprueba que la prueba pasa en macOS y falla en Linux.
  3. Formatea la misma fecha con la región por defecto en las dos plataformas y compara las cadenas resultantes.
  4. Escribe un programa que despache trabajo a la cola principal sin dispatchMain y explica qué imprime y por qué.
  5. Construye el mismo binario con el SDK estático, ejecútalo en una imagen vacía y compara el tamaño con la versión dinámica.