2 pontos por GN⁺ 2023-09-09 | 1 comentários | Compartilhar no WhatsApp
  • O async/await do Rust mira a concorrência em larga escala para lidar com dezenas de milhares de conexões, mas entra em conflito com os objetivos do Rust de controle de baixo nível e verificação estática de lifetimes, criando uma experiência de desenvolvimento diferente do Rust “normal”
  • Threads e canais bastam para muito software, mas em escalas como C10K o custo do modelo de uma conexão por thread fica alto, exigindo tasks em espaço do usuário e escalonamento pelo runtime
  • Em Rust async, os dados precisam ser movidos como Send ou tratados por referências 'static, e por causa da natureza contagiosa de async essas restrições se repetem por todo o código
  • Arc pode resolver problemas de compilação, mas também torna menos claros os lifetimes de objetos e recursos, enquanto continuam surgindo armadilhas como async recursivo, a diferença entre future e task, e chamadas blocking que travam threads do runtime
  • Em Haskell ou Go, “código async” funciona como código comum e o runtime com GC esconde as diferenças, então nesse tipo de programação o controle explícito do Rust pode não funcionar apenas como vantagem

Por que concorrência e paralelismo são necessários

  • Programas rápidos têm duas exigências ao mesmo tempo
    • precisam usar vários núcleos de CPU para aproveitar o computador inteiro
    • precisam continuar fazendo outras coisas enquanto esperam operações lentas, como transmitir mensagens pela internet ou abrir arquivos
  • Paralelismo é o problema de executar código ao mesmo tempo em várias CPUs
  • Concorrência é uma forma de dividir um problema em partes independentes
  • Os dois não são a mesma coisa, mas quando um programa é dividido em partes concorrentes, essas partes podem ser executadas em paralelo e manter os núcleos ocupados

Processos, threads e canais

  • Uma forma simples de construir um sistema concorrente é dividir o código em vários processos
    • o escalonador do sistema operacional executa fatias de tempo dos processos prontos nos núcleos de CPU disponíveis
    • esse modelo também é usado quando comandos de shell são conectados por pipes
  • A abordagem com processos tem alto custo de comunicação entre processos
    • em muitas implementações, é preciso copiar os dados para a memória do SO e depois trazê-los de volta
    • esse custo pode ser reduzido com memória compartilhada, mas isso enfraquece a vantagem de o SO isolar os processos uns dos outros
  • Threads compartilham a mesma memória e evitam esse overhead, mas o uso incorreto de ferramentas de sincronização como mutex, condition variable e semaphore pode causar corrida de dados e deadlock
  • O modelo Communicating Sequential Processes de Tony Hoare conecta threads com filas ou canais
    • as threads não compartilham memória, obtendo um isolamento semelhante ao dos processos
    • a entrada e a saída de cada thread ficam expostas por canais, o que facilita raciocínio e depuração
    • os próprios canais fazem o papel de sincronização: se estiverem vazios, o receptor espera; se estiverem cheios, o emissor espera
  • A biblioteca padrão do Rust inclui std::sync::mpsc::sync_channel
  • Para muito software, threads e canais, junto com ferramentas como Rayon para paralelizar loops intensivos em CPU, já são suficientes

Concorrência em espaço do usuário e Rust async

  • Em problemas C10K, como um servidor web com dezenas de milhares de usuários conectados ao mesmo tempo, o modelo de associar uma thread a cada conexão encontra limites
    • no Linux, cada thread tem um bloco de controle de 4 kB, e a troca de threads exige um context switch que entra no escalonador do sistema operacional
  • Para concorrência em larga escala, algumas linguagens criam e gerenciam tasks no espaço do usuário
    • o runtime agenda essas tasks em um pool de threads do SO
    • normalmente, o pool é configurado para ter uma thread por núcleo de CPU, maximizando o paralelismo
    • essa abordagem também é chamada de green thread, lightweight thread, lightweight process, fiber ou coroutine
  • O Rust usa o modelo async/await visto em C# e Node.js
    • uma async fn não retorna diretamente um valor, mas sim um future ou promise cujo resultado é obtido com .await
  • Os futures do Rust são muito pequenos e rápidos graças ao escalonamento cooperativo e ao design stackless
  • O Rust tenta oferecer a abstração de futures sem abrir mão de prometer controle de baixo nível ao programador
    • tenta verificar estaticamente em tempo de compilação o lifetime de todos os objetos e referências
    • um future divide o código e os dados referenciados por esse código em milhares de partes, que podem ser executadas a qualquer momento e em qualquer thread dependendo de condições conhecidas só depois do início da execução
    • um future que lê dados do cliente só deveria ser executado quando houver dados para ler naquele socket, mas anotações de lifetime não informam esse momento
  • O Rust não embute um runtime de futures na linguagem e deixa isso para bibliotecas como Tokio
    • o usuário ganha liberdade para escolher alternativas adequadas ao seu ambiente
    • ainda assim, mesmo imaginando um mundo em que Tokio fosse embutido na linguagem, as mesmas regras se aplicariam, então esse ponto é um detalhe secundário neste argumento

A pressão criada por Send, 'static e Arc

  • Para convencer o compilador, os dados precisam ser movidos com Send ou passados por referências com lifetime 'static
  • Em código async, é comum que várias tasks compartilhem estado, então mover dados sem cópia muitas vezes não se encaixa
  • Referências também são difíceis, e não existe um equivalente a thread::scope que limite o lifetime de um future a algo menor que “para sempre”
  • async é contagioso, então qualquer função que chame uma função async também precisa ser async
    • por isso, esses problemas de lifetime e mobilidade não precisam ser resolvidos só em algumas funções, mas repetidamente por todo o código
    • dá para quebrar a cadeia esperando a conclusão de um future em runtime com block_on, mas isso não é composicional e, se for aninhado, pode fazer o runtime entrar em panic
  • Arc é a ferramenta para lidar com lifetimes dinâmicos entre várias threads, permitindo passar pelo borrow checker e compilar o código
  • Mas o uso amplo de Arc também torna menos claros os lifetimes de objetos e recursos
    • não fica claro quando recursos como memória, arquivos e sockets serão liberados
    • acabam surgindo perdas parecidas com as de GC sem ganhar as vantagens reais de um GC, como throughput de allocation, baixa fragmentação e evitar vazamentos por ciclos

Armadilhas adicionais do Rust async

  • As coroutines do Rust são stackless, então o compilador transforma cada coroutine em uma máquina de estados que avança até os pontos de .await
    • funções async recursivas se tornam tipos definidos recursivamente
    • quem quer simplesmente chamar a si mesmo precisa fazer boxing manual ou usar uma crate como async-recursion
  • Um future não faz nada até ser aguardado com await
  • Uma task começa a trabalhar em um pool de threads do runtime e retorna um future que sinaliza a conclusão
  • Não existe nenhum mecanismo que impeça chamar código blocking dentro de um future
    • nem que impeça que essa chamada bloqueie a thread do runtime em que ele estiver rodando
    • isso entra em conflito com o objetivo central de usar async

A diferença entre Rust comum, Haskell e Go

  • O Rust async tem um sabor bem diferente do Rust “normal”
    • há mais armadilhas
    • é mais difícil de entender e de ensinar
  • O usuário acaba entre duas opções
    • entender profundamente como a abstração realmente funciona e escrever código complexo
    • espalhar elementos como Arc, Pin e 'static pelo código todo e torcer para dar certo
  • Mesmo equipes experientes podem querer usar Rust em um projeto novo e acabar travadas nesses detalhes
  • Em Haskell e Go, “código async” é código comum
    • as duas linguagens escondem a diferença entre código blocking e non-blocking atrás de um runtime robusto
    • problemas de lifetime ficam a cargo do garbage collector
  • Nesse tipo de software de espaço do usuário com concorrência em larga escala, a forma como o runtime e o GC escondem essas diferenças funciona como uma vantagem direta
  • Talvez o Rust não seja uma boa ferramenta para software de espaço do usuário com concorrência em larga escala, e possa ser melhor usá-lo em projetos que não tenham esse tipo de exigência

1 comentários

 
GN⁺ 2023-09-09
Opiniões no Hacker News
  • Estou escrevendo um cliente de metaverso de alto desempenho em Rust, atualmente com cerca de 40 mil linhas
    O vídeo de demonstração está em https://video.hardlimit.com/w/tp9mLAQoHaFR32YAVKVDrz
    Um metaverso de verdade precisa processar conteúdo criado por usuários quase em tempo real; por isso exige 2 a 3 vezes mais VRAM do que jogos semelhantes, centenas de Mbps de largura de banda para carregar assets do servidor, vários CPUs e Vulkan para fazer renderização e upload para a GPU em paralelo
    Isso não é uma concorrência em “escala web”, com pequenos servidores rodando separadamente no mesmo espaço de endereçamento, mas sim uma arquitetura em que trabalham juntos uma thread de renderização de alta prioridade, uma thread de atualização de eventos de rede, threads de carregamento e descompressão de assets e várias threads responsáveis por objetos em movimento, LOD, limpeza de cache etc.
    Em Rust, uso bastante locking sem estado global além de constantes, uso canais onde faz sentido, e a árvore principal de objetos fica sob propriedade única e é tratada principalmente pela thread de atualização. As conexões de objetos gráficos são gerenciadas com contagem de referências por Arc, e meshes e texturas são enviados para a GPU via Rend3/WGPU/Vulkan
    Se eu tivesse feito isso em C++, estaria lutando contra crashes o tempo todo; em Rust, crashes relacionados a memória acontecem mais ou menos uma vez por ano, e em geral eram culpa de código unsafe de terceiros. No meu código, unsafe é proibido; compilar é difícil, mas, quando compila, tende a “simplesmente funcionar”, o que considero muito melhor do que depurar concorrência
    Também tenho reclamações. Rust é forte contra data races, mas não impede deadlocks, então seria necessário um analisador estático que rastreasse a ordem de locks ao longo dos caminhos de chamada. async não combina com tarefas centradas em computação e várias threads com prioridades diferentes, mas continua se infiltrando como dependência. Estruturas comuns com propriedade única e referências reversas ficam difíceis demais sem Rc e Weak, e o sistema de traits também é complexo, gerando código duplicado nas partes de processamento de assets em que orientação a objetos seria natural
    As crates centrais de gráficos ainda não estão maduras. A frase “Rust tem 5 jogos e 50 motores de jogo” não é um problema da linguagem, mas do ecossistema; mesmo comparando com https://gamedev.rs/, ainda parece faltar desenvolvimento sério de jogos em Rust. Para desenvolvimento profissional de jogos com cronograma, o ecossistema de jogos em Rust ainda não está pronto; eu diria que precisa de algo como mais 1 ano de trabalho de umas 5 pessoas

    • Nos últimos 3 anos, fiz um simulador de robôs em Rust e tive quase a mesma experiência. Em 3 anos, houve só uns 5 bugs reais de runtime; embora Rust e async tenham seus problemas, no geral as vantagens são muito maiores
    • Rastrear a ordem dos locks para encontrar possíveis deadlocks parece uma boa ideia
      Como o lockdep do Linux, daria para analisar quais locks são adquiridos enquanto outro lock já está retido e avisar sobre combinações perigosas antes que o programa realmente trave. Em locks complexos, talvez fossem necessárias anotações como “esta classe de lock é sempre adquirida em ordem de endereço”, mas parece implementável
    • Estou fazendo praticamente a mesma coisa em um MMO com Java, e o JDK torna tudo muito fácil. Basta criar modelos a partir da rede e mover objetos para a thread de UI por meio de filas concorrentes; é simples a ponto de ser meio entediante, e ainda assim rápido
    • Rust não elimina condições de corrida; ele elimina data races
      Condições de corrida ainda podem ocorrer fora do acesso a dados: https://news.ycombinator.com/item?id=23599598
    • A questão de prioridade pode ser resolvida com relativa facilidade
      Você pode criar vários pools de threads e rotear futures para o lugar adequado, ou escrever seu próprio event loop e consumir de várias filas de eventos com prioridades diferentes. A segunda abordagem, se o tempo de execução das tarefas for limitado, permite dar garantias soft real-time às tarefas de alta prioridade enquanto mantém o progresso das de baixa prioridade mesmo com a CPU a 100%
  • Sobre Rust async, há um ponto curioso
    Se você usa um monte de Arc, RwLock e estado compartilhado, a coisa fica bagunçada, e é correta a crítica de que, especialmente quando 'static começa a se espalhar por todo lado, ele infecta tudo como funções coloridas. Antigamente, tentei simplesmente colocar Arc e lidar de forma esperta com empréstimos e lifetimes, e virou uma confusão
    Mas Rust também tem canais. O código que escrevo hoje, em sua maior parte, tem algumas tasks atendendo canais, olhando as mensagens recebidas e, quando necessário, colocando mensagens a serem enviadas a outras tasks nos canais apropriados. Não compartilho objetos. Se várias tasks precisam de um objeto grande, deixo-o dentro de uma task que responde a consultas relacionadas por mensagens, ou faço cada task criar sua própria cópia a partir do fluxo de mensagens
    Mesmo assim, há textos demais sobre como usar Arc e como lidar com lifetimes. Isso pode ser necessário se você estiver implementando um runtime async, mas não entendo bem por que um usuário médio de biblioteca deveria se concentrar tanto nisso

    • A crítica me parece um pouco estranha. async não significa necessariamente multithreading; se o async roda na mesma thread, não há compartilhamento, então também não é preciso colocar palavras-chave mágicas em tudo que será compartilhado
      Ao cruzar threads, em vez de manter um monte de estado compartilhado, envie sinais por canais. Se houver um estado global realmente necessário, crie uma pequena struct que encapsule mecanismos de acesso exclusivo como Arc/RwLock, e faça com que, para o chamador, pareça uma simples chamada de função
      Também não entendo muito a preocupação com Send+Sync. Pela minha experiência, a maioria das coisas é facilmente Send+Sync, e as que não são ou não deveriam ser assim ou não poderiam ser. Às vezes quero escrever código sem pensar nos detalhes, mas, quando é preciso concorrência e paralelismo eficientes, microssegundos e throughput passam a importar; aí é preciso escrever código para o computador real corretamente
    • O paradigma de passagem de mensagens é muito bom, e linguagens como Erlang mostraram que ele é uma excelente escolha para sistemas distribuídos
      Mas codar desse jeito é bem diferente do JavaScript async, que parece código síncrono com green threads adicionadas. As pessoas tentam escrever código do jeito a que estão acostumadas, e acho que por isso acabam caindo no caminho de Arc e RwLock em Rust
    • Smalltalk e o sonho da verdadeira orientação a objetos ainda estão vivos
    • Na faculdade, aprendi este conselho com um professor, e ele me ajudou muito
      Estruturar o problema como dados fluindo entre tarefas, conectadas por filas, evitando estado compartilhado, é uma forma melhor de lidar com multithreading em qualquer linguagem
    • Como disse um programador sábio: “Não se comunique compartilhando memória; compartilhe memória por meio da comunicação”
  • async é, na prática, um Rust muito mais difícil, e é uma pena que tenha acabado sendo praticamente imposto a todos, embora os projetos que realmente precisam dele devam ser algo como 1%
    Ainda assim, nesse 1% ele é realmente excelente. Em serviços que têm como núcleo processar uma enorme quantidade de chamadas de rede, como linkerd ou nginx, em jogos que rodam um número imenso de tarefas leves, ou em sistemas embarcados que precisam de concorrência cooperativa, async Rust se torna uma arma poderosa
    A maior parte do código em nível de sistemas e aplicações não precisa de E/S assíncrona. Para apps REST, um pool de threads basta; e, mesmo quando async é necessário, o normal é limitá-lo a uma pequena parte, como rede, e conectar o restante com threads e canais em um modelo híbrido
    A comunidade Rust usou async de forma indiscriminada demais em todo lugar, e o Rust com E/S bloqueante, que oferece uma experiência de usuário melhor, virou cidadão de segunda classe no ecossistema. Também há vários frameworks web assíncronos bem projetados, como Axum e Warp, mas no lado bloqueante as opções são muito mais limitadas, como tiny_http, rouille e astra

    • O ponto central é que Rust implementou corrotinas da forma errada
      Ao optar por corrotinas sem stack, surgiram async/await e o problema das funções coloridas, além do atrito mencionado no texto. Go não tem esse problema porque usa corrotinas com stack
      No início, Rust também considerou corrotinas com stack, mas concluiu que isso exigiria um runtime preemptivo de corrotinas e teria custo alto, então seguiu com o modelo sem stack. Só que a maioria das pessoas não usa async Rust sem runtime; usa Tokio, e Tokio faz, na prática, quase tudo que o runtime que se queria evitar faria
      Assim, muitos usuários de async Rust acabam ficando com o pior dos dois mundos. No mundo embarcado, há quem use async Rust com um runtime bem fino, mas eles são poucos e nem mesmo todos estão totalmente convencidos
    • Vi Tokio entrando de novo como dependência no meu programa. Eu nem o uso diretamente, mas uma função que não uso em algum crate traz reqwest, que traz h2, que por sua vez traz tokio
    • Fico me perguntando se há motivo para usar async quando a plataforma oferece threads virtuais
      Como alguém que usa Java, estou tentando abandonar todo o paradigma assíncrono e reescrever o código em um modelo bloqueante sobre threads virtuais, em que bloquear é aceitável
  • async se espalhou por crates demais, então o programa inteiro acaba virando async ou, no mínimo, passando a depender de Tokio para muita coisa
    Se você quer um servidor web, parece ser async + tokio ou vá embora; e, se não quiser conectores SQL assíncronos, a sensação é que terá de escrever os seus próprios. Cada um resolve os problemas trazidos por async de um jeito diferente, e coisas como closures async dão a impressão de abrir os portões do inferno dentro do compilador
    É bom que o próprio Rust e o compilador ajudem a resolver problemas, mas um ecossistema que chega perto de “ou é async ou faça você mesmo” não é suficiente

    • Se houvesse primitivas assíncronas melhores na biblioteca padrão ou no crate futures, muito sofrimento poderia ter sido reduzido
      Seriam necessárias coisas como traits que os executores devem implementar, ou um executor bloqueante básico para executar código assíncrono a partir de código síncrono. Hoje, só criar uma biblioteca que suporte vários runtimes assíncronos já é um suplício; no fim, as pessoas acabam dando suporte apenas a Tokio ou, no máximo, adicionando async-std
  • Não sou especialista em Rust async, mas, depois de escrever alguns milhares de linhas de Rust síncrono neste mês, minha impressão é que, quando o rustc dificulta alguma abordagem, geralmente há um bom motivo para isso e existe um caminho melhor para obter um resultado parecido
    Se você está aprendendo a linguagem, eu recomendaria primeiro se acostumar com código síncrono comum, laços, condicionais e regras de empréstimo. async ainda está evoluindo muito, não só na implementação, mas também em um nível filosófico: “o que é o assíncrono e como ele deve aparecer para o usuário?”
    O compilador depende muito de traits, mas os recursos das traits para lidar com async ainda não foram estabilizados. Há, por exemplo, trabalhos como https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-i...
    Se os recursos assíncronos em traits ainda não foram estabilizados, atacar o código assíncrono em Rust por ainda não ser bonito é parecido com criticar o rascunho inicial de um livro que um dia ficará pronto

    • Tenho curiosidade sobre o que seria um “bom design de API assíncrona”. Se fôssemos projetar um servidor totalmente centrado em assíncrono, mas escalável, manutenível e fácil de entender, como ele deveria ser?
      Também fico pensando em como impedir que o assíncrono se espalhe por toda a base de código
      A ideia atual é uma estrutura em que uma thread de E/S divide os eventos do sistema vindos de liburing ou epoll em duas etapas, “submit” e “handle”, e os envia para outros componentes. Por exemplo, ao criar uma tcp-connection, seria possível assinar eventos assíncronos como “pronto para escrita” e “pronto para leitura”; o evento de pronto para escrita retiraria dados de um buffer preenchido por um mutex comum e os enviaria via EPOLLOUT/io_uring_prep_writev
      Para entregar eventos entre threads, poderia ser usado um ring buffer de múltiplos produtores e múltiplos consumidores no padrão LMAX Disruptor. Threads da aplicação ou pools de threads teriam seus próprios loops de eventos e processariam esse ring buffer
      Também estou trabalhando em uma sintaxe para expressar a ordem de disparo de eventos assíncronos; ela se parece com um pipeline de Bash e eu a chamo de statelines: initialstate1 initialstate2 = state1 | {state1a state1b state1c} {state2a state2b state2d} | state3
    • Se não foi estabilizado, então não deve ser usado em produção
    • Acho engraçados os comentários que pressupõem que o autor é iniciante em Rust. Pelo contrário, pode ser que ele tenha mais experiência do que essas pessoas
  • A vida útil de um Arc não é algo desconhecido; ela é determinada por onde e como ele é mantido
    A dissonância deste texto parece vir do fato de o autor tentar aplicar à força ao Rust um modelo mental anterior, como o de garbage collection, em vez de aprender Rust e trabalhar de acordo com a linguagem. É uma armadilha comum ao aprender uma linguagem nova, mas Rust faz as pessoas tropeçarem nela com ainda mais frequência

    • Nesse sentido, até a vida útil de objetos em sistemas com garbage collection tem um limite inferior: “enquanto houver referências”
      Mas isso é praticamente o oposto do objetivo do borrow checker, que é restringir estaticamente a vida útil dos objetos em tempo de compilação
      Na prática, foi quase o contrário. Depois de fazer programação de sistemas em C, C++ e Rust por cerca de 10 anos, passei a usar bastante Haskell no meu emprego atual, e foi bem revelador perceber que runtimes grandes de linguagem e garbage collection não são monstros em certos domínios de problema
    • Boa parte da crítica tem esse tom. Achei que seria um texto sobre como a transformação async atrapalha otimizações que o compilador consegue fazer em código não assíncrono
      O trecho sobre brigar com Weak parece indicar uma tentativa de criar uma estrutura de ownership complexa, algo que não é fácil em Rust de modo geral. Eu uso smart pointers fracos muito raramente
      Canais quase não são mencionados, mas são a principal ferramenta para fazer partes diferentes do programa se comunicarem em código assíncrono ou na ponte entre código assíncrono e síncrono. Também há abstrações de sinalização como Notify e semáforos
      Mutexes são lentos e tendem a virar gargalos, e estado compartilhado rapidamente fica complexo. Isso é conhecido há muito tempo. O problema pode estar, desde o início, em uma estrutura como BIG_GLOBAL_STATIC_REF_OR_SIMILAR_HORROR
      A observação de que não se consegue impedir chamadas a código bloqueante em um contexto assíncrono é válida, mas, se necessário, isso é relativamente gerenciável com algo como tokio::spawn_blocking
    • Contagem de referências também é um tipo de garbage collection: https://en.wikipedia.org/wiki/Garbage_collection_(computer_s...
      É provável que o autor saiba o que é Arc e como ele funciona; o ponto parece ser mais que, em Rust async, acaba-se usando Arc com muito mais frequência no lugar de RAII comum do que em código síncrono
      Se 90% dos objetos do programa são contados por referência, talvez seja melhor usar garbage collection com rastreamento do que pagar o custo de muitas pequenas alocações/liberações no heap e operações atômicas. O exemplo do tutorial do Tokio aponta em uma direção parecida: https://tokio.rs/tokio/tutorial/shared-state
      Fico curioso se um garbage collector de rastreamento real em Rust poderia tornar aplicações assíncronas comuns, como servidores HTTP, significativamente mais rápidas: https://manishearth.github.io/blog/2015/09/01/designing-a-gc...
    • A vida útil de um Arc não é aleatória; ela é estaticamente desconhecida
    • O Arc do Rust pode ser movido ou emprestado, e também pode ser usado sem tocar no contador de referências
      Em muitos casos, ele é muito mais barato do que objetos em linguagens com contagem de referências implícita
  • Gosto de Rust, mas async é uma bagunça, e não dá para escrever código assíncrono como se escreve código síncrono
    Estou cada vez mais convencido de que misturar os dois é uma má ideia, e talvez a abordagem ao estilo Go, mantendo tudo síncrono e oferecendo apenas uma primitiva de canal async, seja a correta
    No momento estou encanando a lógica para chamar métodos síncronos a partir de uma struct que implementa Future, e é um desafio bem interessante. Talvez dê para tornar abstrações assíncronas de custo zero relativamente fáceis para o usuário, mas a dor acaba ficando com o desenvolvedor de bibliotecas

    • Não concordo com a parte final. async definitivamente também é doloroso para usuários finais, e parece que se está usando uma linguagem separada sem recursos centrais do Rust, como lifetimes e tipos explícitos, tudo ainda salpicado de Pin
      Como não dá para executar fibers com escopo, acaba-se colocando Arc em tudo; Pin é difícil de usar sem unsafe; e uma mudança minúscula em uma função assíncrona pode tornar os futures de toda a base de código !Send
    • Desenvolvedores de bibliotecas têm mais condições de lidar com complexidade do que usuários. Faz sentido deixar esse trabalho para desenvolvedores experientes que constroem a infraestrutura de base
    • Vi um caso de uma VM wasm para Rust que oferece algo parecido com escalonamento M:N transparente, e esse tipo de abordagem parece capaz de resolver a maior parte das dificuldades de async. Resta ver como isso vai evoluir
  • Async Everything é uma linguagem ruim
    async/await foi uma ideia terrível para corrigir o problema de o JavaScript não ter threads bloqueantes de verdade, e agora está sendo enxertado em todas as linguagens. Vai dividir a linguagem e o ecossistema de bibliotecas em dois e causar sofrimento por muito tempo
    Quem já fez multithreading fora do JavaScript sabe que atores ou processos sequenciais comunicantes são a melhor forma de lidar com multithreading
    A tese de Joe Armstrong também explica que a única maneira de entender programas multithread é escrever código estritamente sequencial para cada thread e não misturar o código de várias threads em um só lugar. Para minimizar a lacuna conceitual, uma atividade concorrente real do problema deve corresponder exatamente a um processo concorrente da linguagem de programação: https://erlang.org/download/armstrong_thesis_2003.pdf
    A crítica de Ron Pressler, que implementou o Project Loom do Java, a async/await também é boa: https://www.youtube.com/watch?v=oNnITaBseYQ

    • É divertido odiar JavaScript, mas é interessante rever a apresentação em que Ryan Dahl introduziu o Node.js pela primeira vez: https://www.youtube.com/watch?v=EeYvFl7li9E
      Ele era bastante ambivalente em relação ao JavaScript em si; o objetivo principal era encontrar uma abstração para lidar com o loop de eventos de E/S do epoll() sem dar vontade de enfiar os dedos nos olhos. Antes disso, ele tentou muitas outras abordagens
    • async/await na verdade começou em C#, não em JavaScript
      Anders Hejlsberg, do C#, também criou TypeScript, e recursos do TypeScript como classes, arrow functions e async/await acabaram entrando no ES6+
      Acho que foi uma ótima solução em JS/TS, com seu loop de eventos de thread única. Mas, quanto mais baixo nível é a linguagem, pior ela fica como abstração; por isso, a maior parte da crítica a async Rust feita aqui é válida
  • O texto explica bem a complexidade e a dificuldade do Rust async, mas também é importante lembrar que uma das filosofias centrais do Rust é segurança de memória sem sacrificar desempenho
    Os padrões assíncronos do Rust, especialmente a forma como fazem o compilador garantir a segurança dos dados, demonstram bem essa filosofia. Embora haja complexidade, há valor em um modelo de concorrência mais seguro que força o desenvolvedor a pensar profundamente sobre dados e fluxo de execução
    Rust pode não ser a resposta para todas as grandes aplicações concorrentes em espaço de usuário, mas em sistemas nos quais robustez e segurança são a prioridade, os trade-offs podem se justificar. À medida que o ecossistema evoluir, é provável que surjam mais abstrações e bibliotecas que reduzam essas dores

  • Escrevo bastante Rust lock-free baseado em async. O principal problema é que os futures do Tokio são 'static, e isso vem de um erro de design profundamente enraizado no ecossistema Rust: a decisão de que vazamentos de memória são seguros
    Por causa disso, não é possível garantir estaticamente que um future será devidamente limpo. Quando uma tarefa assíncrona é criada, se alguém esquecer o future com std::mem::forget, o borrow checker não consegue saber que as referências passadas transitivamente por esse future ainda estão vivas
    Em vez de espalhar Arc por todo lado, uso este crate unsafe: https://docs.rs/async-scoped/latest/async_scoped/
    Isso pega 99% dos bugs que eu teria criado em C++, então é um compromisso razoável. Também há trabalho em andamento para implementar futures não-'static de forma segura, e espero que dê certo
    Outro grande problema é que async trait hoje exige futures boxed, o que acrescenta malloc/free a cada fronteira de chamada de função, mas isso está no roadmap para ser corrigido este ano
    O conselho de “é só usar canais” também espalha o fluxo de controle por todo lado em bases de código grandes. Canais parecem uma versão moderna de GOTO; eu também os uso, mas evito quando só preciso executar algumas coisas em paralelo e esperar sua conclusão

    • A distinção importante é que não são os futures do Tokio em si que são 'static, e sim que apenas futures 'static podem ser spawnados para aproveitar a concorrência do runtime
      Para ser poll()ado, um future precisa estar Pin, e um T: !Unpin em Pin no fim precisa ter Drop chamado: https://doc.rust-lang.org/std/pin/#drop-guarantee
      Futures gerados pelo recurso async do compilador têm essa propriedade, e futures manuais também podem incluir PhantomPinned. Graças a isso, depois que foram poll()ados, brincadeiras com mem::forget podem ser consideradas comportamento indefinido, e bibliotecas de futures intrusivos e autorreferenciais se tornam possíveis: https://docs.rs/futures-intrusive/latest/futures_intrusive/
      Um future pode continuar vivo e vazar por causa de Arc/Rc, mas, do ponto de vista de quem desenvolve bibliotecas, isso não é razoavelmente distinguível do uso normal, ou não é algo com que seja tão necessário se preocupar
    • Se você vê como erro de design o fato de vazamentos de memória serem seguros, fico curioso se preferiria remover a mutabilidade interna, remover Rc, ou então colocar fronteiras de traits unsafe infecciosas