🧪 Problem-Driven Systems Lab

🦀 Rust#

Versión fijada: 1.83 · Imagen base: rust:1.83-alpine · Hub: :8700 · Casos operativos: 20 / 20

⬅️ Volver a los perfiles de lenguaje · 🗺️ Mapa de stacks · 🔄 Protocolo de actualización


🪪 Identidad#

Rust es un lenguaje compilado y de tipado estático sin recolector de basura, que garantiza seguridad de memoria y de concurrencia en tiempo de compilación mediante un sistema de propiedad (ownership) verificado por el borrow checker. La consigna del proyecto es que un programa que compila no tiene fugas de memoria ni condiciones de carrera en código seguro.

Para qué se usa en la industria: sistemas donde el costo de un fallo de memoria es alto o donde no hay presupuesto para un GC — motores de navegador, kernels, sistemas embebidos, bases de datos, herramientas de línea de comandos de alto rendimiento y, cada vez más, servicios de red. Firefox, el kernel de Linux, Dropbox, Discord y buena parte de la infraestructura de Cloudflare tienen componentes en Rust.

Por qué está en este laboratorio: porque desplaza errores de runtime a errores de compilación, y eso se puede demostrar. En el caso 12 omitir el brazo None de un match no compila. En el caso 03 el compilador impide que el contexto del request sobreviva al request. Son las dos únicas garantías estructurales del set completo de siete stacks.


⚙️ Modelo de ejecución#

Threads del sistema operativo, sin recolector de basura, con liberación determinista por Drop.

El laboratorio usa Rust sin runtime asincrónico: std::thread y un thread por conexión. Es una decisión deliberada, y tiene consecuencias visibles:

ConsecuenciaDónde se nota
La liberación es determinista y contableUn impl Drop propio descuenta bytes vivos y cuenta liberaciones. dropped_total es contabilidad real del destructor, no una estimación del GC — caso 05
No hay cierre que escribirLa Connection se cierra al salir de scope. Ni try-with-resources, ni using, ni defer, ni finallycaso 01
Thread por conexión 1:1 no escala como goroutinesEs el costo de no usar tokio. El caso 11 lo documenta explícitamente en vez de esconderlo — caso 11
std no trae HTTP, JSON ni asyncLa capa HTTP del laboratorio está escrita a mano sobre TcpListener. Es la contrapartida honesta de una stdlib mínima — transversal

🧰 Primitivas que usa el laboratorio#

CasoPrimitiva centralPor qué esta y no otra
01 · API lentarusqlite (feature bundled) + Ownership/DropSQLite compilado dentro del binario: no depende del libsqlite3 del sistema
02 · N+1query_map(...).collect::<Result<Vec<_>>>()Materializa las filas propagando el error de cualquiera de ellas. Ignorar un fallo parcial es imposible
03 · Observabilidadstruct RequestCtx prestado por & + lifetimesUna referencia al contexto no puede almacenarse en una estructura de vida más larga. La fuga de contexto no compila
04 · Timeoutsmpsc::channel + recv_timeoutDeadline del lado del llamador. Limitación documentada: corta la espera, no el trabajo
05 · Memoriaimpl Drop for Tracked + LazyLock<Mutex<Lru>>Destructor propio que hace observable la liberación. HashMap::new() no es const, de ahí LazyLock
06 · Pipelineenum DeployOutcome + match exhaustivoAgregar Canary mañana rompe la compilación hasta contemplarlo en todos lados
07 · MonolitoBox<dyn Fn(&Request) -> Response + Send + Sync>El compilador verifica la thread-safety en el punto de registro, no en el primer request concurrente
08 · Extracciónmpsc::channel (multi-producer, single-consumer)El Receiver no implementa Clone: no puede haber dos consumidores. La restricción está en el tipo
09 · Integración externaMutex<i64> con decremento condicionalMenos expresivo que el canal de Go, pero el guard libera en todos los caminos
10 · Sobre-arquitecturaString::with_capacity + LazyLock<HashMap>Sin realocaciones intermedias; el mapa de solo lectura no necesita lock
11 · ReportesMutex<usize> + CondvarEl que no consigue slot duerme hasta ser despertado: espera pasiva, sin busy-wait
12 · Punto únicoOption<T> + operador ? + match exhaustivoOmitir el brazo None no compila. El ? propaga la ausencia sin escribir un solo if
13 · Cache stampedeArc<Flight> con Mutex + CondvarLa std no trae Future ejecutable; el Arc obliga a que el vuelo sobreviva al mapa
14 · Pool de conexionesimpl Drop sobre LeaseNo hay línea que olvidar; fugar exige llamar a mem::forget por su nombre
15 · Backpressurempsc::sync_channel(N)El límite está en el tipo, y TrySendError::Full(T) devuelve el mensaje rechazado
16 · IdempotenciaHashMap::entry + match exhaustivoEl único donde ignorar el resultado de la reserva no compila
17 · Migración sin downtimeRwLock con spin acotadoEl único caso del lab donde la respuesta de Rust es peor; los guards salvan el unlock
18 · Arranque en fríoAOT sin runtime + OnceLock<T>La curva más plana (1,00x), y el estado «no lista» inalcanzable por tipos
19 · Deriva del índice#[must_use] + HashSetEl único con las dos piezas: el bug no compila, y el diff no se escribe a mano
20 · DLQ olvidadaenum + match exhaustivoUna clase de error nueva no compila; y panic! no es un Result

💡 El patrón que solo se ve mirando la columna entera: en los casos 03, 06, 07, 08 y 12 la corrección no la impone la disciplina del programador sino el compilador. Son cinco categorías de bug que en los otros seis stacks se evitan acordándose.


📈 Rendimiento: qué mide el laboratorio y cómo reproducirlo#

⚠️ Este repositorio no publica benchmarks entre lenguajes. Lo que se mide es la pendiente dentro de cada stack: legacy contra optimized, mismo runtime, misma máquina.

Rust es el único stack donde la medición de memoria es contabilidad exacta en vez de estimación: no hay GC que difiera la liberación, así que live_bytes refleja el estado real en el instante de la consulta.

SeñalDe dónde saleQué caso la expone
live_bytes · live_mbcontador propio en impl Drop05
dropped_totalincremento dentro del destructor05
avg_ms · p95_ms · p99_msAtomicI64 + Mutex<Vec<f64>>01, 02, 10
procesadores lógicosthread::available_parallelism()11
IN_FLIGHTAtomicI6411

Reproducir la medición del caso 05 (liberación determinista):

bash
docker compose -f compose.rust.yml up -d --build
curl -s localhost:8700/05/state
for i in $(seq 1 40); do curl -s "localhost:8700/05/batch-legacy?size_kb=64" > /dev/null; done
curl -s localhost:8700/05/state          # live_bytes y retained_count crecen juntos
curl -s localhost:8700/05/reset-lab      # el Drop libera en el acto, no "cuando pase el GC"
curl -s localhost:8700/05/state          # dropped_total refleja exactamente lo liberado

Especificación de rendimiento que este stack verifica y ningún otro puede: entre el reset-lab y la consulta siguiente no hay ventana de incertidumbre. En Java, .NET, Go, Node y Python la memoria se libera en algún momento después; acá se libera al salir de scope. Esa diferencia es el argumento completo del caso 05.


🚧 Límites, problemas sin solución y desafíos#

LímitePor qué importaDónde se ve
recv_timeout corta la espera, no el trabajoExactamente la misma limitación de CompletableFuture.orTimeout en Java. El thread proveedor sigue dormido. tokio lo resuelve; std nocaso 04
std no trae HTTP, JSON ni runtime asincrónicoLa capa HTTP del lab está escrita a mano. En producción se usaría axum + tokio + serde, tres dependencias que otros stacks tienen en la cajatransversal
std::thread es 1:1 con el SOSin tokio, mil conexiones son mil threads. Go multiplexa; Rust sin runtime async, nocaso 11
Curva de aprendizaje del borrow checkerEs el costo real de las garantías. Un equipo que no lo conoce paga semanas antes de ser productivotransversal
Tiempos de compilaciónEl ciclo editar-compilar-probar es el más lento del laboratorio, con diferenciatransversal
.unwrap() sigue estando ahíEl lenguaje impide ignorar la ausencia, pero no impide convertirla en panic de una líneacaso 12

Desafío abierto del stack en este laboratorio: el caso 04 es el punto flojo, y no por una mala decisión de implementación sino por una limitación real de std. Documentarlo como quinto puesto —en vez de esconderlo detrás de tokio— es lo que hace comparable el caso. Si una versión futura de std incorporara cancelación cooperativa, esa limitación dejaría de ser cierta y el caso 04 tendría que reescribirse. Está anotado como disparador explícito en el protocolo de actualización.


🏆 Dónde gana y dónde pierde en el laboratorio#

Agregado de los veredictos de las 19 comparativas que rankean: 10 primeros puestos, media 2.1 — mas oros y mejor promedio que ningun otro stack.

  • 🥇 Gana en 02, 03, 05, 06, 07, 12, 14 y 16 — en el 14 por lo que el lenguaje impide: impl Drop hace que fugar una conexión no se pueda escribir por descuido — todos los casos donde el sistema de tipos o el Drop determinista convierten un error de runtime en un error de compilación.
  • 🥈 Segundo en 08, 09 y 15 — pierde el primer puesto contra Go por expresividad, no por corrección.
  • 🥉 Tercero en 01 y 11 — el thread-per-connection 1:1 le cuesta el podio.
  • 4º en 13 — hay que construir el single-flight entero con Condvar: el compilador protege del use-after-remove, pero no regala la primitiva.
  • 5º en 04 — la limitación de recv_timeout, documentada arriba.
  • 🥇 Gana en 20 — el enum con match exhaustivo es la primitiva exacta de un caso que trata de clasificar: una clase de error nueva no compila hasta que alguien decida qué hacer con ella. Y panic! como canal separado de Result impide estructuralmente que un bug del consumidor termine en la DLQ disfrazado de dato malo.
  • 🥇 Gana en 20 — el enum con match exhaustivo es la primitiva exacta de un caso que trata de clasificar: una clase de error nueva no compila hasta que alguien decida qué hacer con ella. Y panic! como canal separado de Result impide estructuralmente que un bug del consumidor termine en la DLQ disfrazado de dato malo.
  • 🥇 Gana en 19 — el único stack con las dos piezas del caso: #[must_use] sobre Result hace que ignorar la escritura fallida no compile sin escribirlo a propósito, y HashSet da el diff de tres caras sin recorrer a mano. Y la defensa está en la biblioteca estándar, no en una herramienta externa.
  • 🥈 Segundo en 18 — la curva más plana medida (1,00x) y la única garantía de tipo del lab contra servir desde una instancia no inicializada: con OnceLock, el estado «no lista» es inalcanzable. El reverso exacto del caso 17.
  • 6º en 17 — el único caso donde su respuesta es peor que la de los otros seis: la std no ofrece RwLock con deadline, así que la única opción es un spin que consume CPU.

Lectura honesta: Rust tiene el mayor rango del laboratorio. Es primero siete veces y quinto una vez. Go promedia mejor porque nunca baja del tercer puesto; Rust brilla donde el compilador aporta y queda al descubierto donde std no llega.


🔄 Ciclo de versiones#

Versión fijada hoy1.83 (rust:1.83-alpine)
Cadencia upstreamUna release menor cada 6 semanas
Política de soporteSolo la última versión estable; el compromiso es de estabilidad hacia atrás por edición, no soporte de versiones viejas
Producto en endoflife.daterust

Qué revisar en el próximo salto:

  1. 🚨 Cancelación en std — si std incorpora algo equivalente a un timeout cancelable o a un semáforo, las limitaciones documentadas en los casos 04 y 09 dejan de ser ciertas. Es el disparador de revisión más importante de este stack: no es un bump, es una reescritura de la narrativa del caso.
  2. LazyLock — estabilizado en 1.80 y usado en los casos 05, 07, 08 y 10. Cualquier cambio de API los toca a todos.
  3. Nueva edición (2024 en adelante) — cambia reglas del lenguaje, no solo la versión del compilador. Requiere revisar los siete casos con código unsafe-adyacente o con inferencia sensible.
  4. async en std — si llegara a estabilizarse un runtime mínimo, la decisión de "sin runtime asincrónico" pasaría de honesta a anticuada.

El detalle del procedimiento está en docs/language-upgrade-protocol.md.


🚀 Levantar el stack#

bash
docker compose -f compose.rust.yml up -d --build

Los 20 casos quedan servidos en http://localhost:8700/NN/. La primera compilación es notablemente más lenta que la de los otros seis stacks — es esperable, no es un fallo del build.

Ver esta carpeta en GitHub ↗