AdTechSep 10, 202610 min de leitura

Pausas de GC do Go em um bidder RTB: mark assist, deadlines e a decisão sobre Rust

Real-Time BiddingRustColeta de lixoLatência de cauda
Erro ao carregar a imagem

Um bidder em Go que perde leilões por timeout enquanto sua média parece saudável tem dois problemas sob um sintoma: um deadline que pertence à exchange e cobre a ida e volta, e um coletor cobrando mark assist da goroutine que pontua o lance. É assim que o deadline é dividido, quais ajustes do Go compensam e o que um hot path em Rust não resolve.

Um bidder que perde leilões por timeout enquanto sua média parece saudável está sendo descrito pelo número errado. O deadline pertence à exchange e cobre a rede nos dois sentidos. Um coletor é uma propriedade do processo, então sua pausa é cobrada de todas as conexões de uma vez, e um pico em uma única conexão precisa de outra explicação.

Reescrever em Rust ou ajustar o runtime é uma escolha entre duas respostas para uma pergunta que ninguém fez: qual parte do deadline está sendo gasta, e em quê. O que vem a seguir separa o orçamento do coletor, o coletor do scheduler, e a reescrita do componente que a merece.

A resposta curta é estrutural. A exchange define o deadline e ele inclui a rede, então o primeiro conserto separa o p99 de uma conexão em tempo no handler, tempo esperando por um processador e tempo na rede. O que a amBrain pode sustentar publicamente: construímos o RTBBidder, uma demand-side platform entregue do zero, em que cada decisão de lance avalia dezenas de condições de targeting por impressão. Os números de latência que publicamos como medidos vêm de caminhos de trading, não de um bidder de anúncios, e nenhum número abaixo é medido em um bidder nosso.

O deadline pertence à exchange, e ele cobre a ida e volta

A especificação OpenRTB define tmax como o tempo máximo em milissegundos que a exchange permite para receber lances, incluindo a latência da Internet, e diz que o valor prevalece sobre qualquer orientação anterior. O orçamento é uma ida e volta que chega dentro de cada requisição.

O limiar pelo qual você é julgado não é o do seu dashboard. A documentação do Authorized Buyers do Google exige que 85 por cento das respostas cheguem dentro do deadline medido no trading location, e restringe os bidders que não cumprem. Entre o relógio dele e o seu está tudo o que não é computação:

  • A ida até a exchange e a volta, que é geografia e peering antes de ser engenharia
  • Estabelecimento de conexão quando o keepalive expira, porque um handshake TLS novo dentro do orçamento de um leilão é um leilão perdido
  • Tempo na fila de accept antes de o seu handler ver a requisição, que cresce exatamente quando você está mais ocupado
  • Desserialização, a um custo definido por quanto da requisição você transforma em objetos, não pelo tamanho dela

Então o deadline interno fica abaixo do tmax pela margem que o seu próprio histograma diz que uma resposta custa naquela conexão, rederivado por exchange em vez de definido uma única vez para a frota. Um deadline não é um plano de capacidade: o trabalho cancelado no deadline já gastou sua CPU.

Sob sobrecarga isso significa pagar o preço cheio por respostas que ninguém conta, então o conserto que falta é o controle de admissão: leia o tmax, compare-o com o atraso de fila que você mede, e responda no-bid quando a conta não fecha. Um no-bid rápido conta para os 85 por cento; um lance atrasado não conta.

Um coletor serve todas as conexões, então uma única conexão é outra falha

Um garbage collector é uma propriedade do processo, então um ciclo disparado em qualquer conexão é cobrado de todas as conexões. A que falha primeiro é a que tem o tmax mais apertado e a requisição mais pesada. Descarte o que produz o mesmo quadro sem um coletor:

  • Conexões de menos vindas de uma exchange, já que o HTTP/1.1 carrega uma requisição por vez: QPS por conexão vezes tempo de handler perto de um coloca a fila na conexão
  • Uma única conexão HTTP/2, onde um pacote perdido trava todas as streams que a compartilham - o head-of-line blocking que a RFC 9114 cita em 2022 como a razão de o HTTP/3 existir
  • Keepalive expirando, mais um padrão do que um defeito: http.Server.IdleTimeout cai para ReadTimeout quando é zero, enquanto o Google pede um idle timeout de 2.5 minutos e o nginx fecha em 75 segundos
  • Uma consulta síncrona de features que só algumas exchanges disparam, onde a cauda pertence a um store remoto, não a você

Nenhuma das quatro se conserta com um ajuste do coletor, e a separação tem instrumentos. Um profile de CPU separa as contas do coletor por símbolo, runtime.gcAssistAlloc para as cobranças no handler e runtime.gcBgMarkWorker para a marcação em background. O tempo de stop-the-world é /sched/pauses/total/gc:seconds, a espera em estado runnable é /sched/latencies:seconds, e a fila de accept é lida fora do processo, através de ListenOverflows.

Uma distinção decide de qual conserto no Go você precisa. Uma pausa stop-the-world é cobrada de todas as goroutines de uma vez, então aparece como um pico uniforme em todas as conexões. Um mark assist é cobrado da goroutine que alocou, então cai sobre as requisições que mais alocaram. Duas coisas reduzem um assist: menos bytes por bid request, ou um ciclo mais longo no qual os workers de background cobrem mais da marcação. Só a primeira sobrevive a uma mudança no mix de tráfego.

Mark assist é a conta que a sua bid request paga

O coletor do Go é concorrente, e o guia oficial é explícito ao dizer que a duração da pausa não escala com o tamanho do heap, então as transições stop-the-world são breves. Os assists são a fonte que importa: as goroutines ajudam o coletor quando a alocação é rápida, porque a marcação em background recebe um quarto fixo dos processadores e o déficit é cobrado de quem aloca.

A taxa transforma isso em um limiar, não em uma rampa. A taxa de alocação é o QPS multiplicado pelos bytes por bid request contra uma parcela fixa de background, então um código que nunca faz assist a um quinto do seu tráfego pode fazer assist em quase toda requisição a 100K QPS.

O custo de marcação é proporcional ao grafo de ponteiros vivos, não ao lixo, e um bidder guarda o formato errado para isso: índices de campanhas, segmentos de audiência, caches de frequência. O Discord publicou a mesma constatação em 2020, com um coletor varrendo um cache LRU inteiro para decidir se a memória estava livre. Cinco mecanismos se escondem sob uma única expressão:

  • Transições stop-the-world: um pico uniforme em todas as conexões no mesmo instante, raramente longo o bastante para perder um leilão sozinho
  • Mark assist: nenhuma pausa no trace, apenas tempo de handler que cresceu - leia isso pela parcela de assist na CPU do GC
  • Custo de marcação: CPU do GC que sobe quando o heap vivo cresce mesmo sem a alocação ter crescido, algo que arrays planos movem e menos lixo não move
  • Contenção no scheduler: uma requisição que fica runnable sem executar, o que a métrica de latência do scheduler mostra e a métrica de pausa não mostra
  • O ciclo forçado: um pico com período de aproximadamente dois minutos em uma instância ociosa, apontando para o piso de coleta e não para o seu tráfego

Leia isso como cinco contas separadas. Exatamente uma é quitada por um ajuste do coletor, e nenhuma por trocar de linguagem antes de a separação ser medida.

Uma malha fechada apaga a evidência de que você precisava

Gil Tene deu à falha o nome de coordinated omission: o sistema que mede se coordena com o sistema sob teste de um jeito que evita medir os outliers, porque uma malha fechada espera pela resposta e para de enviar durante uma paralisação. A ScyllaDB publicou em 2021 uma comparação em que uma carga reportou um p99 de 249 microssegundos em malha fechada e 665 ms sob carga aberta com correção, errando por cerca de 2,700 vezes.

  • Carga em malha aberta a uma taxa fixa, com a latência contada a partir do horário de envio pretendido, e não do momento em que a requisição saiu
  • Correção no estilo do HdrHistogram sempre que o gerador não enfileira as requisições que deixou de enviar
  • p99 e p99.9 em vez de uma média, com o seu próprio fan-out contabilizado: Dean e Barroso mostraram em 2013 que tocar 100 servidores com um p99 de um segundo deixa 63 por cento das requisições lentas
  • Um perfil de tráfego copiado da exchange que dói, rodado por mais tempo que o intervalo de coleta forçada e sob a mesma cota de cgroup

Um gerador de carga que espera pela resposta para de enviar exatamente durante a paralisação que ele foi construído para encontrar, e depois dilui esse silêncio na média do resultado. O percentil que ele imprime em seguida descreve o gerador, não o seu bidder.

Aloque menos primeiro, depois ajuste três parâmetros

Nomeie o número de parada antes de ajustar, porque uma reescrita decidida por esgotamento não é uma decisão. Dois números, não um: alocações de heap por requisição, que governam o assist, e o heap vivo, que governa a marcação.

A ordem é fonte antes de teto, não maior ganho primeiro. Comece onde o assist é gerado: escape analysis no caminho do lance, buffers reaproveitados em vez de alocados, e um codec que lê os campos de que você precisa em vez de materializar um novo grafo de objetos. Mantenha as slices dele de vida curta: uma slice apontando para o buffer da requisição prende esse buffer inteiro por toda a vida do lance.

  • sync.Pool alivia a pressão e não promete nada: um item pode ser removido a qualquer momento sem notificação, e um objeto no pool ainda é marcado entre a devolução dele e sua remoção
  • O conjunto vivo é a conta: reconstrua os índices de campanhas e de segmentos em arrays planos fora do hot path, para que o grafo marcado pare de crescer com a quantidade de campanhas
  • GOGC troca memória por CPU do coletor a uma taxa que o guia enuncia sem rodeios: dobrá-lo reduz aproximadamente à metade o custo de CPU do GC, e a parcela de assist cai junto
  • GOMEMLIMIT é essa troca contra um teto, soft por design, porque um limite rígido transforma um pico de heap em uma paralisação indefinida
  • GOMAXPROCS dentro de um contêiner: o Go 1.25 lê o limit de CPU do cgroup e explicitamente não os requests de CPU, então um pod com requests e sem limit mantém o comportamento antigo

A abordagem tem um teto: a Uber relatou em 2021 que ajustar o GOGC contra o limite de memória do contêiner recuperou cerca de 70,000 cores nos seus serviços mission-critical. Isso é um resultado de custo, não um percentil.

O que um hot path em Rust te dá, e o que você paga por isso

A RTB House descreveu em junho de 2025 um serviço de bidding em JVM em que a divisão em microsserviços produziu um alto volume de requisições pequenas: a latência adicionada tinha que caber em 7 ms contra uma requisição média de cerca de 2.5 ms, e os percentis 98 e 99 quebraram sob pausas frequentes do G1. Eles migraram para o ZGC geracional e pagaram em memória.

Repare no que esse conserto exigiu: um segundo coletor para o qual migrar. O Go entrega um só e ele não é plugável, então as alavancas do Go são a taxa de alocação, o formato do conjunto vivo e o GOGC contra o GOMEMLIMIT. O que um hot path em Rust remove, em vez disso, é específico: sem assist, sem marcação em background, sem ciclo forçado. O que não vai embora é mais longo do que a maioria dos times espera:

  • O alocador, mais page faults e posicionamento NUMA: a biblioteca padrão do Rust afirma que o alocador global padrão é não especificado, e malloc sob muitas threads é uma fonte de cauda
  • Escalonamento, porque um runtime assíncrono com um pool de workers reproduz os efeitos do scheduler do Go no momento em que uma tarefa bloqueante cai em um worker
  • Contabilidade de memória, já que o GOMEMLIMIT cobre apenas a memória do runtime do Go: um alocador Rust no mesmo processo fica fora do seu teto e a imposição passa para o OOM killer
  • Pessoas, ou seja, quem fica de plantão pelo hot path à noite e quanto custam duas toolchains em um mesmo repositório
  • A fronteira, se o Go ficar do lado de fora: a Cockroach Labs mediu uma chamada cgo em 171 ns contra 1.83 ns de uma chamada Go em 2015, e o que sobrevive é a razão entre as duas

Se a cauda mora no parsing da requisição, na conexão com a exchange ou no enfileiramento do scheduler, o Rust não devolve nenhum desses milissegundos, e reescrever o componente errado gasta um trimestre para manter a mesma taxa de timeout.

Então mova a menor peça que detém as alocações, não o serviço: o loop de avaliação de impressão e seus índices, a seleção de candidatos, targeting, consultas de frequência e de orçamento, scoring. Precifique a fronteira por travessia: uma vez por bid request com um buffer plano, nunca uma vez por regra de targeting. Valide em instâncias separadas alimentadas por um fluxo espelhado, nunca dentro do processo sob teste, onde um shadow path dobra as duas quantidades que você está medindo.

Um teste de aceitação que serve para os dois caminhos: rode uma janela curta com o coletor desligado, sob um teto de memória que você controla, e registre o p99.9 dentro do handler. Rode em uma instância atrás de uma fração do tráfego, com reversão automática, porque com o GOGC desligado um pico de heap contra o teto coloca o runtime em ciclos consecutivos, e o guia diz que essa paralisação pode ser indefinida. Leia o resultado apenas se o limitador de CPU do GC nunca tiver entrado em ação: assim que ele entra, o percentil descreve o limitador.

A empresa que conserta isso pede primeiro o relatório de timeouts

A segunda metade da pergunta, quem faz esse tipo de trabalho, tem um teste que não precisa de uma lista de fornecedores. Uma empresa que conserta latência causada pelo GC se comporta de forma diferente de uma que vende uma reescrita:

  • Pede a distribuição de tmax e o relatório de timeouts por exchange antes de pedir o repositório
  • Enuncia a separação - handler, scheduler, rede - e qual das três ela espera que detenha os milissegundos, antes de propor uma linguagem
  • Traz seu próprio gerador em malha aberta e seu perfil de tráfego, e recusa um percentil em malha fechada como evidência
  • Nomeia o critério de saída com antecedência: qual exchange, qual percentil, que margem contra o tmax dela, quanta memória por mil requisições
  • Consegue alocar gente no hot path depois, porque uma reescrita para a qual ninguém está de plantão à noite é o segundo incidente

Nenhum deles exige confiança: cada um é um documento que você pode pedir na primeira conversa, e um que volta vago diz que o diagnóstico está sendo pulado.

Então a primeira pergunta não é qual linguagem. É qual das três somas - handler, scheduler ou rede - detém os milissegundos que faltam na conexão que dá timeout, e se os bytes alocados por bid request se movem quando você ataca isso.

O que a amBrain pode sustentar publicamente: a amBrain é uma empresa de desenvolvimento de software especializada em plataformas de trading, matching engines, sistemas de real-time bidding e engenharia de plataformas de cassino, construímos software desde 2019, e em AdTech o que construímos é desenvolvimento de DSP, plataformas de real-time bidding e engenharia de ad exchange. Trabalhamos em três formatos: entrega completa, time dedicado ou engenheiros embarcados no seu. Se você está pesando uma reescrita contra uma rodada de tuning, a conversa que vale ter é a que roda a separação antes de escolher uma linguagem.

Tem um projeto assim na mesa?

Traga sua arquitetura atual e o modo de falha que preocupa você, e vamos analisá-lo juntos em meia hora.

Artigos relacionados

Erro ao carregar a imagem
AdTech
Sep 10, 202610 min de leitura

A medição de anúncios perde eventos no pico: costuras, chaves duplicadas e o join com o bidder

Ler post
Erro ao carregar a imagem
AdTech
Mar 5, 20267 min de leitura

Como a IA está remodelando a publicidade programática em 2026

Ler post
Erro ao carregar a imagem
AdTech
Feb 14, 20266 min de leitura

Segmentação com privacidade em primeiro lugar: construindo ad tech sem cookies de terceiros

Ler post