<!-- Canonical: https://lucioduran.com/es/blog/tcp-quic-http3-kernel-bypass-performance
Language: es
Content-SHA256: ccdc03817f8bca431b8919b37eb46f77d0422a44c868c5bb4582292e371b6a31
-->

Networking

# TCP vs QUIC vs HTTP/3: Kernel Bypass, Zero-Copy y Optimización de Latencia

2025-12-078 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance>)

## El setup de testing

Antes de examinar los números, vale la pena ser transparente con el hardware, demasiados posts de "benchmarks" se ejecutan en una instancia EC2 arbitraria con vecinos ruidosos.

- **Server**: Bare metal, AMD EPYC 9454 (48C/96T), 256GB DDR5-4800
- **NIC**: Mellanox ConnectX-7 (100GbE), dual-port
- **Cliente**: Mismo hardware, conexión directa con cable DAC (sin switch)
- **OS**: Ubuntu 24.04 LTS, kernel 6.8.0
- **DPDK**: 23.11 LTS
- **QUIC**: quiche 0.21 (la implementación de Cloudflare) y msquic 2.3
- **io\_uring**: liburing 2.6

Cada test corrió por 60 segundos, repetido 10 veces, tomando la mediana. ¿Frequency scaling? Deshabilitado. ¿Turbo boost? Deshabilitado. ¿IRQ affinity? Pinneado a cores específicos. ¿Hyperthreading? Apagado. Sin atajos.

## TCP: la vieja guardia todavía muerde

TCP plano sirve como la baseline contra la que todos comparan, y su rendimiento con kernels modernos se subestima con frecuencia.

` send() `/` recv() ` estándar sobre TCP con config default del kernel:

```c
// TCP bloqueante clásico, nada fancy
int sock = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
connect(sock, (struct sockaddr*)&addr, sizeof(addr));

// Mensajes de 64KB, 1M iteraciones
for (int i = 0; i < 1000000; i++) {
 send(sock, buf, 65536, 0);
}
```

**Resultados**: 14.2 Gbps throughput, 11.3us latencia media, 18.7us p99.

Razonable, pero queda rendimiento significativo sin aprovechar. El problema es que cada llamada a ` send() ` es un syscall, cambio de contexto al kernel, copia del buffer, cambio de contexto de vuelta.

### TCP + io\_uring: envíos zero-copy

[io\_uring](<https://github.com/axboe/liburing>) con ` IORING_OP_SEND_ZC ` elimina la copia del buffer:

```c
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, sock, buf, 65536, 0, 0);
sqe->user_data = 1;
io_uring_submit(&ring);

// Esperar completion Y notificación de que el buffer se puede reusar
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->flags & IORING_CQE_F_NOTIF) {
 // El buffer se puede reusar ahora
}
io_uring_cqe_seen(&ring, cqe);
```

**Resultados**: 31.7 Gbps throughput, 5.8us latencia media, 9.2us p99.

Eso es 2.2x mejora en throughput y ~50% reducción de latencia. La clave: no es solo evitar la copia, el batching de submissions en el ring significa menos syscalls en total. Un solo ` io_uring_submit() ` puede empujar docenas de operaciones.

### TCP + DPDK: kernel bypass total

Cuando se necesita absolutamente cada último nanosegundo:

```c
// Construcción de paquete DPDK, control total del stack
struct rte_mbuf *pkt = rte_pktmbuf_alloc(mbuf_pool);
struct rte_ether_hdr *eth = rte_pktmbuf_mtod(pkt, struct rte_ether_hdr*);
// ... construir headers ethernet, IP, TCP manualmente ...

// Acceso directo a la NIC, sin kernel de por medio
uint16_t nb_tx = rte_eth_tx_burst(port_id, queue_id, &pkt, 1);
```

**Resultados**: 82.4 Gbps throughput, 1.1us latencia media, 1.8us p99.

Este es el resultado de eliminar el kernel completamente. El costo es significativo: se debe reimplementar TCP en espacio de usuario. En la práctica, este enfoque se reserva para workloads específicos de trading de alta frecuencia, y la experiencia de debugging requiere considerable expertise.

## QUIC: no es simplemente "TCP 2.0"

QUIC recibe mucho hype como "el futuro del networking" pero la realidad es más matizada. Corre en userspace, lo que significa que no aplican los trucos de kernel bypass, y obliga TLS 1.3, así que cada paquete lleva overhead de encriptación.

El test utilizó la implementación [quiche](<https://github.com/cloudflare/quiche>) de [Cloudflare](<https://www.cloudflare.com/>), extensamente probada en producción:

```rust
// Setup de conexión QUIC con quiche
let mut config = quiche::Config::new(quiche::PROTOCOL_VERSION)?;
config.set_application_protos(b"\x02h3")?;
config.set_max_idle_timeout(30000);
config.set_initial_max_data(10_000_000);
config.set_initial_max_stream_data_bidi_local(1_000_000);
config.enable_early_data();

let conn = quiche::connect(Some("server"), &scid, local, peer, &mut config)?;

// Enviar datos en un stream
let written = conn.stream_send(stream_id, &buf[..65536], false)?;
```

**Resultados**: 8.7 Gbps throughput, 22.4us latencia media, 38.1us p99.

¿Más lento que TCP plano? Sí. Y eso es en una conexión directa con cero pérdida de paquetes. Sin embargo, QUIC no fue diseñado para ganar en este benchmark. Fue diseñado para la internet real, donde los paquetes se pierden, las conexiones migran entre redes, y el head-of-line blocking mata los streams multiplexados de HTTP/2.

### Donde QUIC realmente gana

Se configuró un perfil de tc netem simulando una conexión móvil típica: 50ms RTT, 1% pérdida de paquetes, 0.5% reordenamiento.

| Protocolo | Throughput | Latencia p99 | Setup de Conexión |
| --- | --- | --- | --- |
| TCP + TLS 1.3 | 2.1 Gbps | 187ms | 3 RTT (SYN + TLS) |
| QUIC | 3.8 Gbps | 112ms | 1 RTT (0-RTT resume) |
| HTTP/3 | 3.6 Gbps | 98ms\* | 0 RTT (resume) |

\*El p99 de HTTP/3 es menor porque los streams independientes evitan HOL blocking.

En redes con pérdida, el 0-RTT de QUIC y el multiplexing de streams independientes destruyen a TCP. El caso 0-RTT es particularmente brutal para TCP, para cuando TCP termina su three-way handshake y la negociación TLS, QUIC ya mandó la primera respuesta.

## Control de congestión: el algoritmo importa más que el protocolo

Este aspecto se suele pasar por alto. La elección del algoritmo de control de congestión tiene más impacto en el rendimiento real que TCP vs QUIC en la mayoría de los escenarios.

Se testearon cuatro algoritmos en un link de 100ms RTT con pérdida variable:

```bash
# Cambiar control de congestión en Linux
sysctl -w net.ipv4.tcp_congestion_control=bbr
# o cubic, reno, bbr3 (requiere kernel parcheado)
```

### BBRv3 vs CUBIC vs Reno, 100ms RTT

Con 0.1% de pérdida: BBRv3 empuja 42 Gbps contra 28 Gbps de CUBIC. El approach basado en modelo de BBR no retrocede tan agresivamente.

Con 1% de pérdida: BBRv3 alcanza 18 Gbps, CUBIC cae a 8.3 Gbps. BBR es claramente superior aquí.

Con 5% de pérdida: Acá viene la sorpresa, CUBIC se recupera a 3.1 Gbps mientras BBRv3 solo logra 2.4 Gbps. El bandwidth probing de BBR se confunde con tasas de pérdida altas sostenidas y oscila entre probing y draining.

La investigación sobre por qué BBRv3 underperforma con pérdida alta revela un hallazgo interesante. La respuesta está en cómo BBR estima el bottleneck bandwidth. Usa un filtro de máximo con ventana sobre delivery rates recientes, pero con 5% de pérdida, el stream de ACKs se vuelve tan ruidoso que el filtro max sigue tomando mediciones outlier, haciendo que la tasa de envío oscile salvajemente.

```python
# Estimación de bandwidth de BBR simplificada
class BBRBandwidthFilter:
 def __init__(self, window_size=10):
 self.window = deque(maxlen=window_size)

 def update(self, delivery_rate):
 self.window.append(delivery_rate)
 # Con pérdida alta, estas muestras están por todos lados
 return max(self.window) # Este max es el problema
```

## HTTP/3: el panorama completo

HTTP/3 es QUIC con semánticas HTTP encima. Las características de performance heredan de QUIC, pero hay una capa adicional que vale la pena discutir: la compresión de headers QPACK.

QPACK es la respuesta de HTTP/3 a HPACK, pero está diseñado para la entrega fuera de orden de QUIC. Esto significa que no puede usar la máquina de estados secuencial simple que usa HPACK. En cambio, tiene streams de encoder y decoder que operan independientemente:

```
// Actualización de tabla dinámica QPACK
Encoder Stream: INSERT name=":status" value="200"
 INSERT name="content-type" value="application/json"

// El bloque de headers referencia entradas de la tabla dinámica
Request Stream: INDEXED ref=0 (":status: 200")
 INDEXED ref=1 ("content-type: application/json")
```

En la práctica, QPACK logra 5-15% peor compresión que HPACK en tráfico web típico (porque no puede arriesgarse a bloquear), pero la eliminación del head-of-line blocking compensa de sobra.

## El framework de decisión práctico

Después de todos estos benchmarks, aquí va un framework de decisión práctico:

**Utilizar TCP plano + io\_uring** cuando se controlan ambos endpoints, la red es confiable y se necesita throughput bruto. Aplicaciones típicas: replicación inter-datacenter, protocolos de cable de base de datos, data planes de service mesh.

**Utilizar QUIC** cuando los clientes están en redes poco confiables (móvil, WiFi), se necesita migración de conexión, o multiplexing de streams independientes. Aplicaciones típicas: APIs client-facing, comunicación en tiempo real, edge de CDN.

**Utilizar DPDK** únicamente cuando se necesita latencia sub-2us y se cuenta con un equipo capaz de mantener un stack de networking personalizado. Aplicaciones típicas: HFT, procesamiento de paquetes telco, appliances de DPI.

**Evitar HTTP/3** salvo que exista un requerimiento claro. Si los clientes son navegadores, lo negociarán automáticamente via headers Alt-Svc. Si los clientes son servicios controlados, el overhead del framing HTTP probablemente no se justifica, utilizar QUIC directamente o permanecer con TCP.

## Un punto adicional: GSO y GRO

Generic Segmentation Offload y Generic Receive Offload son componentes fundamentales del networking moderno que suelen pasar desapercibidos. Permiten entregar a la NIC un buffer de 64KB y que el hardware lo divida en segmentos del tamaño del MTU:

```c
// Habilitar GSO en un socket UDP (para QUIC)
int gso_size = 1200; // Tamaño de paquete QUIC
setsockopt(fd, SOL_UDP, UDP_SEGMENT, &gso_size, sizeof(gso_size));

// Ahora se pueden enviar 64KB en un solo syscall
// El kernel/NIC lo divide en paquetes UDP de 1200 bytes
sendmsg(fd, &msg, 0); // msg contiene buffer de 64KB
```

Esta sola optimización puede mejorar el throughput de QUIC 3.2x. Sin GSO, cada paquete QUIC requiere una llamada ` sendmsg() ` separada. Con GSO, se agrupan 50+ paquetes en un syscall. Así es como Cloudflare logra que QUIC rinda a escala.

El panorama del networking está cambiando rápido. io\_uring suma funcionalidades de zero-copy en cada release del kernel, las implementaciones de QUIC maduran velozmente, y el kernel bypass se está volviendo más accesible. Pero los fundamentos no han cambiado: medir el workload real, en la red real, con el hardware real. Los benchmarks sintéticos son un punto de partida, no una respuesta.

## Preguntas frecuentes

### ¿QUIC es siempre más rápido que TCP?

No. En redes de baja latencia y poca pérdida, TCP con kernel bypass (io\_uring o DPDK) suele igualar o superar a QUIC porque QUIC corre en userspace y tiene overhead de encriptación. QUIC brilla en conexiones con pérdida, alta latencia y escenarios de migración de conexión.

### ¿Qué es kernel bypass y cuándo debería usarlo?

Kernel bypass permite que la aplicación envíe y reciba paquetes sin pasar por el stack de networking del SO. Tecnologías como DPDK e io\_uring reducen cambios de contexto y copias de memoria. Se recomienda cuando se necesita latencia sub-milisegundo o se procesan millones de paquetes por segundo.

### ¿Cómo funciona zero-copy networking con io\_uring?

La operación IORING\_OP\_SEND\_ZC de io\_uring pasa referencias a buffers al kernel en vez de copiar datos. El kernel hace DMA directo desde el buffer de aplicación a la NIC. Se recibe un evento de completion cuando el buffer se puede reutilizar.

## Tips

1. **Tip:** Utilizar ` io_uring ` con ` IORING_OP_SEND_ZC ` para envíos zero-copy, reduce el overhead de syscalls aproximadamente un 40% en workloads TCP de alto throughput.
2. **Tip:** El 0-RTT de QUIC reduce significativamente la latencia para clientes móviles, pero es vulnerable a replay attacks, siempre validar ` early_data ` en la capa de aplicación.
3. **Tip:** Al realizar benchmarks con DPDK, fijar los threads a cores aislados con ` isolcpus ` y deshabilitar IRQ balancing, de lo contrario los números son ruido estadístico.
4. **Tip:** BBRv3 no es una solución universal. En links con \>2% de pérdida de paquetes, CUBIC sigue siendo superior. Es necesario perfilar las condiciones de red reales antes de cambiar.

- tcp
- quic
- http3
- io\_uring
- dpdk
- zero-copy
- kernel-bypass
- networking
- performance
- congestion-control

[Volver al Blog](<https://lucioduran.com/es/blog>)
