E/S async con Tokio: un servidor echo concurrente
Aquí la teoría se vuelve un servidor que funciona. TcpListener acepta conexiones sin bloquear, TcpStream se lee y escribe con los traits AsyncReadExt y AsyncWriteExt, y el patrón acepta-y-lanza-tarea entrega una tarea por conexión al scheduler. El resultado es un echo concurrente que atiende a miles de clientes sobre un puñado de hilos, la culminación de todo lo anterior en unas veinte líneas.
Todo lo anterior converge aquí, en algo que se conecta y responde. La E/S de red es el hábitat natural de async, porque es pura espera: un socket pasa la mayor parte de su vida aguardando bytes que aún no llegaron. Tokio ofrece los espejos asíncronos de la biblioteca estándar —tokio::net::TcpListener y TcpStream— cuyas operaciones, en vez de bloquear el hilo, ceden la tarea cuando no pueden progresar. Leer y escribir se hace a través de dos traits de extensión, AsyncReadExt y AsyncWriteExt, que dan los .await-ables read y write_all. Y el patrón que lo ata todo —aceptar en bucle y lanzar una tarea por conexión— produce un servidor echo que atiende a miles de clientes a la vez sobre los pocos hilos del runtime. Veinte líneas que resumen el nivel entero.
- Aceptar conexiones sin bloquear con
TcpListener::bindy el bucle deaccept. - Leer y escribir sobre un
TcpStreamconreadywrite_alldeAsyncReadExt/AsyncWriteExt. - Detectar el fin de conexión (
Ok(0)como EOF) y manejar errores de E/S con?. - Ensamblar un servidor echo concurrente con el patrón acepta-y-lanza-una-tarea.
TcpListener y el bucle de accept
Un servidor empieza escuchando en una dirección. TcpListener::bind es asíncrono —reserva el socket y devuelve un future— y accept espera la siguiente conexión entrante sin bloquear: mientras no llegue ningún cliente, la tarea del bucle cede y el hilo trabajador atiende a otras.
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("escuchando en 127.0.0.1:8080");
loop {
// accept cede la tarea hasta que un cliente se conecta
let (socket, addr) = listener.accept().await?;
println!("conexion de {addr}");
// ... aqui atenderemos el socket
let _ = socket;
}
}
accept devuelve un par: el TcpStream de la conexión y la SocketAddr del cliente. Fíjate en que main devuelve std::io::Result<()>, de modo que el operador ? del nivel 23 propaga cualquier fallo de red hasta el runtime. Este bucle, tal cual, ya acepta conexiones; lo que le falta es atenderlas sin dejar de aceptar las siguientes.
Leer y escribir async
Un TcpStream no trae los métodos read y write como inherentes: llegan a través de dos traits de extensión que debes importar, AsyncReadExt y AsyncWriteExt. Importarlos “enciende” los métodos asíncronos sobre el stream, igual que un trait del nivel 13 añade capacidades a un tipo:
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
async fn saludar(mut socket: TcpStream) -> std::io::Result<()> {
let mut buf = [0u8; 1024];
// read llena el buffer y devuelve cuantos bytes leyo
let n = socket.read(&mut buf).await?;
if n == 0 {
return Ok(()); // el otro extremo cerro: EOF
}
// write_all insiste hasta escribir todos los bytes
socket.write_all(&buf[..n]).await?;
socket.flush().await?;
Ok(())
}
Dos convenios cruciales heredados de la E/S de Unix. read devuelve el número de bytes leídos, y un Ok(0) significa fin de flujo: el otro extremo cerró la conexión, no que “no había datos” —eso habría cedido la tarea, no retornado—. Y write_all es el envoltorio que repite la escritura hasta colocar todos los bytes, porque un write a secas podría escribir solo una parte. Trabaja con write_all salvo que tengas una razón para gestionar las escrituras parciales tú mismo.
Un servidor echo concurrente
Ahora el ensamblaje. La clave está en no atender la conexión en el mismo bucle que acepta —eso serializaría a los clientes, uno esperando a que el anterior termine—. En su lugar, por cada conexión aceptada lanzamos una tarea con tokio::spawn y volvemos de inmediato a accept:
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
loop {
let (mut socket, addr) = listener.accept().await?;
// una tarea por conexion: el bucle sigue aceptando enseguida
tokio::spawn(async move {
let mut buf = [0u8; 1024];
loop {
let n = match socket.read(&mut buf).await {
Ok(0) => break, // el cliente cerro
Ok(n) => n,
Err(e) => {
eprintln!("error leyendo de {addr}: {e}");
break;
}
};
if let Err(e) = socket.write_all(&buf[..n]).await {
eprintln!("error escribiendo a {addr}: {e}");
break;
}
}
});
}
}
Cada tarea corre su propio bucle “lee un trozo, devuélvelo” hasta que el cliente cierra (Ok(0)) o falla la E/S. Como las tareas se lanzan con spawn, el runtime las reparte entre sus hilos con robo de trabajo, y el bucle de accept nunca se detiene a atender a nadie. Pruébalo con nc 127.0.0.1 8080 en varias terminales a la vez: todas reciben eco simultáneo.
El async move traslada el socket a la tarea: deja de existir en el bucle. Esto es lo que satisface el 'static que exige spawn —la tarea no toma prestado nada del main, es dueña de su socket— y lo que permite que la conexión sobreviva a la iteración del bucle que la aceptó. Sin move, el future intentaría tomar prestado el socket local y el borrow checker lo rechazaría: una tarea desligada no puede sostener un préstamo de un ámbito que va a seguir corriendo.
flowchart TD B[bind y escucha en el puerto] --> A[accept cede hasta que llega un cliente] A --> S[spawn lanza una tarea para el socket] S --> A S --> L[Bucle de la tarea lee un trozo del socket] L --> C[read devuelve n bytes] C -->|n mayor que cero| E[write_all devuelve los mismos bytes] E --> L C -->|n igual a cero es EOF| F[La tarea termina y suelta el socket] style B fill:#cba6f7,color:#11111b style A fill:#89b4fa,color:#11111b style S fill:#fab387,color:#11111b style E fill:#a6e3a1,color:#11111b style F fill:#f38ba8,color:#11111b
Una tarea por conexión, no un hilo
Este patrón parece idéntico al viejo modelo “un hilo por conexión” de los servidores clásicos, y esa semejanza es justamente la victoria. Escribes el mismo código secuencial y legible —lee, procesa, responde, repite— pero cada unidad concurrente es una tarea de cientos de bytes, no un hilo de megabytes. Donde el modelo de hilos se ahogaba a los pocos miles de conexiones por agotar memoria y saturar al planificador del núcleo con cambios de contexto, el modelo de tareas sostiene decenas o cientos de miles sobre el puñado de hilos del runtime.
Y la razón por la que funciona es todo lo del nivel: cada .await sobre read, write_all o accept que no puede progresar cede el hilo en lugar de bloquearlo (nivel 34.1), el driver de E/S registra el descriptor en epoll y despierta la tarea cuando hay datos, y el scheduler reparte las tareas listas entre sus trabajadores. El servidor echo es minúsculo, pero es la plantilla exacta de la que crecen hyper y axum: aceptar, lanzar una tarea, y dejar que el runtime haga lo suyo.
TcpListener
bind reserva el socket y accept cede la tarea hasta que llega un cliente, devolviendo un TcpStream y la dirección del par.
AsyncReadExt
Aporta read sobre el stream. Devuelve los bytes leídos; Ok(0) es fin de conexión, no ausencia momentánea de datos.
AsyncWriteExt
Aporta write_all, que insiste hasta colocar todos los bytes, y flush. El envoltorio que evita gestionar escrituras parciales a mano.
spawn por conexión
El patrón que hace concurrente al servidor: aceptar y lanzar una tarea con async move, para no serializar a los clientes.
Detente en la extraña familiaridad de este servidor, porque en ella está la tesis entera de async. El cuerpo de la tarea —un bucle que lee, comprueba el fin de flujo, escribe y repite— es exactamente el que habrías escrito para un servidor bloqueante de un hilo por conexión hace treinta años. No hay callbacks anidados, ni promesas encadenadas, ni una inversión del control que te obligue a partir la lógica en fragmentos inconexos. La forma del código es secuencial porque el problema es secuencial: para un cliente dado, lees y luego respondes y luego vuelves a leer. Lo que async cambia no es la forma de la lógica, sino su coste de concurrencia. En el modelo bloqueante, sostener esa lógica secuencial para diez mil clientes a la vez costaba diez mil hilos, porque la única manera de que un read bloqueante no congelara a los demás era darle a cada uno su propio hilo con su propia pila de megabytes y su propio turno en el planificador del núcleo. Async rompe esa ecuación al reemplazar el bloqueo por la cesión: cuando el read de un cliente no tiene datos, su tarea no congela un hilo esperando, sino que se aparta y devuelve el hilo al scheduler, que lo dedica a otra de las diez mil tareas que sí tienen algo que hacer. El compilador hizo posible esta cesión transformando tu bucle secuencial en la máquina de estados del nivel 32.4, cuyos estados son precisamente los puntos .await donde la tarea puede suspenderse y reanudarse; el runtime la conduce; el reactor la despierta. Y el resultado es que la misma lógica que antes exigía un hilo por conexión ahora exige una tarea por conexión, y una tarea es tres órdenes de magnitud más barata. Aquí se cierra el arco que empezó en el nivel 29 con “un hilo por tarea no escala”: la respuesta nunca fue escribir código más retorcido, sino conservar el código secuencial que los humanos sabemos leer y cambiar, por debajo, lo que cuesta ejecutarlo mil veces a la vez. Cuando nc te devuelve el eco desde tres terminales simultáneas y compruebas que las tres corren sobre los mismos cuatro hilos, estás viendo la promesa cumplida: la ergonomía de un hilo por conexión con el coste de una tarea por conexión.
TcpListener::bind y accept aceptan conexiones cediendo la tarea, no bloqueando. Sobre un TcpStream, importa AsyncReadExt y AsyncWriteExt para tener read y write_all; read devuelve los bytes leídos y Ok(0) es EOF, write_all insiste hasta escribirlo todo. El patrón concurrente es aceptar en bucle y, por cada conexión, tokio::spawn(async move { ... }): el move traslada el socket a la tarea, satisface el 'static y deja el bucle libre para seguir aceptando. Es la misma lógica secuencial que un servidor de hilo por conexión, pero con el coste de una tarea por conexión.
- Arranca el servidor echo y conéctate con
nc 127.0.0.1 8080desde tres terminales; confirma que las tres reciben eco a la vez y explica cuántos hilos las atienden. - Elimina el
tokio::spawny atiende cada conexión en el propio bucle deaccept; observa que un cliente bloquea a los demás y explica por qué se serializaron. - Quita el
movedelasync movey lee el error del compilador; relaciónalo con el'staticque exigespawn. - Haz que el servidor devuelva el texto en mayúsculas en vez de un eco literal; identifica el único punto del bucle que cambia.
- Añade un
tokio::time::timeoutalrededor delreadpara cerrar conexiones inactivas tras cinco segundos, y explica qué tarea se despierta cuando vence el plazo.