Notícias

Por que o Discord está mudando de Go para Rust

0

Rust está se tornando uma linguagem de primeira classe em uma variedade de domínios. No Discord, vimos sucesso com Rust no lado do cliente e no lado do servidor. Por exemplo, nós o usamos no lado do cliente para nosso pipeline de codificação de vídeo para Go Live e no lado do servidor para Elixir NIFs. Mais recentemente, melhoramos drasticamente o desempenho de um serviço, mudando sua implementação de Go para Rust. Esta postagem explica por que faz sentido reimplementar o serviço, como isso foi feito e as melhorias de desempenho resultantes.

O serviço Read States

Discord é uma empresa focada no produto, então vamos começar com algum contexto de produto. O serviço que mudamos de Go to Rust é o serviço “Read States”. Seu único propósito é controlar quais canais e mensagens você leu. Read States é acessado toda vez que você se conecta ao Discord, toda vez que uma mensagem é enviada e toda vez que uma mensagem é lida. Resumindo, Read States está no caminho certo. Queremos ter certeza de que o Discord parece super ágil o tempo todo, então precisamos ter certeza de que Read States seja rápido.

Com a implementação do Go, o serviço Read States não estava dando suporte aos requisitos do produto. Era rápido na maioria das vezes, mas a cada poucos minutos víamos grandes picos de latência que prejudicavam a experiência do usuário. Depois de investigar, determinamos que os picos ocorreram devido aos principais recursos do Go: seu modelo de memória e coletor de lixo (GC).

Por que Go não atingiu nossas metas de desempenho

Para explicar por que Go não estava cumprindo nossas metas de desempenho, primeiro precisamos discutir as estruturas de dados, escala, padrões de acesso e arquitetura do serviço.

A estrutura de dados que usamos para armazenar informações de estado de leitura é convenientemente chamada de “Estado de leitura”. Discord tem bilhões de estados de leitura. Existe um estado de leitura por usuário por canal.

Cada estado de leitura tem vários contadores que precisam ser atualizados atomicamente e geralmente redefinidos para 0. Por exemplo, um dos contadores é quantas @menções você tem em um canal. Para obter atualizações rápidas do contador atômico, cada servidor de estados de leitura tem um cache de estados de leitura menos usados ​​recentemente (LRU). Existem milhões de usuários em cada cache. Existem dezenas de milhões de estados de leitura em cada cache. Existem centenas de milhares de atualizações de cache por segundo.

Para persistência, apoiamos o cache com um cluster de banco de dados Cassandra. No despejo da chave de cache, comprometemos seus Estados de leitura para o banco de dados. Também agendamos um commit de banco de dados para 30 segundos no futuro, sempre que um estado de leitura é atualizado. Existem dezenas de milhares de gravações de banco de dados por segundo.

Na imagem abaixo, você pode ver o tempo de resposta e a CPU do sistema para um período de tempo de amostra de pico para o serviço Go.¹ Como você pode notar, há latência e picos de CPU aproximadamente a cada 2 minutos.

Então, por que picos de 2 minutos?

Em Go, no despejo da chave de cache, a memória não é liberada imediatamente. Em vez disso, o coletor de lixo é executado de vez em quando para localizar qualquer memória que não tenha referências e, em seguida, a libera. Em outras palavras, em vez de ser liberada imediatamente depois que a memória está fora de uso, a memória pendura um pouco até que o coletor de lixo possa determinar se ela está realmente fora de uso. Durante a coleta de lixo, Go tem que trabalhar muito para determinar qual memória está livre, o que pode tornar o programa lento.

Esses picos de latência definitivamente cheiravam a impacto no desempenho da coleta de lixo, mas tínhamos escrito o código Go de maneira muito eficiente e tínhamos muito poucas alocações. Não estávamos criando muito lixo.

Depois de examinar o código-fonte do Go, aprendemos que o Go forçará uma execução de coleta de lixo a cada 2 minutos, no mínimo. Em outras palavras, se a coleta de lixo não for executada por 2 minutos, independentemente do crescimento do heap, go ainda forçará uma coleta de lixo.

Descobrimos que poderíamos ajustar o coletor de lixo para ocorrer com mais frequência a fim de evitar grandes picos, então implementamos um endpoint no serviço para alterar o percentual de GC do coletor de lixo em tempo real. Infelizmente, não importa como configuramos o percentual de GC, nada mudou. Como poderia ser? Acontece que era porque não estávamos alocando memória com rapidez suficiente para forçar a coleta de lixo com mais frequência.

Continuamos pesquisando e descobrimos que os picos eram enormes, não por causa de uma grande quantidade de memória pronta para ser liberada, mas porque o coletor de lixo precisava varrer todo o cache LRU para determinar se a memória estava realmente livre de referências. Portanto, concluímos que um cache LRU menor seria mais rápido porque o coletor de lixo teria menos para verificar. Portanto, adicionamos outra configuração ao serviço para alterar o tamanho do cache LRU e alteramos a arquitetura para ter muitos caches LRU particionados por servidor.

Nós tínhamos razão. Com o cache LRU menor, a coleta de lixo resultou em picos menores.

Infelizmente, a desvantagem de tornar o cache LRU menor resultou em tempos de latência de 99º maior. Isso ocorre porque, se o cache for menor, é menos provável que o estado de leitura de um usuário esteja no cache. Se não estiver no cache, temos que carregar o banco de dados.

Após uma quantidade significativa de testes de carga com diferentes capacidades de cache, encontramos uma configuração que parecia correta. Não totalmente satisfeitos, mas bastante satisfeitos e com peixes maiores para fritar, deixamos o serviço assim por algum tempo.

Durante esse tempo, vimos cada vez mais sucesso com o Rust em outras partes do Discord e decidimos coletivamente que queríamos criar as estruturas e bibliotecas necessárias para construir novos serviços totalmente no Rust. Esse serviço era um ótimo candidato a portar para o Rust, pois era pequeno e independente, mas também esperávamos que o Rust corrigisse esses picos de latência. Portanto, assumimos a tarefa de portar Read States para Rust, na esperança de provar o Rust como uma linguagem de serviço e melhorar a experiência do usuário.

Memory management in Rust

Rust é incrivelmente rápido e eficiente em termos de memória: sem tempo de execução ou coletor de lixo, ele pode alimentar serviços de desempenho crítico, executar em dispositivos incorporados e se integrar facilmente com outras linguagens.

Rust não tem coleta de lixo, então imaginamos que não teria os mesmos picos de latência que Go tinha.

Rust usa uma abordagem de gerenciamento de memória relativamente única que incorpora a ideia de “propriedade” da memória. Basicamente, o Rust rastreia quem pode ler e escrever na memória. Ele sabe quando o programa está usando memória e imediatamente libera a memória quando ela não é mais necessária. Ele impõe regras de memória em tempo de compilação, tornando virtualmente impossível ter bugs de memória em tempo de execução. Você não precisa controlar manualmente a memória. O compilador cuida disso.

So in the Rust version of the Read States service, when a user’s Read State is evicted from the LRU cache it is immediately freed from memory. The read state memory does not sit around waiting for the garbage collector to collect it. Rust knows it’s no longer in use and frees it immediately. There is no runtime process to determine if it should be freed.

Async Rust

Mas havia um problema com o ecossistema Rust. Na época em que esse serviço foi reimplementado, o Rust estável não tinha uma história muito boa para o Rust assíncrono. Para um serviço em rede, a programação assíncrona é um requisito. Algumas bibliotecas da comunidade permitiam o Rust assíncrono, mas exigiam uma quantidade significativa de cerimônia e as mensagens de erro eram extremamente obtusas.

Felizmente, a equipe do Rust estava trabalhando duro para tornar a programação assíncrona mais fácil, e ela estava disponível no canal noturno instável do Rust.

Discord nunca teve medo de abraçar novas tecnologias que parecem promissoras. Por exemplo, fomos os primeiros a adotar Elixir, React, React Native e Scylla. Se uma peça de tecnologia é promissora e nos dá uma vantagem, não nos importamos em lidar com as dificuldades inerentes e a instabilidade da tecnologia de ponta. Esta é uma das maneiras pelas quais alcançamos rapidamente mais de 250 milhões de usuários com menos de 50 engenheiros.

Adotar os novos recursos assíncronos do Rust nightly é outro exemplo de nossa disposição em adotar uma tecnologia nova e promissora. Como uma equipe de engenharia, decidimos que valia a pena usar o Rust noturno e nos comprometemos a executar todas as noites até que o async fosse totalmente compatível com o stable. Juntos, lidamos com qualquer problema que surgisse e, neste ponto, o Rust estável oferece suporte ao Rust assíncrono.⁵ A aposta valeu a pena.

Implementation, load testing, and launch

A reescrita real foi bastante simples. Começou como uma tradução aproximada, então nós a reduzimos onde fazia sentido. Por exemplo, o Rust tem um ótimo sistema de tipos com amplo suporte para genéricos, então podemos descartar o código Go que existia simplesmente por falta de genéricos. Além disso, o modelo de memória de Rust é capaz de raciocinar sobre a segurança da memória em todos os threads, então fomos capazes de descartar parte da proteção de memória manual cruzada da rotina que era necessária no Go.

Quando começamos o teste de carga, ficamos imediatamente satisfeitos com os resultados. A latência da versão Rust era tão boa quanto a de Go e não teve picos de latência!

Notavelmente, nós só colocamos um pensamento muito básico na otimização quando a versão Rust foi escrita. Mesmo com apenas a otimização básica, o Rust foi capaz de superar a versão super ajustada à mão do Go. Este é um grande testemunho de como é fácil escrever programas eficientes com Rust em comparação com o mergulho profundo que tivemos que fazer com Go.

Mas não ficamos satisfeitos em simplesmente igualar o desempenho de Go. Depois de um pouco de criação de perfil e otimizações de desempenho, fomos capazes de superar Go em cada métrica de desempenho. Latência, CPU e memória foram todos melhores na versão Rust.

As otimizações de desempenho do Rust incluíram:

  1. Mudar para um BTreeMap em vez de um HashMap no cache LRU para otimizar o uso da memória.
  2. Trocando a biblioteca de métricas inicial por uma que usava simultaneidade Rust moderna.
  3. Reduzindo o número de cópias de memória que estávamos fazendo.

Satisfeitos, decidimos lançar o serviço.

O lançamento foi bastante tranquilo porque testamos o carregamento. Colocamos em um único nó canário, encontramos alguns casos extremos que estavam faltando e os corrigimos. Logo depois disso, o implementamos para toda a frota.

Abaixo estão os resultados.

Go é roxo, Rust é azul.

Aumentando a capacidade do cache

Depois que o serviço foi executado com sucesso por alguns dias, decidimos que era hora de aumentar novamente a capacidade do cache LRU. Na versão Go, conforme mencionado acima, aumentar o limite do cache LRU resultou em coletas de lixo mais longas. Não tínhamos mais que lidar com a coleta de lixo, então concluímos que poderíamos aumentar o limite do cache e obter um desempenho ainda melhor. Aumentamos a capacidade de memória das caixas, otimizamos a estrutura de dados para usar ainda menos memória (por diversão) e aumentamos a capacidade do cache para 8 milhões de estados de leitura.

Os resultados abaixo falam por si. Observe que o tempo médio agora é medido em microssegundos e max @mention é medido em milissegundos.

Ecossistema em evolução

Finalmente, outra grande coisa sobre o Rust é que ele tem um ecossistema em rápida evolução. Recentemente, tokio (o tempo de execução assíncrono que usamos) lançou a versão 0.2. Nós atualizamos e isso nos deu benefícios de CPU gratuitamente. Abaixo você pode ver que a CPU está consistentemente mais baixa começando por volta do dia 16.

Pensamentos finais

Neste ponto, o Discord está usando o Rust em muitos lugares em sua pilha de software. Nós o usamos para o SDK do jogo, captura e codificação de vídeo para Go Live, Elixir NIFs, vários serviços de back-end e muito mais.

Ao iniciar um novo projeto ou componente de software, consideramos o uso de Rust. Claro, só o usamos onde faz sentido.

Junto com o desempenho, o Rust tem muitas vantagens para uma equipe de engenharia. Por exemplo, sua segurança de tipo e verificador de empréstimo tornam muito fácil refatorar o código conforme os requisitos do produto mudam ou novos aprendizados sobre a linguagem são descobertos. Além disso, o ecossistema e as ferramentas são excelentes e têm uma quantidade significativa de impulso por trás deles.

Fonte: https://blog.discord.com/why-discord-is-switching-from-go-to-rust-a190bbca2b1f

Rodrigues Costa
Sempre gostei muito de tecnologia e decidi levar esse gosto para todos através de um site.

Os 5 principais módulos node.js para aumentar a produtividade da codificação

Previous article

You may also like

Comments

Comments are closed.

More in Notícias