4 pontos por GN⁺ 2024-03-26 | 1 comentários | Compartilhar no WhatsApp
  • O async/await do Rust não é apenas um substituto simples para threads, mas um modelo de programação que expressa código concorrente centrado em I/O como máquinas de estado combináveis
  • Códigos que precisam lidar com várias conexões ao mesmo tempo, como um servidor web, rapidamente encontram limites com execução apenas linear, e threads permitem processamento concorrente ao separar o atendimento de clientes com thread::spawn
  • async/await cede a execução em pontos de await, permitindo que o executor continue rodando outras tarefas, intercalando muitas operações dentro de um mesmo runtime
  • Requisitos como um timeout de 3 segundos podem ser adicionados em async com a combinação de race e Timer, enquanto no código síncrono com threads é necessário um wrapper específico para TcpStream e configuração de timeout de leitura/escrita, o que reduz a generalidade
  • Explicar async apenas pelo overhead de desempenho gera contraexemplos em tarefas CPU bound; a força do async em Rust está na expressividade semântica e na combinabilidade do ecossistema

O ponto de partida do problema de concorrência em Rust

  • O código comum em Rust tem, por padrão, uma estrutura de execução linear
    • Como foo(), bar(), baz(), em que uma tarefa termina antes de a próxima começar
  • Quando é preciso processar várias coisas ao mesmo tempo, como em um servidor web, a estrutura linear atinge rapidamente seu limite
    • Em uma estrutura que recebe clientes com TcpListener::accept() e executa handle_client(), o segundo cliente precisa esperar enquanto o primeiro está sendo processado
    • Se handle_client() leva alguns milissegundos e há 2 clientes simultâneos, surge uma pequena espera
    • Se houver 2 milhões de clientes simultâneos, o usuário no fim da fila pode esperar vários minutos

Como threads resolvem isso

  • Threads do sistema operacional podem salvar em memória os valores dos registradores e a pilha do programa, executar outra rotina e depois retomar a rotina original
  • O código de um servidor web delega o processamento do cliente a uma thread separada com algo como thread::spawn(move || handle_client(client))
    • A thread principal continua fazendo accept() de novas conexões
    • Se a thread de processamento do cliente bloquear, o SO pode voltar para a thread principal e aceitar a próxima conexão
    • Dois clientes podem passar a ser executados em paralelo após um atraso da ordem de microssegundos
  • Se um servidor web de produção tiver dezenas de núcleos de CPU, o SO não apenas fará as threads parecerem simultâneas, mas poderá realmente executar várias delas ao mesmo tempo

Como async/await funciona

  • Na concorrência em espaço de usuário há vários modelos, como programação orientada a eventos, atores e corrotinas, e o modelo escolhido por Rust é o async/await
  • Simplificando, o programa é compilado como um conjunto de máquinas de estado que podem ser executadas independentemente
    • async fn não é uma função tradicional, mas uma função que retorna uma máquina de estado
    • await inclui outra máquina de estado como uma etapa da máquina de estado atual
    • Quando uma função interna cede a execução, como ao esperar por uma nova conexão, a máquina de estado inteira devolve o controle ao executor superior
  • Um executor como smol::Executor roda outra máquina de estado criada com spawn no lugar da máquina atual
    • O bloco async move { handle_client(client).await } é uma nova máquina de estado independente de main
    • Quando main cede, uma das tarefas de cliente é executada, e quando ela cede de novo, o executor passa para a próxima tarefa
  • Com essa estrutura é possível lidar com milhões de clientes ao mesmo tempo, mas a complexidade também aumenta com conceitos como executor, task e máquina de estado

A combinabilidade revelada no exemplo de timeout

  • Um dos pontos fortes de Rust é a combinabilidade
    • Iterator permite encadear vários combinadores e passar o resultado de novo para funções que recebem Iterator
    • Como em mpsc::channel() com recv.try_iter().filter(...).map(...), é possível filtrar e transformar valores antes de adicioná-los a uma lista
  • async/await leva essa combinabilidade também para funções I/O bound
  • Se handle_client() for uma função assíncrona que faz await em read_to_end, do_something_with_data e write_all, um timeout de 3 segundos pode ser implementado combinando dois Futures
    • race executa dois Futures ao mesmo tempo
    • Timer retorna após o tempo especificado
    • Basta envolver o código existente de tratamento da conexão em um bloco async e colocá-lo para competir com um Future que retorna um erro TimedOut após 3 segundos
  • Essa abordagem não fica presa apenas a TcpStream
    • O mesmo padrão pode ser aplicado a qualquer tipo que implemente impl AsyncRead + AsyncWrite
    • Também é possível trocar por um stream GZIP sobre um stream comum, sockets Unix ou arquivos

Limitações ao implementar o mesmo timeout em código síncrono com threads

  • Em código bloqueante, em geral é difícil interromper chamadas de sistema read ou write, e métodos como fechar o descritor de arquivo não podem ser usados em Rust
  • TcpStream fornece set_read_timeout e set_write_timeout
    • É possível configurar timeout separadamente para leitura e escrita
    • Mas, se o cliente enviar 1 byte a cada 2,9 segundos, um timeout simples pode continuar sendo reiniciado
  • Para se proteger disso, é preciso criar um tipo como DeadlineStream que encapsule TcpStream e recalcule a cada vez o tempo restante até o deadline total, configurando esse valor nos timeouts de leitura/escrita
  • Essa abordagem pode funcionar, mas tem muitas limitações
    • Fica presa a TcpStream
    • Em Rust não existe uma trait que abstraia o uso de set_read_timeout e set_write_timeout
    • Aplicá-la a um writer genérico exige bastante trabalho adicional
    • Há chamadas de sistema extras para configurar os timeouts
    • Na lógica real de um servidor web, o uso pode ser ainda mais incômodo

Casos do ecossistema async de Rust

  • O fato de o ecossistema HTTP, incluindo clientes, ter adotado async/await como principal mecanismo de runtime se deve à combinabilidade das funções
    • Funções que fazem chamadas HTTP podem ser encaixadas em diversos pontos e casos de uso
  • tower é um exemplo representativo da combinabilidade de async/await
    • Se um serviço for implementado como função async, é possível adicionar timeout, limitação de taxa, load balancing, hedging e tratamento de backpressure
    • Independentemente do runtime usado ou do que o serviço faz internamente, tower pode ser aplicado para aumentar a robustez
  • macroquad é uma engine de jogos pequena para Rust e executa a engine com async/await na função principal
    • async/await é adequado para expressar situações em Rust em que é preciso pausar uma função linear para esperar por alguma tarefa
    • Isso permite configurações como fazer polling da conexão de rede de um servidor de jogo e de um framework GUI ao mesmo tempo na mesma thread

O limite de explicar async apenas por desempenho

  • O Rust Async Book compara que threads do SO facilitam expressar concorrência sem mudar o modelo de programação, mas a sincronização entre threads é difícil, o overhead de desempenho é alto e, mesmo com thread pools, é difícil dar conta de grandes workloads I/O bound
  • Na comunidade async, quando se pergunta por que usar async em vez de threads do SO, há uma tendência de responder algo como “o overhead é menor e o resto é igual”
  • Os autores de servidores web migraram para async/await para resolver o C10k problem, mas isso não significa que o motivo para todo usuário escolher async/await precise ser desempenho
  • A vantagem de desempenho pode desaparecer dependendo da situação
    • Em tarefas CPU bound, um workflow baseado em threads pode ser mais rápido do que um workflow async equivalente
    • A vantagem temporária de desempenho do async em Rust foi superenfatizada, enquanto seus benefícios semânticos foram subestimados
  • async/await não é uma ferramenta apenas para casos de nicho, mas um modelo de programação poderoso para lidar com padrões que seriam difíceis de expressar de forma concisa em Rust síncrono sem dezenas de threads e canais

Em vez de fazê-lo parecer sync Rust, aceitar a diferença

  • No roadmap do projeto Rust existe a direção de que escrever async Rust deveria, às vezes, ser tão fácil quanto escrever código síncrono, exceto pelo uso das palavras-chave async e await
  • Mas também há a visão de que enquadrar async Rust como “igual ao sync Rust” é fundamentalmente difícil
    • Mesmo que se consiga chegar a 99% de semelhança, o usuário médio inevitavelmente perceberá a diferença
  • Em vez de tentar se tornar igual ao Rust síncrono, o ecossistema de async/await em Rust deveria destacar com mais clareza seus pontos fortes em combinabilidade e expressividade
  • Para que async/await se torne a opção padrão quando concorrência for necessária, esse modelo deve ser explicado por razões semânticas, e não apenas por razões técnicas de desempenho

1 comentários

 
GN⁺ 2024-03-26
Opiniões no Hacker News
  • async/await em thread única é um modelo simples e bem conhecido, e é assim que o JavaScript funciona
    Threads permitem colocar várias CPUs para resolver o problema, e Rust ajuda a gerenciar locks. Também é possível ter threads com prioridades diferentes, o que pode ser necessário para tarefas CPU-bound
    Por outro lado, async/await multithread fica bagunçado. Quando entram trechos seriamente CPU-bound, eles acabam bloqueando na prática uma thread compartilhada com outras tarefas, então o modelo pode desmoronar facilmente
    O multithreading CPU-bound em Rust não funciona tão bem quanto se espera. No alocador de armazenamento, várias threads podem ficar batendo no mesmo lock e causar um colapso por congestionamento de futex; isso fica especialmente caro quando, durante a expansão de um buffer, ocorre uma recópia com o alocador inteiro bloqueado. O alocador da biblioteca dentro da .DLL de emulação das bibliotecas Microsoft no Wine era vulnerável a esse problema: o tempo de CPU era todo gasto em spinlocks e o desempenho caía por múltiplos de dois dígitos, enquanto a implementação da Microsoft não sofria disso
    Além disso, o Mutex padrão e os canais de crossbeam-channel podem sofrer starvation por mutex injusto. Quando várias threads repetem o ciclo de bloquear um recurso, trabalhar e liberar, uma thread pode vencer continuamente e as demais ficarem para trás. Se for preciso um mutex justo, existe parking-lot, mas ele não oferece a segurança de poisoning em caso de panic da thread que o mutex padrão fornece
    Se não for I/O-bound, fica muito mais complexo
    https://users.rust-lang.org/t/mutex-starvation/89080

    • Exato. Eu lidei principalmente com computações I/O-bound, mas mesmo aí surgem problemas de contenção
      Se a vazão de I/O volta a ser o limite, qual é o sentido de ter um milhão de corrotinas? Se você esgota imediatamente um pool de 10 conexões de banco de dados, corrotinas não vão salvar você; elas só tornam a depuração e os contornos mais difíceis, e o raciocínio mais complicado
    • Fico pensando que talvez esse problema acabe tendo de ser repensado no nível do hardware
      Problemas CPU-bound parecem se reduzir a interrupções/retomadas sistemáticas, e se fosse possível fazer trocas de contexto baseadas em fila, justas e eficientes, para n threads de execução em andamento — por exemplo, uma CPU com n contextos em andamento —, será que o problema não poderia se tornar um problema de alocação de recursos?
    • Não sei por que as dificuldades do multitasking cooperativo continuam sendo redescobertas
      Go, que considero uma linguagem projetada de forma responsável, também começou de maneira cooperativa e, no fim, não teve escolha senão mudar para um modelo preemptivo. Isso não quer dizer que multitasking cooperativo seja inútil, mas deveria vir com um rótulo de advertência; indo além, talvez fosse melhor bloquear estaticamente a execução de certos tipos de código
      Deixo como texto relacionado “What color is your function”
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
    • É algo que acabo repetindo sempre: isso é um detalhe de implementação
      Um executor multithread de async/await consegue lidar bem o suficiente com starvation, e a implementação do .NET aguenta até código muito ruim que mistura chamadas bloqueantes com código assíncrono
      https://news.ycombinator.com/item?id=39530435
      https://news.ycombinator.com/item?id=39786142
      https://news.ycombinator.com/item?id=39721626
    • As funções coloridas criadas por async/await também acrescentam custos ao desenvolvimento e à manutenção de software
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
      Se o software não precisa de alta escalabilidade, os trade-offs de async podem não valer a pena
  • O ponto central do debate entre async/await e threads não é qual dos dois é mais complexo, mas sim que ele divide o ecossistema em dois e torna um dos lados cidadão de segunda classe; se você escolher errado para o projeto, surge atrito.
    É possível misturar os dois, mas, quando necessário, isso é hacky e ineficiente. Hoje, o ecossistema Rust está praticamente decidido de modo que, se houver I/O, tudo fica preso ao ecossistema async/await; e como quase tudo que se quer fazer em Rust inclui I/O, com raras exceções, bibliotecas não assíncronas em geral precisam ser ignoradas, quer o restante da aplicação queira async ou não.
    Se Rust tivesse usado uma abstração mais componível do que async/await, e essa componibilidade não exigisse transformar outras coisas também em async/await, acho que a maior parte das reclamações desapareceria.

    • Concordo com o diagnóstico. No meu texto sobre async em Rust[0], cheguei à mesma conclusão.
      O pior é que o ecossistema não apenas se divide em dois: mesmo dentro de código async, você normalmente fica fortemente preso a um executor, geralmente o Tokio. Estendendo o problema das cores de função, em vez de azul (sem I/O), verde (I/O bloqueante) e vermelho (I/O async), na prática vira algo como azul, verde, vermelho (Tokio), roxo (async-std) e laranja (smol).
      Vejo o padrão sans-I/O como a melhor solução para esse problema. Separando todo o código azul e usando inversão de controle para I/O e tempo, dá para fazer a lógica central do protocolo não saber nada sobre I/O e ficar fácil envolvê-la em várias formas de I/O.
      0: https://hugotunius.se/2024/03/08/on-async-rust.html
    • Você só deve ignorar bibliotecas não assíncronas quando há duas bibliotecas disponíveis e todo o resto é igual. Esse caso é raro.
      Usar código bloqueante em uma aplicação async não é tão suave quanto se espera, mas também não é difícil. Em vez de foo(), use tokio::spawn_blocking(foo).await; isso executa o novo código em uma thread separada e devolve uma future que é concluída quando essa thread termina.
    • Em C# é basicamente parecido.
      Existem opções não assíncronas para I/O, mas, se você usa a opção assíncrona, na prática é forçado a tornar tudo async até o Main(). Também há formas de chamar métodos async com segurança a partir de métodos síncronos, mas elas tornam a depuração extremamente difícil.
  • Muita coisa ficou de fora do texto.
    async/await é executado no contexto de uma thread, então não precisa de locks nem sincronização; mas, se você executa async/await em várias threads para aproveitar os núcleos de CPU, locks e sincronização voltam a ser necessários. Essa complexidade pode ser escondida de código externo. Por exemplo, em vez de sincronizar o acesso a uma única conexão de DB, é mais fácil abrir uma conexão de DB para cada tarefa async, mas em SQLite ou PostgreSQL isso pode afetar o desempenho.
    Em async/await, a propagação de erros não é clara. Isso vale especialmente quando se tenta agrupar tarefas async, e Happy Eyeballs é um exemplo típico.
    Se estamos falando de I/O de rede, também é preciso tratar de backpressure. A implementação de async/await do CPython é notória por carecer de backpressure de rede, e há problemas decorrentes disso.

    • async/await tem vários problemas, mas minha maior queixa é esta.
      O “Design Patterns”, do Gang of Four, era em grande parte um livro de receitas para contornar defeitos do C++, mas as pessoas aplicaram aqueles padrões até em linguagens que não tinham esses defeitos.
      Rust não é JavaScript e consegue executar bem várias threads. Por isso, não havia necessidade obrigatória de usar async/await; como linguagem de sistemas, Rust poderia ter tentado outras soluções.
      Mas, para empurrar Rust para programadores JavaScript, async/await era necessário. without.boats escreveu que “promoveu async/await com o fervor sincero da premissa de que a sobrevivência do Rust dependia desse recurso”.
      https://without.boats/blog/why-async-rust/
      Se async/await se ajustava tecnicamente bem ao Rust não importava; a impressão é que, como programadores JavaScript estavam acostumados com async/await, Rust também precisava ter async/await.
    • async/await, assim como threads, é um mecanismo de concorrência, e sempre que se acessa memória compartilhada é necessário usar locks. Não sei de onde veio a afirmação de que locks não são necessários.
    • async pode ser mais assustador em relação a locks.
      Um bloco de código dependia de acesso exclusivo e isso estava garantido porque não havia await; se alguém adiciona um await no meio, o código quebra. Com threading, pelo menos você é levado a explicitar no código o que precisa de acesso exclusivo.
      async também significa assumir o gerenciamento do seu próprio escalonamento de threads. Se há muito I/O e trechos curtos de código CPU-bound, tudo bem; mas, se houver código CPU-bound, mesmo que só de vez em quando, você acaba brincando de scheduler.
    • Quando entrei em uma equipe que usava Node.js, enfrentei problemas de backpressure.
      Vários serviços simplesmente morriam com ABEND e, vindo de Java, achei essa lacuna surpreendente. Também era difícil explicar à equipe como corrigir.
      Por causa da propagação de erros, eu não usaria async/await se tivesse escolha. Em um projeto solo, talvez; mas, se for com outras pessoas, usando bibliotecas e precisando alinhar todo mundo ao mesmo entendimento, de jeito nenhum.
      Ainda não usei de verdade concorrência estruturada em nível de linguagem, mas tenho esperanças no Project Loom do Java. Pelo que parece, ele deve tornar esse debate irrelevante.
    • Em Rust, async/await não é executado apenas no contexto de uma única thread.
  • Há problemas no texto
    O único exemplo é um servidor web, e a solução do lado das threads foi mal resolvida. Além disso, a pergunta parece presumir que as pessoas querem threads do SO em vez de async/await
    O que os programadores querem são threads conceituais/semânticas. É escrever lógica sequencial e não usar anotações estranhas como async. Se async/await é tão bom assim, por que não tornar todas as funções implicitamente async e usar chamadas de função normais em vez de await? Aí, na prática, estaríamos programando com threads
    Threads do SO são caras por causa da pilha alocada estaticamente, e o que queremos são threads baratas, capazes de rodar milhões em uma única CPU. Só que sem as palavras desajeitadas async/await. wait pode continuar existindo para a espera bloqueante no sentido clássico, aguardando um evento ou a conclusão de outra thread, mas não o queremos em chamadas de função
    Voltando ao exemplo do servidor web: quando o timeout é implementado com driver.race(timeout).await, o que acontece com o socket do cliente depois que race sinaliza o erro de timeout? Ele não fica aberto, conectado, vazando recursos?
    A versão com threads do timeout também pode ser feita quase igual a async/await, como threaded_race(client_thread, timeout).wait. threaded_race rastreia o timeout em paralelo com a thread usando um timer e, quando o tempo chega, chama client_thread.interrupt() no estilo Java. Thread.interrupt() apenas define uma flag se a thread não estiver bloqueada e lança InterruptedException se ela estiver bloqueada em uma chamada de I/O. Como é uma exceção verificada, o compilador força client.read_to_end(&mut data) a ser envolvido em try/catch ou a exceção a ser declarada em handle_client, então o programador não esquece de fechar o socket do cliente

    • Os valores internos de race() recebem Drop, e o próprio driver permanece
      Se você usar os tipos desse jeito, o Rust vai reclamar que o Result não está sendo tratado; e, se um novo socket tiver sido criado localmente dentro da future, ele será limpo
      O ponto bom das futures em Rust é que dá para definir todo esse comportamento ao redor delas. Diferentemente de um modelo em que todas as funções são bloqueantes, o Rust permite especificar o momento em que a execução será adiada para a próxima tarefa da fila de trabalho, e faz polling da tarefa arbitrariamente rápido com estado explicitamente armazenado (a struct Future). Por isso não é preciso dar sleep() para ceder como em threads, o que é rápido e mais fácil de raciocinar
      O Thread.interrupt do Java também acaba sendo algo próximo de um loop com sleep, e pode ser aceitável para a maioria das aplicações. Mas Rust é uma linguagem de sistemas; em sistemas embarcados não dá para adotar esse tipo de abordagem, e ela também não é desejável em kernels ou aplicações de baixa latência
    • Alguns programadores querem lógica sequencial, mas muita gente quer exatamente o oposto
      Na maioria dos casos, não me importo muito se a chamada de sistema do SO é bloqueante ou não bloqueante, mas quero entender o fluxo de controle do programa que estou lendo, saber onde ele espera e como pode rodar em paralelo
      Na verdade, eu gostaria que houvesse um par de palavras-chave blocking/block ao trabalhar com funções bloqueantes. Chamadas bloqueantes podem, discretamente, deixar tudo mais lento, e já vi aplicativos lentos e irritantes demais porque havia chamadas de sistema bloqueantes na thread de UI
    • Tornar todas as funções implicitamente async e usá-las como chamadas de função comuns é algo que já foi tentado várias vezes nas últimas décadas. Procure por “RPC”
      Todas as tentativas de unificar síncrono e assíncrono fracassaram. Há grandes diferenças semânticas entre código executado dentro de uma thread, código executado entre threads e até código executado entre computadores. Se você tentar abstrair isso, a abstração acaba ficando insuficiente; então é melhor aprender direito desde o começo
    • Lembro que withoutboats disse em algum texto que a resposta real é compatibilidade com C
    • Também existe a forma de escrever código com poll() e select(), mas isso já é outra abordagem
  • É interessante ver uma campanha quase de marketing para salvar a imagem de async/await
    Pela minha experiência, isso não foi apenas um erro técnico; também impôs um grande custo à comunidade. Em vez de se concentrar em recursos de linguagem realmente úteis, o esforço do Rust foi desviado para essa confusão
    Mesmo assim, ainda tenho grandes expectativas para a linguagem, e a considero a melhor entre as opções atuais. Só me preocupa que essa briga continue para sempre
    PS: o exemplo de AsyncWrite/AsyncRead parece convincente, mas, se você se limitar a *nix, dá para fazer a mesma coisa com threads e descritores de arquivo

    • Já usei async em firmware, e foi uma salvação
      Esse tipo de generalização parece pouco fundamentado e enviesado para uma carga de trabalho específica
    • Não conheço Rust tão bem, então não sei se isso está certo, mas empiricamente parece que 9 em cada 10 discussões sobre Rust que vejo hoje no HN/reddit giram em torno de async
      Para alguém que não tem nenhum interesse em async e quer ler sobre Rust, isso é bem ruim
    • Se você acha que threads são mais rápidas que poll(), eu gostaria de saber em que caso de uso. Em toda a minha vida, nunca vi um caso assim
    • Não foi um erro técnico; quando você precisa de código async de latência ultrabaixa, é uma ótima solução
      O erro foi empurrá-la também para a grande maioria dos casos de uso que não precisam disso
    • Fico curioso se há base para a afirmação de que o trabalho em async roubou espaço de outros recursos úteis
      Muitos projetos importantes em Rust dependem de async não só por grandes ganhos de desempenho em relação a alternativas baseadas em threads, mas também por suas características de projeto. Em cargas de trabalho importantes limitadas por I/O, essas vantagens ficam fáceis de ver. O fato de pessoas inteligentes resolvendo problemas reais terem adotado async amplamente em crates importantes me parece um forte sinal de que async é de fato um recurso de linguagem útil
      A briga acontece principalmente no Hacker News e no reddit, na forma de pessoas que não precisam de async ficando irritadas porque crates de I/O que elas usam agora querem async. Entendo que seja uma situação desagradável, e é verdade que async tem problemas reais que ainda estão sendo resolvidos. Não é perfeito. Mas a divisão em torno de async vista nos fóruns não me parece tão ampla nem tão dramática quanto nos projetos reais
  • O grande ponto que ficou de fora é cancelamento
    futures são muito fáceis de cancelar. Já o cancelamento de threads é bagunçado como um jogo de “bater na toupeira”, e encerrar uma thread à força não é confiável porque há o risco de locks ficarem travados
    No modelo async do Rust, é possível anexar externamente um timeout a qualquer future. Não é preciso que cada função de I/O na ponta dê suporte a uma opção de timeout, nem repassar esse timeout por toda a pilha de chamadas
    Usando junto os guards de Drop, que são uma prática recomendada em Rust para gerenciar estado em andamento, até operações grandes e complexas podem ser canceladas de forma fácil e confiável

    • Não é que todo future seja fácil de cancelar; é que é fácil fingir que qualquer future foi cancelado
      Por exemplo, mesmo que você cancele (drop) algo que usa spawn_blocking, ele continua executando em segundo plano, e o usuário pode nem perceber. Operações async de sistema de arquivos implementadas com pool de threads também continuam executando mesmo após o cancelamento
      Isso pode levar a bugs difíceis de entender, como “tenho certeza de que nada está gravando nesse arquivo, então por que o serviço falha dizendo que o arquivo está em uso?”
    • Se foi possível implementar async futures, então também teria sido possível implementar threads canceláveis
      O problema é bastante isomórfico. Chamadas de sistema são difíceis, mas, seja em uma thread ou em um async future, se você fizer a mesma chamada de sistema, terá exatamente o mesmo problema de cancelamento
    • Não entendo por que cancelamento de threads seria difícil
      Basta ter algum estado, como uma flag que todas as threads possam acessar, e fazer o loop de trabalho verificar essa flag. Se for false, retorna e faz join da thread, e pronto
    • Pela minha experiência, cancelamento não era algo que valesse grande preocupação
      Se uma operação deixou de ser útil, basta que essa informação acabe ficando visível para a função chamada em nome dela. A menos que esteja prestes a fazer algo muito caro, como iniciar um RPC, eu nem checo
  • Acho que a pergunta melhor é: “por que async/await, e não fibers?”
    Sei que Rust tinha green threads antes da 1.0 e as removeu deliberadamente, mas há várias abordagens para implementar concorrência baseada em fibers, incluindo formas que não exigem embutir um runtime pesado na linguagem
    Se entendi bem o texto, ele parece elogiar principalmente o fato de que um future pode ser descartado a qualquer momento. Com threads, não dá para fazer algo parecido por motivos óbvios e, mesmo que tecnicamente fosse possível, seria extremamente inseguro. Mas essa capacidade tem um custo enorme. Além de não ser possível usar executores baseados em conclusão, como io-uring, junto com arrays baseados em stack, nem executar subtarefas em outra thread do executor, também surgem armadilhas sutis e problemas de confiabilidade que viram surpresas muito desagradáveis para quem vem de Rust síncrono
    https://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
    Acho que o cancelamento de tarefas deve ser fundamentalmente cancelamento cooperativo, e que cancelamento não cooperativo é um recurso errado: parece conveniente na superfície, mas tem problemas profundos por baixo
    Também é estranho elogiar a composicionalidade de async/await. No Rust atual, sem um proper effect system, ele está longe de ser composicional por causa de sua natureza contagiosa. Por exemplo, tente usar o método map da biblioteca padrão com closures async, ou usar os traits padrão io::Read/Write

    • Para colocar fibers em Rust como uma abstração de concorrência cooperativa em espaço de usuário, várias decisões de design seriam impostas
      Seria preciso escolher, por exemplo, se as stacks seriam implementadas como spaghetti stacks, se seria exigida uma biblioteca de mapeamento de memória em nível de processo, ou se ficariam limitadas a stacks de tamanho fixo
      Todas as três abordagens têm problemas ao interagir com código de linguagens que têm outro ABI. Por exemplo, se uma fiber chama código C e esse código C tenta retomar outra fiber, a coisa pode ficar bem complicada
      Uma das vantagens de async/await é a própria palavra-chave await. Pontos de espera explícitos permitem realmente raciocinar sobre as interações em programas concorrentes
      Uma fiber que faz yield é meio como o goto do mundo da concorrência. Ao chamar um método, você não sabe se a execução vai parar como efeito colateral, nem se o estado do mundo terá mudado quando ela continuar. Como é preciso escrever defensivamente ao interagir com o mundo externo, fibers se encaixam melhor em tarefas executadas isoladamente e que se comunicam por conclusão
      Green threads, fibers e corrotinas compartilham esse mesmo problema aqui. Concorrência cooperativa em espaço de usuário não resolve tanto as partes difíceis da concorrência; está mais para mudar papéis de lugar em cima da mesa. O async/await de Rust é mais explícito e não esconde os efeitos colaterais que outros mecanismos escondem
    • Não vejo como fibers resolvem o problema de cancelamento. Não é praticamente equivalente?
      Código baseado em fibers me parece difícil de acompanhar porque é preciso rastrear mentalmente a thread em andamento. Pelo menos para mim, é muito mais fácil rastrear o valor que será concluído
    • Fibers com stack não são muito boas para código de baixo nível
      Veja a análise de Gor Nishanov para o comitê de C++ http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136.... Ela também é linkada em https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10.... O resumo é claro: DO NOT USE FIBERS!
    • Como é Ruby, a situação de threads/GVL é diferente de Rust, mas fico pensando se é desse tipo de coisa que estão falando
      https://m.youtube.com/watch?v=qKQcUDEo-ZI
      Acho que ele mostra bem os motivos para async/await ser contagioso e desajeitado, e para fibers serem um paradigma muito melhor, pelo menos na implementação de Ruby
    • Em problemas matemáticos, cancelamento cooperativo pode ser bem irritante
      Um algoritmo de otimização pode chamar um problema de busca de raízes, que por sua vez chama um integrador de ODE, e assim por diante, com qualquer etapa podendo rodar por muito tempo. Seria preciso passar tokens de cancelamento por todos os lados, mas frameworks de computação numérica geralmente não oferecem suporte a isso
      É possível, e necessário, colocar limite de iterações em todos os algoritmos, mas em algoritmos aninhados isso só garante algo como “vai parar ainda este ano”, não “vai parar em 5 segundos”
      Nesses problemas, posso garantir que o que estou fazendo é apenas uma grande quantidade de cálculos matemáticos, alocações e as page faults resultantes, sem I/O, além de escrever strings de log em um objeto Queue da biblioteca padrão, a ser processado pela thread Main que não será cancelada. Qualquer outra funcionalidade necessária também pode ser devolvida à thread principal por meio da Queue
      Sinto que, no século 21, esse problema deveria poder ser resolvido sem forçar a passagem de tokens de cancelamento por todos os lugares e sem fazer as pessoas escreverem defensivamente qualquer código que rode por muito tempo sem verificar o token
  • Em mais uma discussão sobre async/await, as pessoas não entendem async/await, não conseguem imaginar por que seria necessário um mecanismo de concorrência em uma única thread e pressupõem que ninguém precisa disso
    Programação de UI, comunicação com GPU e comunicação entre runtimes são bons exemplos, e deve haver outros
    Threads, sejam green threads ou não, não se encaixam nesses casos, mas async/await se encaixa

    • Em GUIs, dá para usar threads com facilidade, e no passado escrevi vários apps GUI que aproveitavam threads de forma bastante eficaz
    • É certamente importante poder gerenciar explicitamente várias tarefas em uma única thread
      Se for possível implementar outro recurso de linguagem que produza o mesmo binário e seja menos incômodo para os usuários da linguagem, também vale discutir isso
  • Uma das principais vantagens do async/await em Rust é que ele pode funcionar mesmo em situações sem threads ou memória dinâmica.
    Ele é perfeitamente suficiente para escrever código conciso que, em um microcontrolador, espera até que uma interrupção leia dados I2C que entram em algum buffer. É uma abstração de nível mais alto que permite usar concorrência sem expor muita interação com o runtime subjacente.
    Todo software importante em que trabalhei implementava algo assim de alguma forma. Mesmo em código sem o conceito moderno de corrotinas de C++, usávamos Apple Grand Central Dispatch, Intel Threading Building Blocks etc. Caso contrário, a lógica de negócio ficaria bloqueada de forma muito ineficiente em I/O, ou haveria uma quantidade enorme de threads tornando o desenvolvimento e a depuração um inferno, ou o código ficaria coberto de detalhes de implementação do runtime subjacente — ou uma mistura das três coisas.
    Se você não usa a abstração já existente da própria linguagem ou de uma biblioteca, acaba criando a sua, e isso é difícil e provavelmente inferior, no geral, ao que é amplamente usado. No passado, eu mesmo criei uma para C++: https://github.com/goto-opensource/asyncly

  • O autor parece estar confundindo duas coisas.
    Uma é threads em espaço de usuário/green threads, e a outra é concorrência estruturada.
    A primeira é uma vantagem de async/await, mas não é uma vantagem exclusiva. Há exemplos em que isso é possível sem o problema das cores de funções, como Go ou Java Loom.
    A segunda pode ser implementada tanto com threads do OS quanto com green threads. Basta ver a JEP de Structured Concurrency do Java.
    https://openjdk.org/jeps/462