AdTechSep 10, 202610 min de lectura

Pausas del GC de Go en un bidder RTB: mark assist, deadlines y la decisión de Rust

Pujas en tiempo realRustRecolección de basuraLatencia de cola
Error al cargar la imagen

Un bidder en Go que pierde subastas por timeout mientras su media parece sana tiene dos problemas bajo un mismo síntoma: un deadline que pertenece al exchange y cubre el viaje de ida y vuelta, y un recolector que le cobra el mark assist a la goroutine que puntúa la puja. Así se divide el deadline, qué parámetros de Go compensan y qué no arregla un hot path en Rust.

A un bidder que pierde subastas por timeout mientras su media parece sana lo describe el número equivocado. El deadline pertenece al exchange y cubre la red en ambos sentidos. Un recolector es una propiedad del proceso, así que su pausa se le cobra a todas las conexiones a la vez, y un pico en una sola conexión necesita otra explicación.

Reescribir en Rust o ajustar el runtime es elegir entre dos respuestas a una pregunta que nadie ha hecho: qué parte del deadline se está gastando, y en qué. Lo que sigue separa el presupuesto del recolector, el recolector del scheduler, y la reescritura del componente que la merece.

La respuesta corta es estructural. El exchange fija el deadline, y este incluye la red, así que la primera reparación divide el p99 de una conexión en tiempo de handler, tiempo esperando a un procesador y tiempo en la red. Lo que amBrain puede sustentar públicamente: construimos RTBBidder, una demand-side platform entregada desde cero, donde cada decisión de puja evalúa decenas de condiciones de segmentación por impresión. Las cifras de latencia que publicamos como medidas vienen de rutas de trading, no de un bidder publicitario, y ningún número de abajo está medido en un bidder nuestro.

El deadline pertenece al exchange y cubre el viaje de ida y vuelta

La especificación de OpenRTB define tmax como el tiempo máximo en milisegundos que el exchange permite para recibir pujas, incluida la latencia de Internet, y dice que ese valor prevalece sobre cualquier indicación previa. El presupuesto es un viaje de ida y vuelta que llega dentro de cada solicitud.

El umbral con el que te juzgan no es el de tu dashboard. La documentación de Authorized Buyers de Google exige que el 85 por ciento de las respuestas lleguen dentro del deadline medido en el trading location, y limita a los bidders que no lo cumplen. Entre su reloj y el tuyo está todo lo que no es cómputo:

  • El viaje de ida y vuelta hasta el exchange, que es geografía y peering antes que ingeniería
  • Establecimiento de conexión cuando el keepalive decae, porque un handshake TLS nuevo dentro del presupuesto de una subasta es una subasta perdida
  • Tiempo en la cola de accept antes de que tu handler vea la solicitud, que crece justo cuando más ocupado estás
  • Deserializar, a un coste fijado por cuánta parte de la solicitud conviertes en objetos, no por su tamaño

Así que el deadline interno queda por debajo de tmax en la cantidad que tu propio histograma dice que cuesta una respuesta en esa conexión, recalculado por exchange en vez de fijado una sola vez para toda la flota. Un deadline no es un plan de capacidad: el trabajo cancelado en el deadline ya ha gastado su CPU.

Bajo sobrecarga eso significa pagar el precio completo por respuestas que nadie cuenta, así que la reparación que falta es el control de admisión: lee tmax, compáralo con el retraso de cola que mides y responde no-bid cuando la aritmética no cuadre. Un no-bid rápido cuenta para el 85 por ciento; una puja tardía no.

Un solo recolector sirve a todas las conexiones, así que una conexión aislada es otro fallo

Un recolector de basura es una propiedad del proceso, así que un ciclo disparado en cualquier conexión se le cobra a todas. La primera que incumple es la que tiene el tmax más ajustado y la solicitud más pesada. Descarta lo que produce la misma imagen sin recolector:

  • Pocas conexiones desde un mismo exchange, ya que HTTP/1.1 lleva una solicitud a la vez: QPS por conexión multiplicado por tiempo de handler cerca de uno pone la cola en la conexión
  • Una única conexión HTTP/2, donde un paquete perdido atasca todos los streams que la comparten - el head-of-line blocking que RFC 9114 cita en 2022 como la razón de que exista HTTP/3
  • Keepalive que decae, más un valor por defecto que un fallo: http.Server.IdleTimeout recurre a ReadTimeout cuando vale cero, mientras Google pide un idle timeout de 2.5 minutos y nginx cierra a los 75 segundos
  • Una consulta síncrona de features que solo disparan algunos exchanges, donde la cola pertenece a un almacén remoto y no a ti

Ninguno de los cuatro se repara con un ajuste del recolector, y el desglose tiene instrumentos. Un perfil de CPU separa por símbolo las facturas del recolector: runtime.gcAssistAlloc para los cargos del handler y runtime.gcBgMarkWorker para el marcado en segundo plano. El tiempo stop-the-world es /sched/pauses/total/gc:seconds, la espera en runnable es /sched/latencies:seconds, y la cola de accept se lee fuera del proceso a través de ListenOverflows.

Una distinción decide qué reparación de Go necesitas. Una pausa stop-the-world se le cobra a todas las goroutines a la vez, así que aparece como un pico plano en todas las conexiones. Un mark assist se le cobra a la goroutine que asignó, así que cae sobre las solicitudes que más asignaron. Dos cosas bajan un assist: menos bytes por solicitud de puja, o un ciclo más largo en el que los workers de fondo cubren más marcado. Solo la primera sobrevive a un cambio en la mezcla de tráfico.

El mark assist es la factura que paga tu solicitud de puja

El recolector de Go es concurrente, y la guía oficial es explícita en que la duración de la pausa no escala con el tamaño del heap, así que las transiciones stop-the-world son breves. Los assists son la fuente que importa: las goroutines asisten al recolector cuando la asignación es rápida, porque el marcado en segundo plano recibe un cuarto fijo de los procesadores y el déficit se le cobra a quien asigna.

La tasa convierte esto en un umbral y no en una pendiente. La tasa de asignación es QPS multiplicado por bytes por solicitud de puja frente a una parte de fondo fija, así que un código que nunca hace assist con una quinta parte de tu tráfico puede hacer assist en casi todas las solicitudes a 100K QPS.

El coste del marcado es proporcional al grafo de punteros vivos, no a la basura, y un bidder tiene la forma equivocada para eso: índices de campañas, segmentos de audiencia, cachés de frecuencia. Discord publicó el mismo hallazgo en 2020, con un recolector escaneando una caché LRU entera para decidir si la memoria estaba libre. Cinco mecanismos se esconden bajo una sola frase:

  • Transiciones stop-the-world: un pico plano en todas las conexiones en el mismo instante, rara vez lo bastante largo como para perder una subasta por sí solo
  • Mark assist: ninguna pausa en la traza, solo tiempo de handler que creció - léelo desde la parte de assist de la CPU del GC
  • Coste del marcado: CPU del GC que sube cuando crece el heap vivo aunque la asignación no lo haya hecho, algo que los arrays planos mueven y menos basura no
  • Contención del scheduler: una solicitud que se queda en runnable sin ejecutarse, algo que la métrica de latencia del scheduler muestra y la métrica de pausas no
  • El ciclo forzado: un pico con un periodo de unos dos minutos en una instancia tranquila, que apunta al mínimo de recolección forzada y no a tu tráfico

Léelo como cinco facturas separadas. Exactamente una se salda con un ajuste del recolector, y ninguna cambiando el lenguaje antes de medir el desglose.

Un lazo cerrado borra la evidencia que necesitabas

Gil Tene bautizó el fallo como coordinated omission: el sistema de medición se coordina con el sistema bajo prueba de una forma que evita medir los valores atípicos, porque un lazo cerrado espera una respuesta y deja de enviar durante un atasco. ScyllaDB publicó en 2021 una comparación donde una carga reportó un p99 de 249 microsegundos en lazo cerrado y de 665 ms bajo carga abierta con corrección, desviado por un factor de unas 2,700 veces.

  • Carga en lazo abierto a tasa fija, con la latencia contada desde el momento de envío previsto y no desde el instante en que salió la solicitud
  • Corrección al estilo de HdrHistogram siempre que el generador no encole las solicitudes que no logró enviar
  • p99 y p99.9 en lugar de una media, con tu propio fan-out contado: Dean y Barroso mostraron en 2013 que tocar 100 servidores con un p99 de un segundo deja lentas el 63 por ciento de las solicitudes
  • Un perfil de tráfico copiado del exchange que duele, ejecutado durante más tiempo que el intervalo de recolección forzada y bajo la misma cuota de cgroup

Un generador de carga que espera una respuesta deja de enviar justo durante el atasco que se construyó para encontrar, y luego promedia el silencio dentro del resultado. El percentil que imprime después describe al generador, no a tu bidder.

Primero asigna menos, luego ajusta tres parámetros

Nombra la cifra de parada antes de ajustar, porque una reescritura decidida por agotamiento no es una decisión. Dos cifras, no una: las asignaciones de heap por solicitud, que impulsan el assist, y el heap vivo, que impulsa el marcado.

El orden es fuente antes que techo, no la mayor ganancia primero. Empieza donde se genera el assist: escape analysis en la ruta de puja, buffers reutilizados en vez de asignados, y un codec que lea los campos que necesitas en vez de materializar un grafo de objetos nuevo. Mantén sus slices de vida corta: un slice sobre el buffer de la solicitud fija ese buffer entero durante toda la vida de la puja.

  • sync.Pool alivia la presión y no promete nada: un elemento puede eliminarse en cualquier momento sin aviso, y un objeto del pool se sigue marcando entre su devolución y su expulsión
  • El conjunto vivo es la factura: reconstruye los índices de campañas y de segmentos en arrays planos fuera del hot path, para que el grafo marcado deje de crecer con el número de campañas
  • GOGC cambia memoria por CPU del recolector a una tasa que la guía enuncia claramente: duplicarlo reduce aproximadamente a la mitad el coste de CPU del GC, y la parte de assist cae con él
  • GOMEMLIMIT es ese intercambio contra un techo, blando por diseño, porque un límite duro convierte un pico de heap en un atasco indefinido
  • GOMAXPROCS dentro de un contenedor: Go 1.25 lee el límite de CPU del cgroup y explícitamente no las CPU requests, así que un pod con requests y sin límite mantiene el comportamiento anterior

El enfoque tiene un techo: Uber reportó en 2021 que ajustar GOGC contra el límite de memoria del contenedor recuperó unos 70,000 núcleos en sus servicios críticos. Eso es un resultado de coste, no un percentil.

Lo que te da un hot path en Rust, y lo que pagas por ello

RTB House describió en junio de 2025 un servicio de pujas en JVM donde una división en microservicios produjo un gran volumen de solicitudes pequeñas: la latencia añadida tenía que quedarse dentro de 7 ms frente a una solicitud media de unos 2.5 ms, y los percentiles 98 y 99 se rompían con pausas frecuentes de G1. Migraron a ZGC generacional y lo pagaron en memoria.

Fíjate en lo que exigió esa reparación: un segundo recolector al que cambiar. Go trae uno y no es intercambiable, así que las palancas de Go son la tasa de asignación, la forma del conjunto vivo y GOGC frente a GOMEMLIMIT. Lo que en cambio elimina un hot path en Rust es concreto: sin assist, sin marcado en segundo plano, sin ciclo forzado. Lo que no desaparece es más largo de lo que la mayoría de los equipos espera:

  • El allocator, más los fallos de página y la colocación NUMA: la biblioteca estándar de Rust declara que el allocator global por defecto no está especificado, y malloc con muchos hilos es una fuente de cola
  • La planificación, porque un runtime async con un pool de workers reproduce los efectos del scheduler de Go en cuanto una tarea bloqueante cae en un worker
  • Contabilidad de memoria, ya que GOMEMLIMIT cubre solo la memoria del runtime de Go: un allocator de Rust en el mismo proceso queda fuera de tu techo y la aplicación del límite pasa al OOM killer
  • Personas, es decir, quién está de guardia por el hot path de noche y cuánto cuestan dos toolchains en un mismo repositorio
  • La frontera, si Go se queda fuera: Cockroach Labs midió en 2015 una llamada cgo en 171 ns frente a 1.83 ns de una llamada Go, y lo que sobrevive es el ratio

Si la cola vive en el parseo de la solicitud, en la conexión con el exchange o en el encolado del scheduler, Rust no devuelve ninguno de esos milisegundos, y reescribir el componente equivocado gasta un trimestre para conservar la misma tasa de timeouts.

Así que mueve la pieza más pequeña que se lleva las asignaciones, no el servicio: el bucle de evaluación de impresiones y sus índices, la selección de candidatos, la segmentación, las consultas de frecuencia y presupuesto, el scoring. Pon precio a la frontera por cada cruce: una vez por solicitud de puja con un buffer plano, nunca una vez por regla de segmentación. Valida en instancias separadas alimentadas con un flujo espejo, nunca dentro del proceso bajo prueba, donde una ruta en sombra duplica las dos magnitudes que estás midiendo.

Una prueba de aceptación que sirve para cualquiera de los dos caminos: ejecuta una ventana corta con el recolector desactivado, bajo un techo de memoria que controlas, y registra el p99.9 dentro del handler. Hazlo en una sola instancia detrás de una fracción del tráfico y con reversión automática, porque con GOGC desactivado un pico de heap contra el techo mete al runtime en ciclos consecutivos, y la guía dice que ese atasco puede ser indefinido. Lee el resultado solo si el limitador de CPU del GC nunca se activó: en cuanto lo hace, el percentil describe al limitador.

La empresa que arregla esto pide primero el informe de timeouts

La segunda mitad de la pregunta, quién hace este tipo de trabajo, tiene una prueba que no necesita una lista de proveedores. Una empresa que repara latencia causada por el GC se comporta de forma distinta a una que vende una reescritura:

  • Pide la distribución de tmax y el informe de timeouts por exchange antes de pedir el repositorio
  • Enuncia el desglose - handler, scheduler, red - y cuál de los tres espera que se lleve los milisegundos, antes de proponer un lenguaje
  • Trae su propio generador en lazo abierto y su perfil de tráfico, y rechaza un percentil en lazo cerrado como evidencia
  • Nombra el criterio de salida de antemano: qué exchange, qué percentil, qué margen frente a su tmax, cuánta memoria por cada mil solicitudes
  • Puede cubrir el hot path después, porque una reescritura por la que nadie está de guardia de noche es el segundo incidente

Ninguno exige confianza: cada uno es un documento que puedes pedir en la primera conversación, y uno que vuelve vago dice que se está saltando el diagnóstico.

Así que la primera pregunta no es qué lenguaje. Es cuál de las tres sumas - handler, scheduler o red - se lleva los milisegundos que faltan en la conexión que agota el tiempo, y si los bytes asignados por solicitud de puja se mueven cuando lo atacas.

Lo que amBrain puede sustentar públicamente: amBrain es una empresa de desarrollo de software especializada en plataformas de trading, matching engines, sistemas de real-time bidding e ingeniería de plataformas de casino, llevamos construyendo software desde 2019, y en AdTech lo que construimos es desarrollo de DSP, plataformas de real-time bidding e ingeniería de ad exchange. Trabajamos en tres formatos: entrega completa, un equipo dedicado o ingenieros integrados en el tuyo. Si estás sopesando una reescritura frente a una tanda de tuning, la conversación que merece la pena hace el desglose antes de elegir un lenguaje.

¿Tiene un diseño así sobre la mesa?

Traiga su arquitectura actual y el modo de fallo que le preocupa, y lo repasaremos juntos en media hora.

Artículos relacionados

Error al cargar la imagen
AdTech
Sep 10, 202610 min de lectura

La medición publicitaria pierde eventos en el pico: costuras, claves duplicadas y el join con el bidder

Leer artículo
Error al cargar la imagen
AdTech
Mar 5, 20267 min de lectura

Cómo la IA está transformando la publicidad programática en 2026

Leer artículo
Error al cargar la imagen
AdTech
Feb 14, 20266 min de lectura

Segmentación privacy-first: construir ad tech sin cookies de terceros

Leer artículo