1 pontos por GN⁺ 2023-10-08 | 1 comentários | Compartilhar no WhatsApp
  • 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 + 'static ou Send + 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 Send a 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

 
GN⁺ 2023-10-08
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 + 'static vai continuar usando

    • Sendo assim, se você não precisa de fato de uma escala C10M, não seria melhor simplesmente usar uma thread por conexão e não usar algo mais complexo?
      Imagino 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
    • Acho o debate async/await versus corrotinas com stack mais interessante, mas o debate deste texto não é esse
      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_...
    • Existem vários temas no mundo sobre os quais as pessoas discordam
      Este texto apenas trata de outro debate, não daquele que eu gostaria que tivesse sido escrito
    • Ao usar async/await junto com canais, dá para restringir os argumentos de funções async para que recebam apenas referências imutáveis ou tipos copiáveis, e fazer com que tipos compartilhados mutáveis se comuniquem por canais
      Também dá para criar servidores ao estilo Erlang que possuem tipos mutáveis e se comunicam por canais, ou seguir em frente usando Arc
      Rust dá a você o poder de fazer ambos
    • Pelo que entendo, não há nada que impeça o uso de canais sobre o suporte async existente de Rust
      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

    • O principal motivo pelo qual frameworks async em Rust usam roubo de trabalho parece ser que é fácil ativá-lo no nível do framework e que ele melhora o desempenho em muitas aplicações, especialmente nas que não foram projetadas de forma ideal
      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
    • Fiquei curioso sobre o que é essa ideia de push de dados
  • 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 Arc e Mutex
    Essa 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

    • Já escrevi serviços assim, mas eu não os chamaria de centrados em I/O
      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
    • Exato. Nem tudo que quer concorrência é um servidor web
      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
    • tokio oferece suporte a um executor de thread única para quando isso é realmente necessário, e nem é difícil
      Na API do tokio, ele se chama LocalSet
      https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
    • Eu estava prestes a escrever sobre a mesma citação
      Mesmo que uma única thread em uma única CPU seja suficiente, você ainda pode querer concorrência
    • Em vez de Arc e Mutex, você acabaria usando Rc e RefCell, 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 demais
    Não acho que remover Send + Sync faça uma diferença tão grande assim
    O que mais incomoda é 'static, e isso não é por causa de work stealing
    O 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 bt
    No 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

    • Java recebe muitas críticas, mas essa parte simplesmente funciona bem há uns 25 anos e foi imensamente útil para resolver problemas
      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/uniq para encontrar hotspots
    • Para mitigar esse tipo de problema, na empresa usamos uma biblioteca que registra periodicamente quais tasks estão em execução e em que arquivo/número de linha cada task está no momento
      É preciso espalhar manualmente r.set_location(file!(), line!()); antes de cada ponto de await, 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(), e task.rs tem list_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

    • Não vi muito o problema de async afetar outras partes de Rust
      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
    • Concordo com a entrada de desenvolvedores web
      Seria uma pena se, como resultado, a utilidade de Rust para programação de sistemas fosse prejudicada
    • Eu achava que apps web, pelo contrário, seriam ideais para thread por núcleo
      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
    • Acho que isso aconteceu porque a comunidade Rust atraiu esses desenvolvedores de forma bastante ativa para ganhar mercado e reconhecimento
      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 Send para permitir a movimentação de tarefas entre threads do executor como uma falha clara do próprio sistema async de Rust
    Porque 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 Send apenas em funções como spawn ou envio de dados por canais
    A 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 Send a 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 'static e de Send/Sync é algo amplamente sentido pelos desenvolvedores, e não fiquei com a impressão de que eles sejam burros

    • Estou me referindo ao que foi dito antes
      As 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
    • “Difícil” está entre aspas porque não é necessariamente mais difícil
      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
    • Pessoalmente, às vezes também tenho dificuldades reais para entender se meu estado é Send ou não
      Por 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 prematura
    99% 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ão

    • Um motivo pode ser que, se desempenho com thread por núcleo é suficiente, talvez você provavelmente nem queira usar Rust em primeiro lugar
      Há 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”

    • Digamos que você escreva algo mais ou menos assim
      Existe um Server e, em serve, ele lê mensagens e então dá spawn(async move { ... }) em cada handler de mensagem como uma nova task
      No começo, tudo funciona bem
      Aí um dia você muda a implementação de do_this e o tipo de this deixa de ser Send; então spawn(...) passa a gerar um erro de compilação assustador dizendo que o tipo criado pelo escopo anônimo async move { } não é Send
      O motivo nem sempre é claro, e a mensagem de erro não ajuda
      Se this não for Send, ele não pode ser mantido atravessando o .await de do_that(arg).await
      Isso porque cada .await é um ponto de execução em que a future pode ceder e ser agendada em outra thread pelo executor
      Se você conseguir tornar o tipo Send, tudo bem
      Mas há muitos casos em que isso não é possível, e então é preciso mudar o escalonamento da future para algo como spawn_local
      Para chamá-lo, talvez seja necessário adicionar bastante boilerplate
      Esse é o problema de Send
      Nã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 óbvia
    • Em geral, o compilador detecta se algo é Send e Sync
      Se 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 Send e Sync por 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 Send em algumas situações, mas não em todas
      E, no longo prazo, isso pode voltar para te morder do ponto de vista arquitetural
    • O código não compila a menos que tudo seja Send/Sync nos lugares em que isso é necessário
      Posso estar errado, mas o jeito preguiçoso de conseguir isso geralmente é envolver as coisas que podem ser compartilhadas em Arc ou Mutex
    • Acho que o argumento contra adotar thread por core como padrão pode ser simplificado
      Se 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