Thread por núcleo
(without.boats)- O debate sobre runtimes async em Rust está menos ligado a usar ou não “uma thread de SO por núcleo” e mais próximo de escolher entre equilibrar a carga com work-stealing ou reduzir a movimentação de dados com share-nothing
- Rust async já atende às condições centrais de thread-per-core — concorrência em espaço de usuário e I/O assíncrona — e a questão restante é quanto é possível evitar o compartilhamento de estado entre núcleos
- O work-stealing busca reduzir o tempo ocioso das threads para melhorar a latência de cauda (tail latency) e a utilização de CPU, mas em Rust o estado que atravessa pontos de yield precisa ser thread-safe, o que traz a exigência de
Send - O share-nothing pode reduzir a latência ao manter os dados no cache de um núcleo específico, mas a dificuldade varia muito entre casos como armazenamentos chave-valor, em que o particionamento de estado é fácil, e casos que exigem transações e mudanças atômicas
- O artigo de Enberg compara share-nothing e shared-state, mas nenhum dos dois lados usa work-stealing, então é difícil ligar diretamente seus resultados a uma crítica dos executores com work-stealing em Rust
O ponto central do debate sobre runtimes async em Rust
- Na comunidade Rust, continua o debate sobre a escolha de runtimes async principais como o tokio usarem por padrão executores multithread e equilibrar dinamicamente várias tarefas com work-stealing
- Alguns usuários criticam esse padrão por prejudicar a experiência de escrever código, já que impõe restrições como
Send + 'staticouSend + Sync + 'static - Há também quem prefira servidores single-thread, mas aqui parte-se do cenário em que um sistema Rust quer usar mais de um núcleo de CPU
- A alternativa apresentada como “thread-per-core” costuma ser vista como mais rápida e mais simples de implementar, mas é difícil obter ao mesmo tempo desempenho e facilidade de implementação
A confusão criada pelo nome “thread-per-core”
- Mesmo executores async multithread tradicionais, em um sentido amplo, também se encaixam em thread-per-core
- criam uma thread de SO por núcleo
- e escalonam nessas threads muito mais tarefas do que o número de núcleos
- Pekka Enberg resume thread-per-core como a combinação de três ideias
- tratar a concorrência em espaço de usuário em vez de depender de threads de kernel caras
- usar I/O assíncrona para que a thread de cada núcleo não bloqueie
- particionar os dados entre núcleos de CPU para eliminar custos de sincronização e movimentação de dados entre caches
- Enberg considera as duas primeiras importantes para construir sistemas de alta vazão, mas entende que a terceira talvez só seja necessária em máquinas multicore muito grandes
- Se você usa Rust async, essas duas primeiras condições — concorrência em espaço de usuário e I/O assíncrona — já estão atendidas
- Por isso, o centro do debate não é thread-per-core em si, e sim qual otimização escolher entre work-stealing e share-nothing
Objetivo e custo do work-stealing
- Work-stealing é uma otimização para manter todas as threads sempre com trabalho, reduzindo a latência de cauda
- Em sistemas reais, cada tarefa exige quantidades diferentes de trabalho
- uma requisição HTTP pode exigir muito mais trabalho do que outra
- mesmo que a distribuição inicial entre threads seja equilibrada, diferenças imprevisíveis entre tarefas podem fazer a carga divergir com o tempo
- Sob carga máxima, algumas threads podem receber mais trabalho do que conseguem processar, enquanto outras permanecem ociosas
- tokio, async-std e smol implementam work-stealing com o objetivo de reduzir a latência de cauda e melhorar o uso de CPU
- O custo é que uma tarefa pode parar em uma thread e depois continuar em outra
- o estado usado após um ponto de yield precisa ser thread-safe
- nas APIs Rust, isso aparece como a exigência de que o future seja
Send - se o estado do sistema não estiver claramente entendido, fica difícil decidir como garantir
Send
- Quando o estado se move para outra thread, surgem custos de sincronização e cache misses, o que entra em conflito com o princípio share-nothing, em que cada CPU lida apenas com seu próprio estado
A lógica de desempenho do share-nothing
- Share-nothing é um desenho que busca reduzir a latência de cauda mantendo os dados no cache mais rápido de um núcleo específico de CPU, em vez de em um cache lento compartilhado por vários núcleos
- O artigo de Enberg The Impact of Thread-Per-Core Architecture on Application Tail Latency mostra melhora de latência de cauda ao comparar um armazenamento chave-valor share-nothing com uma arquitetura de memcached de shared-state
- Na comunidade Rust, citar esse artigo de forma simplista como “71% de melhora de desempenho” é superficial e pouco útil
- O armazenamento chave-valor de Enberg divide estado e conexões para viabilizar a estrutura share-nothing
- particiona o keyspace entre várias threads com uma função hash
- distribui as conexões TCP de entrada entre threads com
SO_REUSEPORT - roteia requisições da thread que gerencia a conexão para a thread responsável por aquele keyspace usando canais de passagem de mensagens
- No memcached, todas as threads compartilham a propriedade de um keyspace particionado, e cada partição é protegida por mutex
- Os resultados de Enberg mostram que uma arquitetura baseada em canais pode alcançar latência de cauda menor do que uma baseada em mutex
- isso pode ser entendido como menos cache misses, porque a mesma partição é acessada repetidamente e permanece no cache de um único núcleo
- Ainda assim, é difícil dizer que uma arquitetura que evita movimentação de dados com recursos avançados de kernel e planejamento cuidadoso seja mais simples de implementar do que apenas colocar os dados dentro de um mutex
Casos em que particionar estado é fácil e difícil
- Armazenamentos chave-valor são um bom exemplo para share-nothing, porque é fácil dividir o estado da aplicação entre várias threads
- Em aplicações mais complexas, se for necessário alterar o estado de várias partições de forma transacional ou atômica, a implementação correta exige muito mais cuidado
- A defesa de share-nothing lembra em parte o entusiasmo exagerado do passado em torno de bancos de dados de eventual consistency
- o desempenho pode melhorar
- mas é preciso um desenho cuidadoso para evitar bugs causados por inconsistência de dados
A distância entre o artigo de Enberg e o debate sobre work-stealing em Rust
- Nem a implementação de Enberg nem o memcached usam work-stealing
- Por isso, é difícil ligar diretamente os principais resultados de desempenho do artigo à arquitetura de work-stealing em Rust
- Se work-stealing fosse adicionado à arquitetura de Enberg, a movimentação de dados poderia aumentar até certo ponto, mas também poderia elevar a utilização de CPU
- No caso de adicionar work-stealing ao memcached, é difícil imaginar por que isso não ajudaria
- A implementação do artigo foi projetada para distribuir o trabalho de forma uniforme antecipadamente, com particionamento equilibrado do keyspace e
SO_REUSEPORT - Em ambientes reais, podem surgir desequilíbrios dinâmicos
- uma hot key pode receber mais leituras e escritas, aumentando a carga da thread responsável por aquele keyspace
- algumas conexões podem executar muito mais requisições do que outras, ampliando a carga da thread que as gerencia
- Pelo que se entende, o benchmark do artigo não reproduz essas condições de desequilíbrio, porque cada conexão executa uma carga uniforme sobre chaves aleatórias
- Mesmo dentro de um sistema share-nothing, é possível projetar mecanismos para reduzir esse desequilíbrio, como armazenar uma hot key em cache em partições adicionais
- Também é possível fixar algumas tarefas em núcleos específicos para evitar movimentação de estado, enquanto outras formas de work-stealing são usadas como otimização
Conclusão prática
- Se um sistema for cuidadosamente projetado para evitar movimentação de dados entre caches de CPU, ele pode ter desempenho melhor do que um sistema que não faz isso
- Mas, se a maior reclamação for apenas a necessidade de adicionar restrições
Senda generics, então provavelmente não se está fazendo esse tipo de engenharia de desempenho detalhada - E, se o sistema de qualquer forma vai usar shared state, é difícil sustentar que work-stealing não melhora a utilização de CPU sob carga
1 comentários
Opiniões do Hacker News
Pessoalmente, este texto me dá a impressão de que perde a visão geral e fica preso aos detalhes
O ponto central do debate não é um executor com roubo de trabalho no modelo de uma thread por núcleo, mas se async/await em Rust é uma boa abstração para isso
Quanto mais código async eu uso, mais sinto que a abstração vaza e que é difícil programar contra ela
O modelo alternativo de concorrência que as pessoas querem é concorrência estruturada por meio de corrotinas com stack e canais, em cima de um executor com roubo de trabalho
Até alguém implementar isso e comparar com async/await e o modelo baseado em futures, será difícil ter uma discussão produtiva; quem não gosta de async vai evitar, e quem não se importa com
Send + Sync + 'staticvai continuar usandoImagino que, na maioria dos casos, trabalhos que exigem essa escala sejam muito raros
Textos como este apenas dizem “threads do kernel são caras” e passam adiante como se isso fosse inerentemente verdadeiro, mas na prática não é bem assim
Se o trabalho não estiver apenas criando tarefas que não fazem nada o tempo todo, é provável que a sobrecarga de “threads de verdade” seja pequena, enquanto a simplicidade obtida em troca é enorme
A citação abordada por withoutboats é a parte do texto linkado que critica especificamente o multithreading padrão e o roubo de trabalho
[1] https://www.reddit.com/r/rust/comments/16p47f1/the_state_of_...
Este texto apenas trata de outro debate, não daquele que eu gostaria que tivesse sido escrito
Também dá para criar servidores ao estilo Erlang que possuem tipos mutáveis e se comunicam por canais, ou seguir em frente usando
ArcRust dá a você o poder de fazer ambos
Corrotinas com stack parecem não fazer muito sentido nesse ponto, porque aí bastaria usar threads separadas
Cerca de 15 anos atrás, o problema original que o modelo de uma thread por núcleo tentou resolver era obter escalabilidade e eficiência computacional em servidores multicore de uso geral
Ao contrário de algumas alegações, uma thread por núcleo foi explicitamente pensada para otimizar cargas de trabalho centradas em CPU e, embora depois tenha sido necessário um tratamento de I/O mais sofisticado, acabou ficando claro que ela também se destaca em cargas centradas em I/O de alta vazão
Ao ler textos como este, parece que eles repetem rapidamente vários erros de projeto de software que ocorreram quando arquiteturas de uma thread por núcleo foram introduzidas
Para ser justo, a ciência da computação relacionada a uma thread por núcleo veio principalmente de HPC e é pouco documentada
Este texto se concentra no problema difícil das arquiteturas de uma thread por núcleo: o balanceamento de trabalho entre núcleos
O modelo básico tem quatro variantes: push/pull de dados/carga, e o roubo de trabalho é essencialmente um modelo de pull de carga (load pull)
Essa abordagem só tem baixa sobrecarga quando quase não precisa ser usada, isto é, quando há um equilíbrio natural — algo raro em problemas reais
Em cargas de trabalho mais interessantes, nas quais desvios dinâmicos de carga entre núcleos são comuns, o roubo de trabalho vira um gargalo de desempenho por causa da sobrecarga de coordenação
Ainda assim, por ser fácil de entender, continua sendo usado nas cargas de trabalho certas, mas não se generaliza bem
Entre as cargas de trabalho raras não mencionadas no texto, talvez haja casos em que ele seja a melhor opção
Hoje, o modelo que parece ganhar mais força é o de push de dados (data push), que é menos intuitivo, mas exige muito menos coordenação entre threads
Esse modelo também não se encaixa em certas cargas de trabalho, mas se generaliza bem para a maioria das cargas comuns
Arquiteturas de uma thread por núcleo vieram para ficar. Em escalabilidade e eficiência, não há como superá-las
O problema é que a maioria dos engenheiros de software não tem intuição sobre como é um projeto moderno e idiomático de uma thread por núcleo, e isso piora porque há poucos textos ou artigos que tratem do tema em profundidade
Pelo perfil e por esta explicação, imagino que esse não seja o tipo de aplicação com que você costuma lidar
Eu gostaria de ver links para literatura relacionada, se houver
Concordo com a frase: “Dizer que é centrado em I/O significa, na prática, que, ao escrever em Rust, não há trabalho suficiente para saturar um único core. Então, obviamente, escreva um sistema de thread única”
Boa parte das aplicações que uso são daemons que reagem a eventos em segundo plano e, se forem feitas com uma única thread, dá para eliminar a sobrecarga de
ArceMutexEssa sobrecarga, nesse ponto, é mais um peso sintático do que qualquer outra coisa, mas facilita depuração e manutenção
Pagar custo só pelo que é necessário é uma das coisas de que gosto em Rust
O texto original ao qual este responde critica o fato de tokio e outras bibliotecas async tornarem difícil voltar a uma arquitetura simples de thread única
Há algum exagero, mas no geral concordo com essa crítica
Tornar o padrão mais complexo por ser melhor para aplicações de alta vazão parece ir contra os ideais de Rust
Não são limitados por vazão; ficam ociosos a maior parte do tempo e, quando aparece trabalho, tentam terminá-lo rápido para reduzir o uso de recursos do sistema
A menos que de vez em quando haja uma enorme explosão de trabalho e, nesse momento, a latência seja muito importante, usar mais de uma thread só adiciona complexidade e sobrecarga sem benefício
Em um sistema operacional, todos os serviços de sistema precisam lidar com requisições IPC simultaneamente, mas a maioria faz isso em uma única thread para reduzir o consumo total de CPU
Em um dispositivo de 4 cores, transformar dezenas de serviços em thread por core desperdiça CPU e RAM
Na API do tokio, ele se chama
LocalSethttps://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
Mesmo que uma única thread em uma única CPU seja suficiente, você ainda pode querer concorrência
ArceMutex, você acabaria usandoRceRefCell, e acho que, em termos de código, isso não é igualmente complexo e verboso?Entendo que seja menos eficiente, mas, no caso descrito, o custo extra de algumas operações atômicas não seria insignificante de qualquer forma?
Sobre a citação, concordo que a frase “O pecado original da programação async em Rust foi tornar o padrão multithread…
Send + 'static, ou pior,Send + Sync + 'static, mata o prazer de usar Rust” soa melodramática demaisNão acho que remover
Send + Syncfaça uma diferença tão grande assimO que mais incomoda é
'static, e isso não é por causa de work stealingO que eu quero é concorrência com escopo
Por exemplo, algo como <https://github.com/tokio-rs/tokio/issues/2596>
Outra coisa de que realmente não gosto no async de Rust hoje é a falta de instrumentação
Na empresa, temos um problema em produção em que algumas tasks simplesmente travam, e eu queria poder fazer o equivalente a
gdb; thread apply all btNo mínimo, espero que <https://github.com/tokio-rs/tokio/issues/5638> seja incorporado
Hoje isso já existe de forma experimental, mas, pela minha experiência, às vezes causa panic
Hoje vou de fato escrever um PR para tentar usar a versão experimental ao receber SIGTERM
A avaliação é que, como já está encerrando mesmo, não faz mal se houver crash
Nenhuma dessas reclamações seria resolvida eliminando work stealing
E, mesmo listando mais, remover work stealing quase não ajudaria em nada
Nem precisa de debugger: envie SIGQUIT para a JVM e ela despeja no stderr os stack traces de todas as threads e continua executando
Também inclui quais locks cada thread está segurando ou aguardando
Sinto falta desse recurso sempre que uso outra linguagem
Também dá para usá-lo para profiling ad hoc em produção
Basta tirar vários snapshots e usar
grep/sed/sort/uniqpara encontrar hotspotsÉ preciso espalhar manualmente
r.set_location(file!(), line!());antes de cada ponto deawait, mas isso já ajudou várias vezes a explicar por que o sistema parecia travado[1] https://github.com/antialize/tokio-tasks/blob/main/src/run_t... tem
set_location(), etask.rstemlist_tasks()Este problema não tem uma resposta certa; tudo depende do caso de uso
No fim, é uma questão de cargas de trabalho centradas em I/O versus centradas em CPU, e de quão ruim é o impacto de coisas como expulsão de cache ou contenção de locks
Se você está colocando, em um servidor virtual compartilhado, um servidor HTTP que se comunica com um banco de dados externo e tem uma lógica de negócios leve no meio, roubo de trabalho e reutilização de threads fazem sentido intuitivamente
Claro, é preciso sempre fazer benchmark
Por outro lado, se você está criando um banco de dados ou um sistema parecido, e alta concorrência sob carga e muitas trocas de contexto causam expulsão de cache e contenção por toda parte, vai sofrer
Nesse caso, thread por núcleo faz muito sentido, e o próprio framework async talvez nem faça sentido
Não existe uma resposta dogmática sobre o que é “melhor”
É preciso fazer profiling da aplicação
Como já disse antes, sinto que o foco geral de Rust está sendo distorcido pela entrada em massa do desenvolvimento de serviços web
Ainda não tenho certeza se Rust é uma linguagem adequada para esse tipo de trabalho, mas parece funcionar muito bem para eles, então que seja
Só que a discussão pública sobre a linguagem e os crates que estão sendo empurrados para a linha de frente refletem hoje, de modo geral, esse viés
Também é o viés de muitos engenheiros de software deste fórum
Aplicações de sistemas como engines de jogos, bibliotecas de criptografia, kernels, ferramentas de linha de comando, compiladores etc. estão sendo criadas com sucesso sem tocar em async
Mantenho uma grande biblioteca de criptografia e não fui afetado em nada pelo trabalho no lado async
Seria uma pena se, como resultado, a utilidade de Rust para programação de sistemas fosse prejudicada
A própria aplicação tem pouco estado fora das requisições, e os listeners de socket e as conexões com o banco de dados podem ser divididos por thread
O estado restante provavelmente é em grande parte estático entre requisições, então a invalidação de cache não deve acontecer com frequência
Como não há estado compartilhado, lidar com ownership também deveria ser fácil
Não quero dizer se isso é bom ou ruim, mas agora Rust terá de conviver com um fluxo interminável de bibliotecas e frameworks relacionados à web, de qualidade variável
E, como muitas bibliotecas e crates fundamentais adotaram uma abordagem async primeiro, async continuará sendo tema de discussão
Hoje já ficou difícil escrever código síncrono comum para problemas de negócios, a menos que o desenvolvedor comum declare, como diretriz principal do projeto, que não vai usar async
Vejo a exigência do limite
Sendpara permitir a movimentação de tarefas entre threads do executor como uma falha clara do próprio sistema async de RustPorque isso, junto com o problema fundamental do async Drop, impede a implementação de APIs com escopo
Assim como acontece com threads, deveria bastar haver um limite
Sendapenas em funções como spawn ou envio de dados por canaisA abordagem sem compartilhamento geralmente não passa de uma solução de contorno para esconder essa falha
Fixar tarefas opcionalmente a uma thread/núcleo específico tem suas vantagens e é realmente útil em algumas situações, mas essa é uma discussão mais refinada e tem pouca relação com as reclamações dos usuários de async envolvendo
SendÉ um bom texto, e recomendo ler além do título
A frase de que mais gostei foi: “se a maior reclamação é adicionar um limite
Senda algum genérico, é difícil acreditar que essa pessoa esteja fazendo engenharia nesse nível”Edição: concordo totalmente com o comentário de “duped”
Como eu não conhecia o contexto maior dessa discussão, talvez tenha citado essa frase com pressa demais
O trecho “pessoas que não conseguem enxergar bem o estado do sistema podem ter dificuldade para encontrar a melhor forma de garantir que um future seja
Send” não soa um pouco arrogante?O problema das restrições de lifetime
'statice deSend/Syncé algo amplamente sentido pelos desenvolvedores, e não fiquei com a impressão de que eles sejam burrosAs pessoas dizem que não fazer roubo de trabalho é mais fácil e mais rápido
Meu argumento é que é uma de duas coisas
Para que a abordagem sem compartilhamento seja mais rápida, é preciso projetar o código de uma forma que não é mais fácil do que tornar uma arquitetura com estado compartilhado thread-safe
No parágrafo seguinte há uma frase paralela correspondente a “lento”
Não acho que pessoas com dificuldade para fazer Rust paralelo e concorrente compilar sejam burras
Só não gosto quando elas agem como se a API que criamos tivesse arruinado a vida delas
Se é algo que você teria de fazer de qualquer jeito, então não é mais difícil
Em vez de “as pessoas reagem de forma exagerada a uma tarefa trivial”, é mais no sentido de que async faz você resolver mais cedo um problema que teria de ser resolvido algum dia
Parece um pouco com o borrow checker
Às vezes ele é restritivo demais, mas às vezes havia de fato um caso de borda que você não considerou ao assumir que estava tudo bem
Sendou nãoPor isso a frase citada fez sentido para mim
Este é um texto que perde a visão geral e se prende aos detalhes
Não existe um método sempre correto para obter o melhor desempenho em todos os programas
Dá para discutir à vontade, mas os prós e contras de thread por núcleo são o típico “depende”
O problema é que, em primeiro lugar, o próprio uso de
asyncé uma otimização prematura99% dos programas em Rust não são coisas como redis ou linkerd
São ferramentas CLI ou apps web que seriam rápidos o suficiente mesmo escritos em Python ou Ruby
Então fico me perguntando por que a comunidade abandonou Rust com I/O bloqueante, por que tudo virou async, e por que os desenvolvedores estão colocando
#[tokio::main]em tudo como se fosse o padrãoHá linguagens que oferecem uma experiência de programação melhor em troca de velocidade, e Python é um exemplo disso
Se você quer usar Rust, provavelmente precisa de desempenho extra e, já que aceitou uma linguagem menos confortável, também pode aceitar um estilo menos confortável em troca de desempenho melhor
Nunca usei Rust, mas entendo a reclamação
Acho que seria irritante ter de escrever o código de uma forma especial para que o estado possa ser movido entre threads, em nome de uma redistribuição de carga que talvez nem seja necessária e que, numa situação com muita folga de CPU, pode até aumentar a latência ponta a ponta de uma única requisição
Se fosse uma plataforma em que estado movível fosse o padrão e quase nunca quebrasse, essa abordagem poderia fazer sentido, mas em Rust não parece ser assim
O que me deixa curioso é como isso é na prática
Quero saber se é mais perto de “o código não compila se você não adicionar o encantamento mágico
Send”, ou de “durante work stealing, o estado se corrompe e falha de modo intermitente e difícil de depurar”Existe um
Servere, emserve, ele lê mensagens e então dáspawn(async move { ... })em cada handler de mensagem como uma nova taskNo começo, tudo funciona bem
Aí um dia você muda a implementação de
do_thise o tipo dethisdeixa de serSend; entãospawn(...)passa a gerar um erro de compilação assustador dizendo que o tipo criado pelo escopo anônimoasync move { }não éSendO motivo nem sempre é claro, e a mensagem de erro não ajuda
Se
thisnão forSend, ele não pode ser mantido atravessando o.awaitdedo_that(arg).awaitIsso porque cada
.awaité um ponto de execução em que a future pode ceder e ser agendada em outra thread pelo executorSe você conseguir tornar o tipo
Send, tudo bemMas há muitos casos em que isso não é possível, e então é preciso mudar o escalonamento da future para algo como
spawn_localPara chamá-lo, talvez seja necessário adicionar bastante boilerplate
Esse é o problema de
SendNão é simplesmente uma questão de adicionar uma anotação de tipo; como nem sempre fica claro se um tipo implementa
Send, isso pode se infiltrar sutilmente no código e quebrar depois de uma forma pouco óbviaSendeSyncSe você escrever o programa de forma thread-safe, não há problema
O ponto principal é este
As pessoas reclamam que o async do Tokio é difícil porque exige
SendeSyncpor toda parte, mas, na prática, escrever código concorrente seguro de qualquer tipo já é difícil por si sóNão é intuitivo, e o problema é que async faz parecer que isso é “resolvido” automaticamente
Mas, na realidade, não é
Você precisa saber o que está fazendo, e o compilador apenas ajuda nisso
Com thread por core, dá para ocultar a necessidade de
Sendem algumas situações, mas não em todasE, no longo prazo, isso pode voltar para te morder do ponto de vista arquitetural
Send/Syncnos lugares em que isso é necessárioPosso estar errado, mas o jeito preguiçoso de conseguir isso geralmente é envolver as coisas que podem ser compartilhadas em
ArcouMutexSe a carga for centrada em CPU, work stealing provavelmente será melhor na maioria dos casos
Se for centrada em I/O, thread por core pode funcionar melhor, mas, de qualquer forma, há folga de CPU suficiente, então o desempenho não importa tanto
Na minha opinião, work stealing é um padrão melhor para colocar na API da linguagem