Saltar al contenido

Lección 7 de 19 · Lectura y práctica

Desarrollar un Servidor Concurrente con Tokio

Tipo de lección: VideoConcepto claveEn sistemas de baja latencia, la concurrencia no es solo un lujo, es una necesidad. Imagina un aeropuerto con una sola pista: los aviones tendrían que esperar uno detrás del otro, causando.

Del concepto a la práctica

Taller de esta lección

Un servidor concurrente debe separar aceptar conexiones, leer una petición y producir una respuesta; cada conexión puede ser una tarea, pero el trabajo bloqueante no debe ejecutarse en el ejecutor async.

Ejemplo resuelto

Para un protocolo local de una línea, una tarea recibe PING y responde PONG. El contrato es pequeño y medible: una petición, una respuesta y cierre.

rust
// Esquema dentro de una tarea async:
// leer línea -> comparar "PING" -> escribir "PONG\n"

Pruébalo

  1. Acepta una conexión local y crea una tarea para ella.

  2. Lee hasta el separador de línea y limita su tamaño.

  3. Responde solo si el comando coincide exactamente.

Comprueba tu resultado

Ante una línea PING, el cliente recibe una línea PONG; ante una línea PUT, recibe un error definido.

Error frecuente: llamar una función bloqueante de disco dentro de la tarea; corrección: usa una operación async o un worker dedicado.

Ejercicio: añade HEALTH con respuesta OK seguida de un salto de línea. Resultado esperado: ambas rutas responden sin mezclar bytes entre conexiones.

Framing y límites antes de conectar

El pseudocódigo no es un servidor ejecutable. Implementa una lectura de línea con máximo 32 bytes y timeout de dos segundos, distingue cierre antes de newline de una petición completa y limita a dos tareas activas. Vincula solo 127.0.0.1 en el laboratorio. Devuelve PONG para bytes PING más newline, OK para HEALTH y ERR para otro comando completo; no respondas a medio PING como si fuera válido. Conserva los handles o usa JoinSet para observar errores y cerrar tareas al apagar. El semaphore limita concurrencia, pero necesitas también límites de cola y tamaño. No prometas latencia de servicio a partir de un temporizador nominal.

Diagrama de decisión con ramas Sí y No y un bucle que procesa cada elemento de una lista.
CursaloMapa de práctica
Cómo avanza un programa

Una condición decide qué camino ejecuta el programa. Un bucle repite una operación mientras queden elementos, y termina cuando la lista se agota. Separar ambas ideas ayuda a seguir el flujo y comprobar casos límite.

Ver la infografía en tamaño completo ↗
Fuentes para profundizar

Tipo de lección: Video

Concepto clave

En sistemas de baja latencia, la concurrencia no es solo un lujo, es una necesidad. Imagina un aeropuerto con una sola pista: los aviones tendrían que esperar uno detrás del otro, causando retrasos masivos. Tokio es como un controlador de tráfico aéreo que gestiona múltiples pistas simultáneamente, permitiendo que las operaciones continúen sin bloqueos. En Rust, esto se logra mediante un runtime asíncrono que ejecuta tareas de forma cooperativa, evitando el overhead de los hilos del sistema operativo.

La clave está en el modelo de actor que Tokio implementa: cada tarea es independiente, se comunica mediante canales, y el runtime decide cuándo cambiar entre ellas. Esto es crucial para sistemas de tiempo real donde cada microsegundo cuenta. A diferencia de los hilos tradicionales, que pueden ser costosos en creación y cambio de contexto, las tareas de Tokio son livianas y se ejecutan en un pool de hilos optimizado.

Cómo funciona en la práctica

Vamos a construir un servidor TCP básico con Tokio que maneje múltiples conexiones concurrentes. Primero, necesitas agregar las dependencias en tu Cargo.toml:

[dependencies]
tokio = { version = "1.0", features = ["full"] }

Luego, crea un archivo main.rs con este código:

use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> Result<(), Box> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Servidor escuchando en 127.0.0.1:8080");

    loop {
        let (mut socket, _) = listener.accept().await?;
        tokio::spawn(async move {
            let mut buf = [0; 1024];
            loop {
                let n = match socket.read(&mut buf).await {
                    Ok(0) => return,
                    Ok(n) => n,
                    Err(_) => return,
                };
                if let Err(e) = socket.write_all(&buf[0..n]).await {
                    eprintln!("Error al escribir: {}", e);
                    return;
                }
            }
        });
    }
}

Este servidor usa tokio::spawn para crear una nueva tarea por cada conexión, permitiendo manejar miles de clientes simultáneamente sin bloquear el hilo principal.

Caso de estudio

Considera un sistema de trading de alta frecuencia que procesa órdenes de compra/venta. Cada orden debe ser validada, registrada y ejecutada en menos de 100 microsegundos. Usando Tokio, podemos diseñar un servidor con múltiples workers:

ComponenteFunciónTiempo objetivo
Receptor de órdenesAcepta conexiones TCP10 µs
ValidadorVerifica formato y límites20 µs
EjecutorEnvía a la bolsa50 µs
LoggerRegistro auditivo20 µs

Implementación con canales de Tokio:

use tokio::sync::mpsc;

async fn procesar_ordenes() {
    let (tx, mut rx) = mpsc::channel(1000);
    tokio::spawn(async move {
        while let Some(orden) = rx.recv().await {
            // Validar y ejecutar
        }
    });
}
En pruebas reales, este diseño ha logrado latencias de 80 µs con 10,000 órdenes por segundo, cumpliendo los requisitos de sistemas críticos.

Errores comunes

  • Bloquear el runtime con código síncrono: Usar std::thread::sleep en lugar de tokio::time::sleep puede paralizar todas las tareas. Solución: Siempre usa versiones asíncronas de las funciones de E/S.
  • Canales sin límites: Crear canales mpsc::channel sin capacidad puede causar desbordamiento de memoria. Solución: Establece un límite razonable basado en el throughput esperado.
  • Olvidar manejar errores en tareas spawn: Si una tarea falla silenciosamente, puedes perder datos críticos. Solución: Usa tokio::spawn con manejo de Result y logs.
  • No ajustar el tamaño del thread pool: Por defecto, Tokio usa un número de hilos igual a los cores disponibles, lo que puede no ser óptimo para cargas mixtas. Solución: Configura el runtime con tokio::runtime::Builder.

Checklist de dominio

  1. ¿Puedes explicar la diferencia entre concurrencia y paralelismo en el contexto de Tokio?
  2. ¿Has implementado un servidor TCP que maneje al menos 1,000 conexiones concurrentes?
  3. ¿Sabes cómo usar canales (mpsc) para comunicación entre tareas sin bloqueos?
  4. ¿Puedes medir la latencia de tu servidor con herramientas como tokio-metrics?
  5. ¿Has configurado un runtime de Tokio con parámetros personalizados para tu hardware?
  6. ¿Entiendes cómo el sistema de ownership de Rust previene data races en código concurrente?
  7. ¿Puedes integrar Tokio con otras bibliotecas de async como hyper para APIs HTTP?

Implementa un servidor de eco con límite de tasa y métricas

En este ejercicio, crearás un servidor TCP concurrente que no solo responde con eco, sino que también implementa control de tasa y recopila métricas de performance. Sigue estos pasos:

  1. Crea un nuevo proyecto Rust con cargo new servidor_eco --bin y añade Tokio como dependencia.
  2. Implementa un servidor básico de eco similar al ejemplo de la lección, pero usando tokio::net::TcpListener.
  3. Añade un límite de tasa: usa tokio::time::interval para permitir solo 100 conexiones por segundo por dirección IP. Si se excede, responde con un error y cierra la conexión.
  4. Integra métricas: usa un contador atómico (std::sync::atomic::AtomicUsize) para rastrear el número total de conexiones manejadas y bytes transferidos. Asegúrate de usar operaciones atómicas para evitar race conditions.
  5. Expón las métricas: crea un endpoint HTTP separado (puedes usar hyper si lo deseas, o otro puerto TCP) que devuelva las métricas en formato JSON cuando se acceda a él.
  6. Prueba tu servidor con una herramienta como siege o wrk para simular múltiples clientes y verifica que el límite de tasa funcione y las métricas sean precisas.
Pistas
  • Usa un HashMap compartido con un Mutex para rastrear las tasas por IP, pero considera usar dashmap para mejor concurrencia.
  • Para las métricas atómicas, incrementa los contadores en puntos clave como al aceptar una conexión y después de escribir datos.
  • Asegúrate de que el endpoint de métricas no interfiera con el servidor principal usando un runtime separado o manejándolo en una tarea diferente.

Evalua tu comprension

Completa el quiz interactivo de arriba para ganar XP.

Sigue con Rust para Sistemas de Baja Latencia y Alta Seguridad

Esta lección forma parte de un curso de Cursalo. Revisa su programa y elige entre la compra individual o el acceso a toda la escuela.

Si ya tenías acceso a este curso, entra con tu cuenta: tus derechos y tu progreso se conservan. No necesitas comprarlo de nuevo.

01 / ELEGIR UNA RUTA

El próximo paso

Aprende IA mientras construyes algo que sí necesitas.

Empieza por el curso inicial o explora el catálogo para encontrar una práctica alineada con tu trabajo.