Assincronia não é concorrência
(kristoff.it)- Assincronia e concorrência são conceitos frequentemente confundidos, mas têm significados diferentes
- Assincronia significa a possibilidade de que tarefas sejam executadas sem depender da ordem
- Concorrência significa a capacidade de o sistema avançar várias tarefas ao mesmo tempo
- A falta de uma distinção clara entre os dois conceitos no ecossistema de linguagens e bibliotecas gera ineficiência e complexidade
- Na linguagem Zig, a separação entre assincronia e concorrência permite a coexistência de código síncrono e assíncrono sem duplicação de código
Introdução: por que distinguir assincronia e concorrência
A famosa apresentação de Rob Pike tornou conhecida a frase “concorrência não é paralelismo”, mas existe um ponto ainda mais prático e importante do que esse: a necessidade do conceito de “assincronia”. Segundo a definição da Wikipédia,
- Concorrência: a capacidade de um sistema de lidar com várias tarefas ao mesmo tempo, seja por divisão de tempo ou em paralelo
- Computação paralela: executar de fato várias tarefas simultaneamente no nível físico
Além disso, há um conceito importante que costumamos deixar de lado: “assincronia”.
Exemplo 1: salvar dois arquivos
Ao salvar dois arquivos (A, B), se a ordem não importa,
io.async(saveFileA, .{io})
io.async(saveFileB, .{io})
- tanto faz salvar A primeiro ou B primeiro, e também não há problema em intercalar os dois no meio do processo
- inclusive, mesmo que o arquivo A seja salvo por completo antes de começar B, o código continua correto
Exemplo 2: dois sockets (servidor, cliente)
Quando é preciso criar um servidor TCP e conectar um cliente dentro do mesmo programa,
io.async(Server.accept, .{server, io})
io.async(Client.connect, .{client, io})
- nesse caso, as duas tarefas precisam necessariamente progredir de forma sobreposta
- ou seja, enquanto o servidor aceita a conexão, o cliente também precisa tentar se conectar
- se isso for processado em série, como no primeiro exemplo dos arquivos, o comportamento esperado não acontece
Organizando os conceitos
Os conceitos de assincronia, concorrência e paralelismo podem ser definidos assim:
- Assincronia (asynchrony): propriedade em que o resultado continua correto mesmo que as tarefas sejam executadas fora de ordem
- Concorrência (concurrency): capacidade de fazer várias tarefas avançarem ao mesmo tempo, seja em paralelo ou por execução intercalada
- Paralelismo (parallelism): capacidade física de executar várias tarefas simultaneamente em tempo real
Os dois exemplos, salvar arquivos e conectar sockets, são assíncronos, mas no segundo caso (servidor-cliente) a concorrência é obrigatória.
A utilidade prática de separar assincronia e concorrência
Sem essa distinção, surgem problemas como:
- autores de bibliotecas precisam implementar duas versões do código, uma assíncrona e outra síncrona (ex.: redis-py vs asyncio-redis)
- usuários passam pelo incômodo de ter código assíncrono “contagioso”, em que uma única dependência assíncrona já obriga a transformar o projeto inteiro em assíncrono
- para evitar isso, acabam surgindo soluções improvisadas, que frequentemente causam *deadlocks* e ineficiência
Por isso, separar claramente os dois conceitos traz grandes vantagens tanto para bibliotecas quanto para usuários.
Zig: separando assincronia e concorrência
A linguagem Zig usa io.async para trabalhar com assincronia, mas isso não garante concorrência.
- ou seja, mesmo usando
io.async, a execução interna ainda pode acontecer em modo single-thread e bloqueante - por exemplo
esse código, em um ambiente bloqueante, pode funcionar da mesma forma queio.async(saveFileA, .{io}) io.async(saveFileB, .{io})saveFileA(io) saveFileB(io) - em outras palavras, mesmo que o autor da biblioteca use
io.async, o usuário continua com a flexibilidade de executar tudo como I/O bloqueante sequencial, se quiser
Introdução de concorrência e mecanismo de troca de tarefas (agendamento)
Quando a concorrência é necessária, para que o comportamento seja realmente efetivo é preciso:
- usar I/O baseado em eventos, sem bloqueio (
epoll,io_uringetc.) - usar primitivas de troca de tarefas (switching), como
yield
- como exemplo, Zig usa a técnica de stack swapping em ambientes com green threads para fazer a troca de tarefas
- de forma semelhante ao agendamento de threads no nível do sistema operacional, o estado como registradores de CPU e stack é salvo/restaurado para alternar entre várias tarefas
- é esse mecanismo de troca que permite agendar de forma realmente concorrente um código assíncrono
- implementações de corrotinas stackless (por exemplo,
suspend,resume) seguem o mesmo princípio
Coexistência entre código síncrono e assíncrono
Se duas chamadas de saveData forem executadas com io.async, como abaixo,
io.async(saveData, .{io, "a", "b"})
io.async(saveData, .{io, "c", "d"})
- como as duas tarefas são assíncronas entre si, mesmo que a função interna tenha sido escrita de forma síncrona, ainda é possível agendá-las naturalmente em um contexto concorrente
- assim, usuários e autores de bibliotecas podem usar funções síncronas e assíncronas juntos sem duplicar código
Indicando quando a concorrência é “obrigatória”
Algumas funções específicas (por exemplo, accept de um servidor TCP) precisam expressar no código que concorrência é necessária durante a execução.
- em Zig, isso pode ser distinguido com funções explícitas como
io.asyncConcurrent - dessa forma, se o ambiente de execução não oferecer suporte a concorrência, um erro é gerado
- diferente de
io.async, cujo objetivo é assincronia, aqui a garantia de concorrência é obrigatória, então a função é implementada como failable
Conclusão
- Assincronia e concorrência são conceitos completamente diferentes e devem ser distinguidos com clareza
- é possível fazer código síncrono e assíncrono coexistirem
- o modelo de assincronia/concorrência do Zig permite aproveitar os dois mundos sem duplicação de código
- essa estrutura também já foi aplicada em outras linguagens, como Go, e aponta um caminho para superar a “contagiosidade” de
async/await - com o novo design de async I/O do Zig, é possível esperar um ambiente de programação concorrente/assíncrona ainda mais intuitivo no futuro
1 comentários
Comentários no Hacker News
a definição de async parece muito difícil, eu mesmo fui uma das várias pessoas que projetaram o async no JavaScript, não concordo com a definição apresentada neste texto, só por ser async não significa que vai funcionar corretamente, mesmo em código async ainda podem ocorrer vários tipos de race conditions em nível de usuário, independentemente de a linguagem suportar async/await ou não, a definição a que cheguei recentemente é que async é “código explicitamente estruturado para concorrência”, mas essa visão ainda precisa ser mais refinada, também tenho um texto meu sobre isso, veja Quite a few words about async
acho importante distinguir entre o conceito abstrato de assincronismo e sua implementação prática, esta última incluindo tanto abstrações no nível da linguagem quanto mecanismos de coordenação mecânicos, no nível mais alto de abstração, assíncrono é simplesmente o oposto de síncrono, em geral, quando vários agentes precisam atuar juntos (por exemplo, uma tarefa só prossegue depois que outra termina), o ponto central da assincronia é que não se sabe ou não está definido quando isso vai acontecer, essa definição em si não é difícil, o problema é a carga cognitiva que surge ao projetar essa abstração no nível da linguagem
não sou profundo nesse tema, mas na minha visão código async serve para transformar operações originalmente bloqueantes em não bloqueantes, permitindo que outras tarefas avancem ao mesmo tempo, no meu caso isso fica muito claro em loops embarcados, onde código bloqueante por muito tempo estraga o I/O e pode causar erros visíveis ou audíveis
eu até questiono se async precisa mesmo ser definido, na prática é difícil definir justamente porque nada se encaixa perfeitamente em um único conceito, também duvido que seja necessário definir async ou event loop de forma rígida, no domínio físico dos chips que realmente conseguem processamento paralelo provavelmente há inúmeros conceitos que eu desconheço, para mim basta entender “user finger” (toque do dedo etc.), “quickies” (tarefas de execução muito curta), job queue e APIs bloqueantes/não bloqueantes, se quero atingir meu objetivo, uma API não bloqueante é melhor, porque tarefas demoradas ficam para subsistemas inferiores e eu só escrevo “quickies” como salvar os dados que quero, além de definir quickies diferentes para sucesso e falha, a distinção entre sync e async em si não ajuda tanto, embora eu precise entender os conceitos quando outras pessoas falam deles, no fundo considero async uma API não bloqueante, e um modelo de programação async é basicamente escrever pequenas operações bloqueantes e atômicas (em termos de tempo de execução) em torno de eventos “caóticos e não determinísticos”, independentemente do que o sistema faz internamente, eu confio que o navegador, o SO e o próprio dispositivo fornecem múltiplas unidades de execução e um bom escalonador, para mim async é um conceito vagamente definido, e mesmo que dê para defini-lo, não sei se isso seria útil, conceitos como eventos, a natureza bloqueante do trabalho que escrevo, closures de função e entender em que pontos ao usar uma API algo é dividido em outro job são muito mais práticos, o próprio termo “callback” foi extremamente confuso no começo, eu achava que o código parava ali, mas na verdade era preciso entender com precisão que aquele trecho ia até o fim, e depois, quando o “callback” fosse chamado, que código seria executado e que informações ele poderia ver, sinceramente isso é caos e genialidade ao mesmo tempo, mais do que “async” em si, o modelo fundamental — eventos, operações bloqueantes, fila de jobs e APIs não bloqueantes — é muito mais simples, e também é bem importante entender o que eu estou fazendo e o que navegador/SO etc. estão fazendo, por exemplo, em cpp você declara um modelo concorrente, mas quem realmente executa é o SO, em JS você declara “talvez” haver concorrência para o navegador ou Node por meio de APIs não bloqueantes, e eles realmente tratam isso de forma concorrente por dentro, o mais importante é manter cada trabalho curto (<50ms) e usar APIs não bloqueantes para expressar sua intenção, cpp ou rust avisam ao SO para executar tarefas de forma concorrente, então mesmo se houver fisicamente só uma thread, a responsividade da UI é mantida, no fim, o trabalho do programador async é criar um “modelo de UX bacana” e mapear bem os eventos para quickies
parece que o autor tirou o conceito de “yield de execução” da definição de concorrência e o colocou dentro de um novo termo, “assincronia”, e ainda afirma que sem isso toda a concorrência desmorona, na minha opinião, a concorrência já exige capacidade de ceder execução desde o início, então isso já é um conceito inerente a ela, é um conceito importante, mas separá-lo em um novo termo só aumenta a confusão
eu considero paralelismo 1:1 uma forma de concorrência sem yield de execução, fora isso, toda concorrência não paralela precisa ceder execução em algum ciclo, mesmo que seja no nível de instrução, por exemplo, em CUDA, threads divergentes dentro do mesmo warp acabam intercalando instruções, então um ramo pode bloquear o outro
quero enfatizar que o texto citado na verdade explicita que “yield de execução é um conceito de concorrência”
concorrência não implica necessariamente yield de execução, lógica síncrona precisa de sincronização explícita, e yield é apenas um meio de sincronização, quando falo de lógica assíncrona, refiro-me a concorrência que funciona sem sincronização nem yield, do ponto de vista prático, concorrência ou lógica assíncrona não existem plenamente em máquinas de von Neumann
neste contexto, async é a abstração que separa a preparação/envio de uma requisição da coleta do resultado, isso permite enviar várias requisições e só depois verificar seus resultados, pode permitir uma implementação concorrente, mas isso não é obrigatório, ainda assim, o objetivo dessa abstração é obter concorrência, sem concorrência não há o ganho que se busca, algumas abstrações async nem sequer podem ser implementadas sem um mínimo de concorrência, por exemplo, um modelo de callback pode ser simulado em uma única thread, mas ele tem limites, como deadlock ao segurar um mutex não recursivo, ou seja, uma abstração async sem concorrência inevitavelmente falha, se o requisitante fizer a chamada segurando o mutex e o callback for executado antes do unlock, talvez o unlock nunca aconteça, no mínimo é preciso uma thread separada para que o requisitante consiga chegar ao unlock
“multitarefa cooperativa não é preemptiva”, o termo “async” normalmente significa “thread única, multitarefa cooperativa (yield explícito) e baseada em eventos”, com operações externas sendo executadas concorrentemente e relatando os resultados via eventos, em modelos multithread ou de execução concorrente, async perde bastante o sentido, mesmo que aquela thread bloqueie, o programa continua avançando, então nem é necessário que os pontos de yield sejam explícitos
a nova ideia de I/O do Zig parece inovadora para desenvolvimento de aplicações em geral, é ótima para quem não precisa de corrotinas stackless, mas parece propensa a erros na escrita de bibliotecas, o autor da biblioteca tem dificuldade para saber se o I/O fornecido é single-thread ou multi-thread, ou se é I/O orientado a eventos, código relacionado a concorrência/assincronia/paralelismo já é difícil de escrever mesmo conhecendo toda a pilha de I/O, e fica ainda mais difícil quando o I/O vem de fora, se a interface de I/O ficar grande como um “pequeno SO”, o número de cenários para testar explode, não tenho certeza se só com os primitivos async oferecidos pela interface dá para lidar com todos os edge cases reais, para suportar várias implementações de I/O o código precisaria ser muito “defensivo” e sempre presumir o I/O mais paralelo possível, especialmente misturar corrotinas stackless com esse método não parece simples, para reduzir spawns desnecessários de corrotinas provavelmente seria preciso polling explícito de corrotinas, e a maioria dos desenvolvedores talvez não escreva esse tipo de código por conta própria, no fim isso provavelmente vai convergir para uma estrutura parecida com código async/await comum, considerando ainda o dynamic dispatch e a tendência de design bottom-up do Zig, isso pode acabar tornando a linguagem bastante high-level, como ainda não há casos reais de uso, chamar isso de abordagem “sem concessões” parece um nome ousado demais, só depois de alguns anos de uso será possível avaliá-la de verdade
corrotinas stackless já estão previstas de qualquer maneira, porque são necessárias para suporte a alvo WASM, então certamente vão entrar, dynamic dispatch só é usado quando há mais de uma implementação de I/O, se houver só uma, isso vira chamada direta, como ainda não foi validado em campo, também acho cedo chamar de “sem concessões”, ouvi dizer que a linguagem Jai usa com sucesso um modelo parecido (a diferença é que ela usa contexto de I/O implícito em vez de passagem explícita de contexto), mas isso também dificilmente conta como algo realmente testado em produção
concordo com o ponto de que, para suportar execução síncrona e assíncrona ao mesmo tempo, o código sempre precisa presumir o I/O mais paralelo possível, no entanto, se a assincronia estiver bem implementada no handler de eventos de I/O de nível mais baixo, basta aplicar o mesmo princípio em todos os lugares, no pior caso o código só vai rodar de forma sequencial (e lenta), sem cair em problemas de race/deadlock
acho a ideia do Zig muito boa no sentido de não precisar usar duas bibliotecas separadas, mas sempre me preocupo com testes de código assíncrono, não sei como ter certeza de que um teste aprovado hoje realmente reproduz todos os cenários/ordens que podem ocorrer em produção, programas com threads têm o mesmo problema, mas código multithread é sempre mais difícil de escrever e depurar, eu evito usar threads sempre que posso, o problema real é “fazer o desenvolvedor entender corretamente o ambiente async/threaded”, recentemente trabalhei com uma equipe que usava metade JS, metade Python em um sistema Python, eles converteram um código grande para async e threaded, mas nem sabiam o que era o Global Interpreter Lock (GIL), minhas observações pareciam só bronca, além disso os testes deles sempre passavam mesmo quando o código era quebrado de propósito, no mangum, tarefas em background e async são forçadas a terminar quando a HTTP request acaba, e eles nem sabiam disso, e mesmo quando essas coisas são apontadas, todo mundo parece indiferente, não basta saber, o mais importante é que os outros deem atenção a isso
no Zig, está previsto um implementador de teste de Io, com ele o plano é permitir também testes de estresse, como fuzzing, sob modelos de execução paralela, mas o ponto principal é que a maior parte do código de biblioteca provavelmente nunca vai chamar
io.asyncouio.asyncConcurrentdiretamente, por exemplo, a maioria das bibliotecas de banco de dados consegue funcionar só com código puramente síncrono, e o desenvolvedor da aplicação pode tornar isso assíncrono facilmente com algo comoio.async(writeToDb),io.async(doOtherThing), desse jeito isso fica menos sujeito a erros e muito mais fácil de entender do que espalhar async/await pelo código inteiroconcordo, testar todos os interleavings em código assíncrono e multithread é notoriamente difícil, mesmo usando fuzzer ou frameworks de teste de concorrência, é difícil ter confiança sem as lições aprendidas na prática em produção, em sistemas distribuídos isso piora ainda mais, por exemplo, ao projetar uma infraestrutura de webhook, não há só async dentro do próprio código, há também retries de rede, timeouts, falhas parciais e vários outros problemas externos, em ambientes de alta concorrência, garantir retry, deduplication e idempotency vira um problema de engenharia por si só, por isso surge a necessidade de usar serviços especializados como Vartiq.com (eu trabalho lá), esses serviços abstraem parte da complexidade operacional da concorrência e reduzem o blast radius, mas os problemas de teste async dentro do meu código continuam, em resumo, async, threading e concorrência distribuída amplificam os riscos uns dos outros, então comunicação e design de sistema importam mais do que qualquer sintaxe ou biblioteca
acho que o autor está confundindo a definição de concorrência neste contexto, vale consultar o artigo do Lamport
não deixe só o link do artigo, por favor explique, na minha opinião a definição em si estava boa, por exemplo: assincronia: se estiver correto que tarefas não sejam executadas em sequência, então isso é assincronia, concorrência: propriedade de um sistema capaz de fazer várias tarefas avançarem ao mesmo tempo, seja por paralelismo ou troca de tasks, paralelismo: quando duas ou mais tarefas realmente estão rodando ao mesmo tempo em nível físico
por isso eu parei completamente de usar esses termos, com qualquer pessoa a interpretação muda, então os termos em si deixam de ter valor para comunicação
o autor também sabe, no post do blog, que já existiam definições anteriores para esse termo, ele apenas propôs uma nova definição, e isso basta desde que ela seja consistente, a única diferença é se o leitor vai aceitá-la ou não
metade do artigo do Lamport nem pode ser expressa conceitualmente na maioria das linguagens, criar threads não significa que você vá discutir ordem total e parcial o tempo todo, esse tipo de discussão só costuma ser necessária ao projetar protocolos com TLA+, não é preciso chamar de nova teoria o fato de a API async do Zig emitir erro de compilação para funções que “só funcionam em ambiente de execução assíncrona”
uma boa forma de medir se o termo “assincronia” realmente é necessário é verificar se ele é útil não só em uma linguagem/modelo, mas em vários modelos de concorrência, por exemplo Haskell, Erlang, OCaml, Scheme, Rust, Go etc., se for um termo necessário em comum entre esses ambientes, então tem valor, em geral, quando entra escalonamento cooperativo, o sistema inteiro passa a exigir mais cuidado com travamentos e latência por causa de um único trecho de código, já com escalonamento preemptivo, grande parte desses problemas desaparece, como o travamento total do sistema se torna impossível, a classe de problemas diminui bastante
“assincronia” é uma palavra inadequada neste caso, já existe o termo matemático bem definido “comutatividade”, algumas operações não dependem da ordem (adição, multiplicação etc.), outras dependem (subtração, divisão etc.), em geral a ordem das operações no código é representada pela posição das linhas (de cima para baixo), mas em código async essa ordem se quebra, e um
asyncConcurrent(...)escrito assim acaba sendo bastante confuso, se você não assimilou completamente o conteúdo do post, fica difícil entender o que isso significa, no Zig (e também no Rust, que eu gosto) esse tipo de abordagem “hipster” aparece com frequência, seria melhor implementar esse sistema procedimental de comutatividade/ordem baseado em async, como os lifetimes do Rust, ou então simplesmente usar algo com que as pessoas já estejam acostumadasnão concordo com a afirmação de que “
asyncConcurrent(...)é confuso”, se você internalizar a ideia central do post do blog, isso não é nada confuso, outra questão é se vale a pena aprender essa ideia, na prática muitas pessoas vão experimentar isso, e com o tempo veremos em uso real se a ideia é boa ou não, e ao substituir por “comutatividade” você na verdade gera mais confusão no Zig, porque lá já existem operadores que obedecem à comutatividade, se forf() + g(), por exemplo, o+é comutativo, então poderia surgir a confusão de achar que o Zig poderia executar isso em paralelo, ordem de execução e comutatividade são coisas completamente diferentes e devem ser distinguidasestritamente falando, comutatividade é uma propriedade válida para operações (binárias), se dizemos que duas instruções async como connect/accept comutam, surge a pergunta “em relação a qual operação?”, no momento o operador bind (
>>=) ou algo como.then(...)é o que mais se aproxima desse papel, mas por enquanto isso ainda está no campo da intuiçãoassincronia também permite ordem parcial, mesmo que duas operações precisem se aposentar na mesma ordem, isso é independente da ordem real de execução, por exemplo, subtração não é comutativa, mas ainda é possível calcular o saldo e o valor a descontar em paralelo com duas queries, e depois aplicar os resultados na ordem apropriada
o fato de outro termo abranger esse conceito não significa que ele seja melhor do que “asynchrony”, a palavra “commutativity” é feia de ler, ouvir e escrever, asynchrony é muito mais familiar
há um limite para a ideia de comutatividade, se A e B comutam cada um com C, então ABC=CAB, mas isso não significa necessariamente que seja igual a ACB, em assincronia é preciso que ABC=ACB=CAB, não sei se já existe um termo matemático para isso
como programador de rede, já escrevi uma enorme quantidade de código concorrente, paralelo e assíncrono, e este texto me parece um pouco confuso, como se estivesse tentando encontrar respostas sobre abstrações cheias de brechas, se a ferramenta ou implementação em si estiver errada, esse tipo de coisa pode “quebrar” com muita facilidade, na verdade depurar código multithread é até bem divertido, ver outras pessoas morrendo de medo desses monstros multithread chega a ser prazeroso