Neste artigo, vamos dar uma olhada em algumas técnicas para analisar e melhorar o desempenho de aplicativos da web Rust.

O campo de otimização de desempenho no Rust é vasto e este tutorial pode apenas arranhar a superfície. Para uma ótima visão geral das ferramentas e do cenário técnico do Rust quando se trata de desempenho, eu recomendo muito The Rust Performance Book por Nicholas Nethercote.

Neste tutorial, veremos uma maneira de medir o desempenho de aplicativos da web e explorar uma ferramenta para analisar e melhorar seu código Rust em geral.

Se esta postagem atingir seu objetivo, você deverá sair com algum conhecimento útil para melhorar o desempenho de seus aplicativos da web Rust, juntamente com alguns bons recursos para se aprofundar no tópico.

Vamos começar!

Configuração

Para acompanhar, tudo que você precisa é uma instalação recente do Rust (1.45+) e uma instalação do Python3 com a capacidade de executar Locust .

Primeiro, crie um novo projeto Rust:

 cargo new rust-web-profiling-example
cd rust-web-profiling-example

Em seguida, edite o arquivo Cargo.toml e adicione as dependências necessárias:

 [dependências]
tokio={version="1.1", features=["macros","time","rt-multi-thread","sync"]}
warp="0,3" [profile.release]
debug=true

Tudo que precisamos para este tutorial é um pequeno serviço da web, então usaremos Warp e Tokio para criá-lo. As técnicas discutidas neste artigo funcionarão com qualquer outra biblioteca e framework da web, no entanto.

Observe que definimos debug=true para o perfil de lançamento, o que significa que teremos informações de depuração mesmo na compilação de lançamento. A razão para isso é que sempre queremos fazer a otimização de desempenho no modo release com todas as otimizações do compilador. No entanto, também gostaríamos de ter o máximo de informações possível sobre o código em execução, o que torna a criação de perfil muito mais fácil.

Um serviço web mínimo

Primeiro, criamos um serviço da web Warp muito básico com um recurso compartilhado e alguns endpoints para testar.

Começamos definindo alguns tipos:

 digite WebResult =std:: result:: Result ; # [derivar (depurar, clonar)]
pub struct Client { pub user_id: usize, pub subscribed_topics: Vec ,
} Tipo de pub Clientes=Arc >>;

O WebResult é simplesmente um tipo auxiliar para o resultado de nossos gerenciadores da web. O tipo Clients é nosso recurso compartilhado-um mapa de ids de usuário para clientes. Um Client tem um user_id e uma lista de tópicos assinados, mas isso não é particularmente relevante para nosso exemplo.

O que é relevante é que este recurso será compartilhado por toda a nossa aplicação e vários endpoints irão acessá-lo simultaneamente. Para esse propósito, nós o envolvemos em Mutex , para proteger o acesso e o colocamos em um ponteiro inteligente Arc , para que possamos distribuí-lo com segurança.

A seguir, definimos alguns auxiliares para inicializar e propagar nossos Clientes :

 fn with_clients (clients: Clients)-> impl Filter  + Clone { warp:: any (). map (mover || clients.clone ())
} async fn initialize_clients (clients: & Clients) { deixe mut clients_lock=clients.lock (). await; clients_lock.insert ( String:: from ("87-89-34"), Cliente { user_id: 1, subscribed_topics: vec! [String:: from ("gatos"), String:: from ("cães")], }, ); clients_lock.insert ( String:: from ("22-38-21"), Cliente { user_id: 2, subscribed_topics: vec! [String:: from ("gatos"), String:: from ("répteis")], }, ); clients_lock.insert ( String:: from ("12-67-22"), Cliente { user_id: 3, tópicos_de_inscrição: vec! [ String:: from ("ratos"), String:: from ("pássaros"), String:: from ("cobras"), ], }, );
}

O with_clients Warp Filter é simplesmente uma maneira de disponibilizar recursos para rotas no framework da web Warp. Em initialize_clients , adicionamos alguns valores codificados ao nosso mapa Clients compartilhado, mas os valores reais não são particularmente relevantes para o exemplo.

Em seguida, adicionamos um módulo handler , que usará os Clientes compartilhados:

 use crate:: {Clients, FasterClients, WebResult};
use std:: time:: Duration;
use warp:: {responder, Responder}; pub async fn read_handler (clientes: clientes)-> WebResult  { deixe clients_lock=clients.lock (). await; let user_ids: Vec =clients_lock .iter () .map (| (_, cliente) | client.user_id.to_string ()) .collect (); tokio:: time:: sleep (Duration:: from_millis (50)). await; let result=user_ids .iter () .rev () .map (| user_id | user_id.parse::  ().expect ("pode ​​ser analisado para usar")) .fold (0, | acc, x | acc + x); Ok (responder:: html (result.to_string ()))
}

Esta função de manipulador da web assíncrona recebe uma referência clonada e compartilhada de Clients , acessa-a e obtém uma lista de user_ids do mapa.

Então, usamos tokio:: time:: sleep para pausar a execução aqui de forma assíncrona. Isso é apenas para simular a passagem de algum tempo nesta solicitação-pode ser, por exemplo, uma chamada de banco de dados ou uma chamada HTTP para outro serviço em um aplicativo do mundo real.

Depois que o manipulador sai do modo de espera, fazemos outra operação nos user_ids , analisando-os em números, revertendo-os, adicionando-os e devolvendo-os ao usuário.

Isso ocorre apenas para que nenhum código seja otimizado-neste caso, isso deve simular algum trabalho vinculado à CPU.

Agora, vamos conectar tudo em main :

 # [tokio:: main]
assíncrono fn main () { deixar clientes: Clients=Clients:: default (); initialize_clients (& clients).await; deixe read_route=warp:: path! ("ler") .and (with_clients (clients.clone ())) .and_then (handler:: read_handler); println! ("Servidor iniciado em localhost: 8080"); warp:: serve (read_route) .run (([0, 0, 0, 0], 8080)) .aguardam;
}

Simplesmente criamos os Clients , inicializamos, definimos a rota read e iniciamos o servidor com esta rota na porta 8080 .

Quando executamos isso usando cargo run , podemos ir para http://localhost: 8080/read e teremos uma resposta.

Até agora, tudo bem. Vamos ver como isso funciona.

Teste de carga

Para testar o desempenho de nosso serviço da web e do manipulador read em particular, usaremos Locust neste tutorial. No entanto, qualquer outro aplicativo de teste de carga (como Gatling ) ou sua própria ferramenta para enviar e medir muitas solicitações para um servidor web, será suficiente.

Instalar o Locust é bastante simples-você pode instalá-lo diretamente ou em um virtualenv .

Agora, com o Locust instalado, vamos criar a pasta locust em nosso projeto, onde podemos adicionar algumas definições de teste de carga:

 de locust importar HttpUser, tarefa, entre classe Basic (HttpUser): wait_time=entre (0,5, 0,5) @tarefa def read (self): self.client.get ("/ler")

Escrever um locustfile é relativamente simples, mas se você quiser se aprofundar, a documentação do Locust é fantástico.

No exemplo read.py acima, criamos uma classe chamada Basic baseada em HttpUser , que nos dará todos os ajudantes Locust dentro da classe.

Então definimos um @task chamado read , e este cliente simplesmente faz uma solicitação GET para /read usando o cliente HTTP que o Locust fornece. Também definimos a propriedade wait_time , que controla quanto tempo esperar entre as solicitações. Isso é útil se o objetivo é simular o comportamento real do usuário, mas, em nosso caso, vamos defini-lo apenas para 0,5 segundos.

Vamos executá-lo usando o seguinte comando:

 locust-f read.py--host=http://127.0.0.1: 8080

Agora podemos navegar para http://localhost: 8089 e seremos recebidos pela interface da web do Locust.

Lá, podemos definir a quantidade de usuários que queremos simular e a velocidade com que eles devem gerar (por segundo).

Captura de tela do teste de carga Locust

Nesse caso, queremos gerar 3.000 usuários com 100/s. Esses usuários farão uma solicitação /read a cada 0,5 segundos até que paremos.

Dessa forma, podemos criar alguma carga no serviço da web, o que nos ajudará a encontrar gargalos de desempenho e caminhos importantes no código, como veremos mais tarde.

Uma coisa importante a se notar ao otimizar o desempenho no Rust, é sempre compilar no modo release . Não crie o perfil de seu binário debug , pois o compilador não fez nenhuma otimização lá e você pode acabar otimizando parte do seu código que o compilador irá melhorar ou descartar totalmente.

Então, executamos cargo build --release e, em seguida, iniciamos o aplicativo usando ./target/release/rust-web-profiling-example. Agora nossos gafanhotos podem começar a enxamear!

Você pode ter que aumentar o número de arquivos abertos permitidos para o processo locust usando um comando como ulimit-n 200000 no terminal onde você executa o Locust.

Se executarmos o teste de carga por um tempo, pelo menos até que todos os usuários sejam gerados e os tempos de resposta se estabilizem, podemos ver algo assim, ao interrompê-lo:

Captura de tela dos resultados do teste de carga Locust

Vemos que conseguimos obter meros 19,5 pedidos por segundo e os pedidos demoraram em média 18+ segundos. Claramente há algo errado com nosso código-mas não fizemos nada extravagante, e Rust, Warp e Tokio são todos super rápidos. O que aconteceu?

Melhorando o desempenho de bloqueio

Se revisarmos o código em nosso read_handler , poderemos notar que estamos fazendo algo muito ineficiente no que diz respeito ao bloqueio Mutex:

 pub async fn read_handler (clientes: clientes)-> WebResult  { deixe clients_lock=clients.lock (). await; let user_ids: Vec =clients_lock .iter () .map (| (_, cliente) | client.user_id.to_string ()) .collect (); tokio:: time:: sleep (Duration:: from_millis (50)). await; let result=user_ids .iter () .rev () .map (| user_id | user_id.parse::  ().expect ("pode ​​ser analisado para usar")) .fold (0, | acc, x | acc + x); Ok (responder:: html (result.to_string ()))
}

Nós adquirimos o bloqueio, acessamos os dados e, nesse ponto, terminamos com os clientes e não precisamos mais deles. No entanto, como o client_lock permanece no escopo, especialmente durante toda a duração da nossa falsa chamada DB (sleep), isso significa que travamos o recurso durante toda a duração deste manipulador!

Além disso, neste aplicativo, exceto para a inicialização, nós apenas lemos a partir do recurso compartilhado, mas um Mutex não distingue entre acesso de leitura e gravação, ele simplesmente sempre bloqueia.

Portanto, existem duas otimizações simples que podemos fazer aqui:

  1. Tiramos o cadeado depois de terminar de usá-lo
  2. Usamos um RwLock em vez de um Mutex , uma vez que não bloqueia se houver apenas leituras, mas apenas se houver uma gravação

Portanto, em main , implementamos um tipo FasterClients usando um RwLock :

 tipo de pub FasterClients=Arc >>; # [tokio:: main]
assíncrono fn main () { ... deixe Fast_clients: FasterClients=FasterClients:: default (); initialize_faster_clients (& faster_clients).await; ... deixe fast_route=warp:: path! ("rápido") .and (with_faster_clients (faster_clients.clone ())) .and_then (handler:: fast_read_handler); ... warp:: serve (read_route.or (fast_route).or (cpu_route).or (cpu_route_alloc)) .run (([0, 0, 0, 0], 8080)) .aguardam;
} fn with_faster_clients ( clientes: FasterClients,
)-> Filtro impl  + Clone { warp:: any (). map (mover || clients.clone ())
} async fn initialize_faster_clients (clientes: & FasterClients) { deixe mut clients_lock=clients.write (). await; clients_lock.insert ( String:: from ("87-89-34"), Cliente { user_id: 1, subscribed_topics: vec! [String:: from ("gatos"), String:: from ("cães")], }, ); clients_lock.insert ( String:: from ("22-38-21"), Cliente { user_id: 2, subscribed_topics: vec! [String:: from ("gatos"), String:: from ("répteis")], }, ); clients_lock.insert ( String:: from ("12-67-22"), Cliente { user_id: 3, tópicos_de_inscrição: vec! [ String:: from ("ratos"), String:: from ("pássaros"), String:: from ("cobras"), ], }, );
}

Inicializamos os FasterClients da mesma maneira e os passamos da mesma forma que os Clients com um filtro. Também definimos uma rota para /fast com o seguinte manipulador:

 pub async fn fast_read_handler (clientes: FasterClients)-> WebResult  { deixe clients_lock=clients.read (). await; let user_ids: Vec =clients_lock .iter () .map (| (_, cliente) | client.user_id.to_string ()) .collect (); drop (clientes_lock); tokio:: time:: sleep (Duration:: from_millis (50)). await; let result=user_ids .iter () .rev () .map (| user_id | user_id.parse::  ().expect ("pode ​​ser analisado para usar")) .fold (0, | acc, x | acc + x); Ok (responder:: html (result.to_string ()))
}

Como você pode ver, ultrapassamos os FasterClients agora e eliminamos o bloqueio imediatamente após terminar de usá-lo. Isso deve nos dar um grande aumento de velocidade-vamos verificar.

No arquivo read.py Locust, você pode comentar o endpoint /read anterior e adicionar o seguinte:

 @task def read (self): self.client.get ("/fast")

Vamos recompilar e executar o Locust novamente.

Captura de tela dos resultados do teste de carregamento do Locust

É mais rápido, certo! Recebemos cerca de 820 solicitações por segundo, uma melhoria de 40 vezes, apenas alterando um tipo e eliminando um bloqueio mais cedo.

Agora, neste ponto, você pode revirar os olhos um pouco com este exemplo inventado, e eu concordo que isso provavelmente não é um problema que você encontrará muito em sistemas reais. No entanto, esta postagem é sobre um fluxo de trabalho e ferramentas que podemos usar para encontrar problemas de desempenho e a abordagem descrita até agora é um bom ponto de partida para detectar ineficiências em seu aplicativo.

A seguir, armados com uma ótima maneira de testar a carga de nosso aplicativo da web, faremos alguns perfis reais para obter uma visão mais aprofundada do que acontece nos bastidores de nossos gerenciadores da web.

Gráficos de chamas

A seguir, veremos uma técnica real de criação de perfil usando a ferramenta conveniente cargo-flamegraph , que envolve e automatiza a técnica descrita no artigo gráfico em chamas de Brendan Gregg.

A ideia básica é coletar dados de desempenho usando ferramentas como perf ou dtrace , em particular quais funções levam quanto tempo de CPU durante a amostragem e para então visualize os resultados de uma forma que possa ser bem interpretada.

Os gráficos de chama também podem ser usados ​​para fazer, entre outras análises, Análise fora da CPU , que pode ajudar a encontrar problemas onde os threads estão esperando muito por I/O, por exemplo.

Neste exemplo, faremos apenas a análise do tempo de CPU, que é suportada pelo cargo-flamegraph.

Primeiro, vamos construir um manipulador para obter uma boa visualização:

 pub async fn cpu_handler_alloc (clientes: FasterClients)-> WebResult  { deixe clients_lock=clients.read (). await; let user_ids: Vec =clients_lock .iter () .map (| (_, cliente) | client.user_id.to_string ()) .collect (); drop (clientes_lock); deixe mut result=0; para i em 0..1000000 { resultado +=user_ids .iter () .clonado () .rev () .map (| user_id | user_id.parse::  ().expect ("pode ​​ser analisado para usar")) .fold (i, | acc, x | acc + x); } Ok (responder:: html (result.to_string ()))
}

Neste exemplo (também bastante artificial), reutilizamos a base do manipulador /fast , mas estendemos o cálculo para ser executado em um loop longo. Observe também como usamos .cloned () no iterador, clonando a lista inteira para cada iteração. Este é um problema de desempenho bastante óbvio, mas quando você está fazendo malabarismos com referências e lutando com o verificador de empréstimo, é possível que o estranho e supérfluo .clone () apareça em seu código que, dentro de loops ativos, pode levar a problemas de desempenho.

Também adicionamos o manipulador em principal:

... let cpu_route_alloc=warp:: path! ("cpualloc") .and (with_faster_clients (faster_clients.clone ())) .and_then (handler:: cpu_handler_alloc);
...

Vamos executar cargo flamegraph para coletar estatísticas de criação de perfil com o seguinte comando:

 cargo flamegraph--bin rust-profiling-example

Além disso, adicionamos outro arquivo Locust em /locust chamado cpu.py :

 de locust importar HttpUser, tarefa, entre classe Basic (HttpUser): wait_time=entre (0,5, 0,5) @tarefa def cpu (próprio): self.client.get ("/cpualloc")

Isso é essencialmente o mesmo de antes, apenas chamando o endpoint /cpualloc .

Depois de executar este teste de carga em nosso aplicativo com perfil e parar o servidor da web Rust em execução usando CTRL + C, obtemos um gráfico em degradê como este:

Captura de tela dos resultados do flamegraph

Você pode ver os benefícios desta visualização. Vemos, empilhados, onde passamos a maior parte do tempo durante o teste de carga. Podemos rastrear desde o tempo de execução do Tokio até nosso cpu_handler e o cálculo. Uma coisa a observar é que gastamos muito tempo fazendo alocações.

Isso não é muito surpreendente, pois adicionamos .cloned () ao iterador, que, para cada iteração de loop, clona o conteúdo da lista antes de processar os dados. Podemos ver entre os blocos de alocação que também passamos algum tempo analisando as strings em números.

Vamos tentar nos livrar dessas alocações desnecessárias. Novamente, este é um exemplo um pouco simplificado e, no código real, você provavelmente terá que cavar um pouco mais fundo para encontrar os problemas subjacentes, mas esta demonstração mostra as ferramentas e um fluxo de trabalho para abordar os problemas de desempenho em seu código.

Se você estiver procurando especificamente por problemas de desempenho relacionados à memória, pode dar uma olhada nas ferramentas mencionadas em seção Profiling do The Rust Performance Book, ou seja, heaptrack, DHAT ou cachegrind.

Consertar isso é muito fácil, simplesmente removemos o .cloned () , pois não precisamos dele aqui de qualquer maneira, mas como você deve ter notado, a clonagem desnecessária pode levar a grandes impactos no desempenho, especialmente dentro do código quente. Freqüentemente, pessoas que ainda não estão familiarizadas com o sistema de propriedade do Rust usam .clone () para fazer com que o compilador os deixe em paz.

 pub async fn cpu_handler (clientes: FasterClients)-> WebResult  { deixe clients_lock=clients.read (). await; let user_ids: Vec =clients_lock .iter () .map (| (_, cliente) | client.user_id.to_string ()) .collect (); drop (clientes_lock); deixe mut result=0; para i em 0..1000000 { resultado +=user_ids .iter () .rev () .map (| user_id | user_id.parse::  ().expect ("pode ​​ser analisado para usar")) .fold (i, | acc, x | acc + x); } Ok (responder:: html (result.to_string ()))
}

E principalmente:

...
let cpu_route=warp:: path! ("cpu")
.and (with_faster_clients (faster_clients.clone ()))
.and_then (handler:: cpu_handler);
...

Vamos executar a amostragem novamente. Obteremos um gráfico em degradê como este:

Captura de tela de um flamegraph

Isso é uma grande diferença! Como você pode ver, gastamos muito menos tempo alocando memória e gastamos a maior parte do nosso tempo analisando as strings em números e calculando nosso resultado.

Você também pode usar uma ferramenta como Hotspot para criar e analisar gráficos em degradê. O bom de usar essas ferramentas de alto nível é que você não apenas obtém um arquivo estático.svg, que oculta alguns dos detalhes, mas também pode ampliar seu perfil!

É isso! O código de exemplo completo pode ser encontrado em GitHub .

Conclusão

Nesta postagem, demos um mergulho na medição de desempenho e no aprimoramento de aplicativos da web Rust.

As possibilidades nesta área são quase tão infinitas quanto as diferentes maneiras de escrever código. Neste tutorial, tentei fornecer a você algumas técnicas, que me ajudaram a encontrar código lento e regressões de desempenho no passado.

Se você está interessado neste tipo de coisa e quer mergulhar mais fundo, há uma enorme toca de coelho esperando por você e você pode usar os recursos mencionados em The Rust Performance Book como um ponto de partida em sua jornada rumo ao código Rust ultrarrápido.

A postagem Uma introdução à criação de perfil de uma Web Rust o aplicativo apareceu primeiro no LogRocket Blog .

Source link