Por que a discórdia migra de Go para Rust



A ferrugem está se tornando um idioma de primeira classe em uma ampla variedade de campos. Nós da Discord usamos com sucesso no servidor e no cliente. Por exemplo, no lado do cliente no pipeline de codificação de vídeo do Go Live, e no lado do servidor para as funções Elixir NIF (Native Implemented Functions).

Recentemente, aprimoramos drasticamente o desempenho de um único serviço, reescrevendo-o de Go to Rust. Este artigo explicará por que fazia sentido reescrever o serviço, como o fizemos e quanta produtividade melhorou.

Serviço de rastreamento de estado de leitura (estados de leitura)


Nossa empresa é construída em torno de um produto, então vamos começar com algum contexto, o que exatamente transferimos de Go para Rust. Este é um serviço de leitura de estados. Sua única tarefa é acompanhar quais canais e mensagens você lê. Os estados de leitura são acessados ​​toda vez que você se conecta ao Discord, toda vez que você envia uma mensagem e toda vez que você lê a mensagem. Em resumo, os estados são lidos continuamente e estão em um "caminho quente". Queremos garantir que o Discord seja sempre rápido, portanto a verificação de estado deve ser rápida.

A implementação do serviço on Go não atendeu a todos os requisitos. Na maioria das vezes, funcionava rapidamente, mas a cada poucos minutos havia atrasos fortes, perceptíveis para os usuários. Após examinar a situação, determinamos que os atrasos eram devidos aos principais recursos do Go: seu modelo de memória e coletor de lixo (GC).

Por que a Go não cumpre nossas metas de desempenho


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

Para armazenar informações de estado, usamos uma estrutura de dados, chamada: Estado de Leitura. Existem bilhões deles no Discord: um estado para cada usuário por canal. Cada estado possui vários contadores, que devem ser atualizados atomicamente e, geralmente, redefinidos para zero. Por exemplo, um dos contadores é o número @mentionno canal.

Para atualizar rapidamente o contador atômico, cada servidor Read States possui um cache LRU (Menos Utilizados Recentemente). Cada cache possui milhões de usuários e dezenas de milhões de estados. O cache é atualizado centenas de milhares de vezes por segundo.

Por segurança, o cache é sincronizado com o cluster do banco de dados Cassandra. Quando uma chave é pressionada para fora do cache, inserimos os estados desse usuário no banco de dados. No futuro, planejamos atualizar o banco de dados dentro de 30 segundos com cada atualização de estado. São dezenas de milhares de registros no banco de dados a cada segundo.

O gráfico abaixo mostra o tempo de resposta e a carga da CPU no intervalo de tempo de pico para o serviço Go 1. Pode-se observar que atrasos e rajadas de carga na CPU ocorrem aproximadamente a cada dois minutos.



Então, de onde vem o crescimento dos atrasos a cada dois minutos?


No Go, a memória não é liberada imediatamente quando uma tecla é pressionada para fora do cache. Em vez disso, o coletor de lixo é executado periodicamente e procura por partes não utilizadas da memória. Isso é muito trabalho que pode retardar um programa.

É muito provável que lentidão periódica de nosso serviço esteja associada à coleta de lixo. Mas nós escrevemos um código Go muito eficiente com uma quantidade mínima de alocação de memória. Não deve sobrar muito lixo. Qual é o problema?

Revistando o código-fonte Go, aprendemos que o Go inicia forçosamente a coleta de lixo pelo menos a cada dois minutos . Independentemente do tamanho da pilha, se o GC não for iniciado por dois minutos, o Go forçará a inicialização.

Decidimos que, se você executar o GC com mais frequência, poderá evitar esses picos com grandes atrasos; portanto, definimos um ponto final no serviço para alterar o valor da Porcentagem do GC rapidamente . Infelizmente, a configuração do percentual do GC não afetou nada. Como isso pôde acontecer? Acontece que o GC não queria iniciar com mais frequência, porque não alocamos memória com frequência suficiente.

Começamos a cavar mais. Acontece que esses atrasos grandes não ocorrem devido à enorme quantidade de memória liberada, mas porque o coletor de lixo varre todo o cache da LRU para verificar toda a memória. Decidimos que, se diminuirmos o cache da LRU, o volume da varredura diminuirá. Portanto, adicionamos mais um parâmetro ao serviço para alterar o tamanho do cache da LRU e alteramos a arquitetura, dividindo a LRU em muitos caches separados em cada servidor.

E assim aconteceu. Com caches menores, os atrasos de pico são reduzidos.

Infelizmente, o comprometimento com a diminuição do cache da LRU elevou o 99º percentil (ou seja, o valor médio para uma amostra de 99% dos atrasos aumentou, excluindo os de pico). Isso ocorre porque diminuir o cache reduz a probabilidade de o estado de leitura do usuário estar no cache. Se não estiver aqui, devemos recorrer ao banco de dados.

Após uma grande quantidade de testes de carga em diferentes tamanhos de cache, encontramos uma configuração aceitável. Embora não fosse o ideal, era uma solução satisfatória, então deixamos o serviço por um longo tempo para trabalhar dessa maneira.

Ao mesmo tempo, implementamos o Rust com muito sucesso em outros sistemas Discord e, como resultado, tomamos uma decisão coletiva de escrever frameworks e bibliotecas para novos serviços apenas no Rust. E esse serviço parecia ser um excelente candidato para a transferência para o Rust: é pequeno e autônomo, e esperávamos que o Rust corrigisse essas explosões com atrasos e, finalmente, tornasse o serviço mais agradável para os usuários 2.

Gerenciamento de memória em Rust


O Rust é incrivelmente rápido e eficiente com a memória: na ausência de um ambiente de tempo de execução e de um coletor de lixo, é adequado para serviços de alto desempenho, aplicativos incorporados e se integra facilmente a outros idiomas. 3

Como o Rust não possui um coletor de lixo, decidimos que não haveria atrasos como o Go.

No gerenciamento de memória, ele usa uma abordagem bastante única com a idéia de "possuir" memória. Em suma, Rust controla quem tem o direito de ler e gravar na memória. Ele sabe quando um programa usa memória e a libera imediatamente assim que a memória não é mais necessária. A ferrugem aplica regras de memória em tempo de compilação, o que praticamente elimina a possibilidade de erros de memória em tempo de execução. 4Você não precisa rastrear manualmente a memória. O compilador cuidará disso.

Portanto, na versão Rust, quando o estado de leitura é excluído do cache do LRU, a memória é liberada imediatamente. Essa memória não fica e não espera pelo coletor de lixo. Rust sabe que não está mais em uso e a libera imediatamente. Não há processo em tempo de execução para verificar qual memória liberar.

Ferrugem assíncrona


Mas havia um problema com o ecossistema Rust. No momento da implementação do nosso serviço, não havia funções assíncronas decentes no ramo estável do Rust. Para um serviço de rede, a programação assíncrona é uma obrigação. A comunidade desenvolveu várias bibliotecas, mas com uma conexão não trivial e mensagens de erro muito estúpidas.

Felizmente, a equipe do Rust trabalhou duro para simplificar a programação assíncrona e já estava disponível no canal instável (Nightly).

A discórdia nunca teve medo de aprender novas tecnologias promissoras. Por exemplo, fomos um dos primeiros usuários do Elixir, React, React Native e Scylla. Se alguma tecnologia parece promissora e nos oferece uma vantagem, estamos prontos para enfrentar a inevitável dificuldade de implementação e a instabilidade de ferramentas avançadas. Essa é uma das razões pelas quais atingimos tão rapidamente uma audiência de 250 milhões de usuários com menos de 50 programadores no estado.

A introdução de novas funções assíncronas do canal instável Rust é outro exemplo de nossa disposição de adotar uma nova e promissora tecnologia. A equipe de engenharia decidiu implementar as funções necessárias sem esperar pelo suporte na versão estável. Juntamente com outros representantes da comunidade, superamos todos os problemas que surgiram e agora a ferrugem assíncronamantido em um ramo estável. Nossa taxa valeu a pena.

Implementação, teste de estresse e lançamento


Apenas reescrever o código foi fácil. Começamos com uma transmissão aproximada e depois a reduzimos para lugares onde fazia sentido. Por exemplo, o Rust possui um excelente sistema de tipos, com amplo suporte a genéricos (para trabalhar com dados de qualquer tipo); portanto, descartamos silenciosamente o código Go, que compensava a falta de genéricos. Além disso, o modelo de memória Rust leva em consideração a segurança da memória em diferentes threads, então jogamos fora as goroutines protetoras.

O teste de carga mostrou imediatamente um excelente resultado. O desempenho do serviço no Rust acabou sendo tão alto quanto o da versão Go, mas sem essas explosões de maior atraso !

Normalmente, praticamente não otimizamos a versão Rust. Mas, mesmo com as otimizações mais simples, o Rust conseguiu superar uma versão cuidadosamente ajustada do Go.Essa é uma prova eloquente de como é fácil escrever programas eficazes de Rust em comparação com o aprofundamento no Go.

Mas não satisfazemos o simples desempenho quo. Após um pouco de criação de perfil e otimização, superamos o Go em todos os aspectos . Atraso, CPU e memória - tudo ficou melhor na versão Rust.

As otimizações de desempenho de ferrugem incluíram:

  1. Alternando para BTreeMap em vez de HashMap no cache LRU para otimizar o uso da memória.
  2. Substituindo a biblioteca original de métricas por uma versão com suporte para a concorrência moderna Rust.
  3. Diminua o número de cópias na memória.

Satisfeito, decidimos implantar o serviço.

O lançamento foi bem tranquilo, enquanto realizamos testes de estresse. Conectamos o serviço a um nó de teste, descobrimos e corrigimos vários casos limítrofes. Logo depois, eles lançaram uma nova versão para todo o parque de servidores.

Os resultados são mostrados abaixo.

O gráfico roxo é Go, o gráfico azul é Rust.



Aumentar o tamanho do cache


Quando o serviço funcionou com sucesso por vários dias, decidimos aumentar o cache da LRU novamente. Como mencionado acima, na versão Go, isso não pôde ser feito, porque o tempo para coleta de lixo aumentou. Como não fazemos mais a coleta de lixo, é possível aumentar o cache contando com um aumento ainda maior no desempenho. Portanto, aumentamos a memória nos servidores, otimizamos a estrutura de dados para menos uso de memória (por diversão) e aumentamos o tamanho do cache para 8 milhões de estados de estado de leitura.

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



Desenvolvimento de ecossistemas


Finalmente, Rust possui um maravilhoso ecossistema que está crescendo rapidamente. Por exemplo, recentemente uma nova versão do tempo de execução assíncrona que usamos é o Tokio 0.2. Atualizamos e, sem nenhum esforço de nossa parte, reduzimos automaticamente a carga na CPU. No gráfico abaixo, você pode ver como a carga diminuiu desde 16 de janeiro.



Pensamentos finais


Atualmente, o Discord usa Rust em muitas partes da pilha de software: para GameSDK, captura e codificação de vídeo no Go Live, Elixir NIF , vários serviços de back-end e muito mais.

Ao iniciar um novo projeto ou componente de software, estamos definitivamente considerando o uso do Rust. Claro, apenas onde faz sentido.

Além do desempenho, o Rust oferece aos desenvolvedores muitos outros benefícios. Por exemplo, seu tipo de segurança e verificador de empréstimos simplificam bastante a refatoração à medida que os requisitos do produto mudam ou novos recursos de idioma são introduzidos. O ecossistema e as ferramentas são excelentes e estão se desenvolvendo rapidamente.

Curiosidade: a equipe Rust também usa o Discord para coordenar. Existe até uma muito útilServidor da comunidade Rust , onde às vezes conversamos.



Notas de rodapé


  1. Gráficos retirados do Go versão 1.9.2. Tentamos as versões 1.8, 1.9 e 1.10 sem nenhuma melhoria. A migração inicial de Go para Rust foi concluída em maio de 2019. [para retornar]
  2. Para maior clareza, não recomendamos reescrever tudo no Rust sem motivo. [para retornar]
  3. Citação do site oficial. [para retornar]
  4. Claro, até você usar inseguro . [para retornar]

Source: https://habr.com/ru/post/undefined/


All Articles