Por que escolher async/await em vez de threads
(notgull.net)- O
async/awaitdo 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/awaitcede a execução em pontos deawait, 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
asynccom a combinação deraceeTimer, enquanto no código síncrono com threads é necessário um wrapper específico paraTcpStreame configuração de timeout de leitura/escrita, o que reduz a generalidade - Explicar
asyncapenas pelo overhead de desempenho gera contraexemplos em tarefas CPU bound; a força doasyncem 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
- Como
- 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 executahandle_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
- Em uma estrutura que recebe clientes com
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
- A thread principal continua fazendo
- 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 fnnão é uma função tradicional, mas uma função que retorna uma máquina de estadoawaitinclui 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::Executorroda outra máquina de estado criada comspawnno lugar da máquina atual- O bloco
async move { handle_client(client).await }é uma nova máquina de estado independente demain - Quando
maincede, uma das tarefas de cliente é executada, e quando ela cede de novo, o executor passa para a próxima tarefa
- O bloco
- 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
Iteratorpermite encadear vários combinadores e passar o resultado de novo para funções que recebemIterator- Como em
mpsc::channel()comrecv.try_iter().filter(...).map(...), é possível filtrar e transformar valores antes de adicioná-los a uma lista
async/awaitleva essa combinabilidade também para funções I/O bound- Se
handle_client()for uma função assíncrona que fazawaitemread_to_end,do_something_with_dataewrite_all, um timeout de 3 segundos pode ser implementado combinando dois Futures - 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
- O mesmo padrão pode ser aplicado a qualquer tipo que implemente
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
readouwrite, e métodos como fechar o descritor de arquivo não podem ser usados em Rust TcpStreamforneceset_read_timeouteset_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
DeadlineStreamque encapsuleTcpStreame 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_timeouteset_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
- Fica presa a
Casos do ecossistema async de Rust
- O fato de o ecossistema HTTP, incluindo clientes, ter adotado
async/awaitcomo 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 deasync/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,
towerpode ser aplicado para aumentar a robustez
- Se um serviço for implementado como função
macroquadé uma engine de jogos pequena para Rust e executa a engine comasync/awaitna função principalasync/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 usarasyncem 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/awaitpara resolver o C10k problem, mas isso não significa que o motivo para todo usuário escolherasync/awaitprecise 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
asyncequivalente - A vantagem temporária de desempenho do
asyncem Rust foi superenfatizada, enquanto seus benefícios semânticos foram subestimados
- Em tarefas CPU bound, um workflow baseado em threads pode ser mais rápido do que um workflow
async/awaitnã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 Rustdeveria, às vezes, ser tão fácil quanto escrever código síncrono, exceto pelo uso das palavras-chaveasynceawait - Mas também há a visão de que enquadrar
async Rustcomo “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/awaitem Rust deveria destacar com mais clareza seus pontos fortes em combinabilidade e expressividade - Para que
async/awaitse 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
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
Mutexpadrão e os canais decrossbeam-channelpodem 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, existeparking-lot, mas ele não oferece a segurança de poisoning em caso de panic da thread que o mutex padrão forneceSe não for I/O-bound, fica muito mais complexo
https://users.rust-lang.org/t/mutex-starvation/89080
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
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?
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-...
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
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.
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
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(), usetokio::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.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.
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.
Um bloco de código dependia de acesso exclusivo e isso estava garantido porque não havia
await; se alguém adiciona umawaitno 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.
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.
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 deawait? Aí, na prática, estaríamos programando com threadsThreads 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.waitpode 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çãoVoltando ao exemplo do servidor web: quando o timeout é implementado com
driver.race(timeout).await, o que acontece com o socket do cliente depois queracesinaliza 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_racerastreia o timeout em paralelo com a thread usando um timer e, quando o tempo chega, chamaclient_thread.interrupt()no estilo Java.Thread.interrupt()apenas define uma flag se a thread não estiver bloqueada e lançaInterruptedExceptionse ela estiver bloqueada em uma chamada de I/O. Como é uma exceção verificada, o compilador forçaclient.read_to_end(&mut data)a ser envolvido em try/catch ou a exceção a ser declarada emhandle_client, então o programador não esquece de fechar o socket do clienterace()recebemDrop, e o própriodriverpermaneceSe você usar os tipos desse jeito, o Rust vai reclamar que o
Resultnão está sendo tratado; e, se um novo socket tiver sido criado localmente dentro da future, ele será limpoO 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 darsleep()para ceder como em threads, o que é rápido e mais fácil de raciocinarO
Thread.interruptdo 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ênciaNa 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/blockao 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 UITodas 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
poll()eselect(), 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/AsyncReadparece convincente, mas, se você se limitar a *nix, dá para fazer a mesma coisa com threads e descritores de arquivoEsse tipo de generalização parece pouco fundamentado e enviesado para uma carga de trabalho específica
Para alguém que não tem nenhum interesse em async e quer ler sobre Rust, isso é bem ruim
poll(), eu gostaria de saber em que caso de uso. Em toda a minha vida, nunca vi um caso assimO erro foi empurrá-la também para a grande maioria dos casos de uso que não precisam disso
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ávelPor exemplo, mesmo que você cancele (
drop) algo que usaspawn_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 cancelamentoIsso 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?”
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
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
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íncronohttps://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
mapda biblioteca padrão com closures async, ou usar os traits padrãoio::Read/WriteSeria 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 concorrentesUma fiber que faz yield é meio como o
gotodo 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ãoGreen 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
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
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!
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
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
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