SwiftNIO: el motor que hay debajo de todo
La arquitectura de `SwiftNIO`: por qué un hilo por conexión no escala, cómo funciona el bucle de eventos sobre `epoll` y `kqueue`, qué es exactamente un `Channel` y su tubería de manejadores, el contrato de aislamiento del `EventLoop`, y cómo se cose todo eso con async/await sin bloquear el motor.
Ni Vapor ni Hummingbird implementan la red. Los dos son capas de comodidad sobre la misma pieza: SwiftNIO, una biblioteca de entrada y salida no bloqueante que Apple publicó en 2018 tomando prestada la arquitectura de Netty, y que es lo único de la pila del servidor que verdaderamente no puedes ignorar. Entender NIO cambia la naturaleza de los problemas que sabes diagnosticar: sin ese conocimiento, un servidor que se queda mudo bajo carga es un misterio; con él, es un EventLoop bloqueado por alguien que llamó a una función síncrona donde no debía, y el fallo tiene nombre, causa y arreglo.
- Explicar por qué el modelo de un hilo por conexión colapsa y qué lo sustituye en un servidor moderno.
- Describir el bucle de eventos como unidad de ejecución y enunciar su contrato de aislamiento.
- Componer una tubería de manejadores sobre un
Channely razonar sobre el flujo entrante y saliente. - Integrar código con async/await sobre
NIOsin bloquear jamás un hilo del motor.
Por qué un hilo por conexión no sobrevive
El modelo clásico asigna un hilo del sistema operativo a cada conexión: el hilo lee, se bloquea esperando datos, procesa y responde. Es cómodo de escribir y se rompe por tres sitios a la vez. Cada hilo reserva una pila —típicamente entre 512 KB y 8 MB de espacio virtual—, así que diez mil conexiones ociosas cuestan gigabytes sin haber transferido un solo byte. El planificador del núcleo tiene que repartir el tiempo entre miles de hilos casi todos dormidos, y cada cambio de contexto invalida cachés. Y la mayoría de esos hilos no está calculando nada: está esperando.
La alternativa es invertir la pregunta. En lugar de que cada conexión pregunte «¿hay datos para mí?» y se duerma, un puñado de hilos pregunta al núcleo «¿cuál de estos veinte mil descriptores está listo?» y recibe solo los que lo están. Esa primitiva se llama epoll en Linux y kqueue en las plataformas BSD, y NIO la envuelve tras una interfaz común.
let grupo = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
defer { try? grupo.syncShutdownGracefully() }
El número de hilos por defecto es el número de núcleos, y esa cifra no es arbitraria: si cada hilo solo trabaja cuando hay algo que hacer y nunca se bloquea, tener más hilos que núcleos solo añade cambios de contexto sin añadir capacidad. Cien mil conexiones ociosas ocupan cien mil descriptores y una tabla, no cien mil pilas.
El bucle de eventos y su contrato
Un EventLoop es un hilo con un bucle infinito y una cola de trabajo. En cada vuelta pregunta al núcleo qué descriptores están listos, ejecuta los manejadores correspondientes, vacía la cola de tareas pendientes y vuelve a esperar. Todo lo que ocurre en ese hilo ocurre en serie.
flowchart TB
A[EventLoop arranca] --> B[Pregunta al selector por descriptores listos]
B --> C{Hay eventos}
C -->|Si| D[Ejecuta manejadores del Channel afectado]
C -->|No| E[Espera con tiempo limite]
D --> F[Vacia la cola de tareas encoladas]
E --> F
F --> B
G[Trabajo bloqueante dentro de D] --> H[El bucle no vuelve a B]
H --> I[Todas las conexiones de ese loop se congelan]
style I fill:#f38ba8,color:#11111b
style F fill:#a6e3a1,color:#11111bDe ahí sale el contrato entero de NIO, y merece memorizarse. Primero: cada Channel está atado de por vida a un único EventLoop, y todo su estado se toca solo desde ese hilo, lo que elimina la necesidad de cerrojos dentro de un manejador. Segundo: nada puede bloquear el bucle, porque un bucle detenido detiene a la vez a todas las conexiones que le tocaron, no solo a la que causó el problema. Tercero: para tocar un Channel desde fuera de su bucle hay que encolar trabajo con execute o submit, nunca llamar directamente.
Una llamada síncrona a disco, un DispatchSemaphore.wait, una consulta con un controlador bloqueante o un simple Thread.sleep dentro de un manejador congelan un núcleo entero de tu servidor. El síntoma es traicionero: no falla nada, no hay error, la latencia de una fracción de las peticiones —justo las que cayeron en ese bucle— se dispara mientras el resto va perfecto. Si ves una distribución de latencias con dos jorobas, empieza a buscar aquí.
Channels y la tubería de manejadores
Un Channel es la abstracción de una conexión: un socket, un canal de datagramas, o incluso un par de tuberías. Lo que lo hace interesante no es el socket sino su ChannelPipeline, una lista doblemente enlazada de manejadores por la que atraviesan los datos.
final class EcoHandler: ChannelInboundHandler {
typealias InboundIn = ByteBuffer
typealias OutboundOut = ByteBuffer
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let entrada = unwrapInboundIn(data)
context.write(wrapOutboundOut(entrada), promise: nil)
}
func channelReadComplete(context: ChannelHandlerContext) {
context.flush()
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
context.close(promise: nil)
}
}
Los datos entrantes recorren la tubería desde la cabeza hacia la cola atravesando los manejadores entrantes; los salientes la recorren en sentido inverso por los manejadores salientes. Esa simetría es la que permite componer protocolos por capas: un manejador de TLS descifra bytes y cifra los de vuelta, uno de HTTP convierte bytes en mensajes y mensajes en bytes, uno de compresión se interpone entre ambos, y tu lógica de aplicación vive al final sin enterarse de nada de eso.
ServerBootstrap(group: grupo)
.childChannelInitializer { canal in
canal.pipeline.configureHTTPServerPipeline().flatMap {
canal.pipeline.addHandler(EcoHandler())
}
}
.bind(host: "0.0.0.0", port: 8080)
La unidad de datos es ByteBuffer, y no es un detalle menor: se apoya en almacenamiento reservado por bloques y reutilizado, con índices independientes de lectura y escritura, precisamente para evitar la asignación por petición que dominaría el perfil de rendimiento de cualquier servidor que usara Data o Array de bytes ingenuamente.
Un canal, un bucle, sin cerrojos
Dentro de un manejador no hace falta sincronizar nada. Fuera de su bucle, no puedes tocar nada directamente.
Protocolos por capas
TLS, HTTP, compresión y aplicación son manejadores independientes que se apilan. Añadir una capa es insertar un elemento.
Buffers reutilizados
ByteBuffer existe para no asignar memoria por petición. Convertirlo a Data en el camino caliente anula el diseño.
Cosiendo NIO con async/await
La interfaz histórica de NIO es EventLoopFuture y EventLoopPromise: una promesa clásica con la particularidad de que sus continuaciones se ejecutan en el bucle que la creó. Desde NIO 2.x existe un puente completo hacia la concurrencia estructurada de Swift, y en 2026 es el estilo por defecto de todo código nuevo.
// De promesa a await
let respuesta = try await cliente.enviar(peticion).get()
// De await a promesa, dentro de un manejador
let promesa = context.eventLoop.makePromise(of: Respuesta.self)
promesa.completeWithTask { try await servicio.resolver(peticion) }
El puente tiene un coste que hay que entender: un await no corre en el EventLoop, corre en el pool de ejecutores de la concurrencia de Swift, así que cruzar la frontera implica un salto de hilo en cada dirección. Por eso los manejadores de camino caliente siguen escribiéndose a menudo con futuros puros, y por eso los frameworks modernos concentran el cruce en un único punto bien definido en lugar de esparcirlo por la tubería.
Y queda la regla que sobrevive a todos los estilos: para trabajo genuinamente bloqueante que no puedes evitar —una biblioteca de C sin versión asíncrona, un cálculo pesado— hay que sacarlo del motor con NIOThreadPool o con una tarea desligada, devolviendo el resultado al bucle cuando termine. El bucle nunca espera; el bucle solo reparte.
let pool = NIOThreadPool(numberOfThreads: 4)
pool.start()
let resultado = try await pool.runIfActive {
try bibliotecaDeCBloqueante.procesar(entrada) // hilo aparte, nunca el EventLoop
}
Queda una pieza más que en 2026 conviene conocer porque cambia cómo se escribe el código nuevo: NIOAsyncChannel, el puente que expone un canal como una secuencia asíncrona de mensajes entrantes y un escritor para los salientes. Con él, un protocolo entero puede escribirse como un bucle for await sin implementar un solo ChannelHandler, y la cancelación de la tarea consumidora cierra la conexión por el camino habitual de la concurrencia estructurada.
try await asyncCanal.executeThenClose { entrante, saliente in
for try await mensaje in entrante {
try await saliente.write(procesar(mensaje))
}
}
La tubería de manejadores no desaparece —sigue siendo donde viven TLS, HTTP y la compresión— pero deja de ser el lugar donde escribes lógica de aplicación, que es exactamente el reparto correcto: manejadores para las capas de protocolo, concurrencia estructurada para lo que hace tu servicio.
Lo que NIO enseña, y que vale mucho más allá de Swift, es que el paralelismo y la concurrencia son cosas distintas y que confundirlas produce sistemas caros. Un hilo del sistema operativo es un recurso de paralelismo: sirve para usar un núcleo. Una conexión es una unidad de concurrencia: una tarea en curso que la mayor parte del tiempo está esperando. El modelo de un hilo por conexión gasta un recurso escaso y costoso —la pila, el planificador, el cambio de contexto— para representar algo que es abundante y barato, y colapsa exactamente cuando el número de tareas esperando supera en órdenes de magnitud al número de núcleos, que es la condición normal de cualquier servidor de red. El bucle de eventos invierte la correspondencia: hay tantos hilos como núcleos, ni uno más, y las tareas en espera se representan como estado, no como pilas dormidas. El precio de esa inversión es que la ejecución deja de ser transparente: el programador ya no puede bloquear, porque bloquear ya no es «esperar mi turno» sino «apropiarse del turno de todos los demás», y esa asimetría es la que hace que un solo sleep mal puesto derribe la mitad de un servidor sin producir un solo mensaje de error. Toda la evolución posterior —las promesas, las corrutinas, async/await, la concurrencia estructurada— consiste en devolverle al programador la ilusión de secuencialidad sin devolverle la capacidad de bloquear, y esa es la razón profunda de que await no sea azúcar sobre hilos sino algo categóricamente distinto: marca los puntos donde tu tarea puede ceder el hilo a otra, que es la información que el motor necesita y que un bloqueo le niega. Cuando entiendas eso, la mayoría de los desastres de rendimiento en sistemas asíncronos —de Node a Python, de la JVM a Swift— se te volverán la misma historia contada con distintos nombres.
- Levanta un servidor de eco con
ServerBootstrapy comprueba conhtopcuántos hilos crea y cómo se reparten la carga. - Introduce un
Thread.sleepde un segundo en el manejador y mide la latencia de las conexiones que caen en distintos bucles. - Arregla el punto anterior moviendo el trabajo a un
NIOThreadPooly verifica que la distribución de latencias se aplana. - Añade un manejador propio entre el de HTTP y el tuyo que registre tamaños de mensaje sin copiar el
ByteBuffer. - Convierte un manejador basado en
EventLoopFuturea async/await y mide si el salto de hilo se nota en tu carga real.