Sans-IO: o segredo do Rust para serviços de rede eficazes
(firezone.dev)- A biblioteca de conectividade em Rust da Firezone,
connlib, gerencia conexões de rede e túneis WireGuard, e obtém testes rápidos e alta confiabilidade operacional com um design sans-IO - Os protocolos são implementados como máquinas de estado puras, sem lidar diretamente com sockets, e o loop de eventos chama APIs como
handle_input,poll_transmit,handle_timeoutepoll_timeout - Ao empurrar a escolha de IO para fora da máquina de estado, reduz-se o peso do function colouring no async do Rust, e a aplicação pode decidir entre IO bloqueante, IO não bloqueante ou um runtime async específico
- Ao abstrair sockets e tempo, é possível validar passagem do tempo, perda de pacotes e respostas anômalas usando apenas
InstanteTransmit, sem portas reais nem esperas - Em troca, é preciso gerenciar o loop de eventos manualmente, o que pode introduzir bugs sutis; fluxos sequenciais aumentam o código da máquina de estado; e o ecossistema Rust ainda tem poucas bibliotecas sans-IO
A estrutura sans-IO adotada pelo connlib da Firezone
- A Firezone usa Rust para criar acesso remoto seguro e escalável em Android, macOS e Linux
- No centro de cada app está o
connlib, biblioteca que gerencia conexões de rede e túneis WireGuard para proteger o tráfego - A stack Rust da Firezone usa
tokio,tungstenite,boringtun,rustlse outros, mas sua estrutura interna difere do código async Rust típico- quase não há chamadas a
tokio::spawn - toda a comunicação é multiplexada em um único socket UDP
- APIs como
handle_timeout,poll_transmitehandle_inputse repetem em várias camadas
- quase não há chamadas a
- Essas características sinalizam um design sans-IO, no qual a lógica do protocolo não executa IO diretamente, mas expressa estado e intenções de entrada e saída
- No ecossistema Python existe um site de documentação dedicado a sans-IO, e em Rust as bibliotecas a seguir usam esse padrão
O peso do async Rust e do function colouring
- Em Rust, funções async só podem ser chamadas de dentro de outras funções async, o que cria a restrição de function colouring, em que toda a cadeia de chamadas acaba virando async
- Essa restrição força em tempo de compilação que a possibilidade de pausar a execução e retomá-la depois faça parte do contrato de API da função
- Por causa de uma única função async em um ponto profundo da pilha, outras funções no caminho também podem precisar virar async para usar
.await - Na prática, o trabalho realmente assíncrono costuma acontecer no ponto mais baixo da pilha de chamadas
- escrita em socket
- leitura de arquivo
- espera pela passagem do tempo
- Muitas funções async não fazem trabalho assíncrono diretamente; elas são async apenas porque dependem de outras funções async
- O
connlibda Firezone usa ICE para atravessar NAT e usa STUN para descobrir um candidato server-reflexive, isto é, o endereço público - O binding STUN é um protocolo simples: envia-se um pacote UDP ao servidor e recebe-se uma resposta UDP contendo o IP e a porta vistos pelo servidor
- O mesmo exemplo de STUN pode ser escrito quase da mesma forma tanto com o
UdpSocketasync dotokioquanto com IO bloqueante da biblioteca padrão - Para oferecer funcionalidade STUN como biblioteca, surge duplicação: é preciso escolher entre uma versão async e uma bloqueante, ou incluir as duas
- O código de exemplo está em firezone/sans-io-blog-example
O núcleo do sans-IO: separar política e implementação de IO
- O cerne do sans-IO é parecido com o princípio da inversão de dependência da programação orientada a objetos
- O código de política que decide “o que fazer” não deve depender dos detalhes de implementação que executam “como fazer”
- Se o código que decide enviar uma mensagem de rede depender diretamente do código real de envio pelo socket, o código acima dele também fica preso à escolha entre async e IO bloqueante
- No exemplo de STUN, o código de política é o mesmo, mas ao montá-lo sobre
tokio::UdpSocketele vira async, e sobrestd::net::UdpSocketele vira bloqueante - Em vez de chamar
UdpSocket::senddiretamente, o sans-IO cria uma abstração que representa a intenção de transmissão - No exemplo,
Transmitcontém as seguintes informaçõesSocketAddrde destinopayloada ser enviado
- O código do protocolo não escreve no socket diretamente; ele emite
Transmit - A chamada real a
UdpSocket::sendousend_tofica a cargo do loop de eventos - O código sans-IO precisa ser dirigido por um loop de eventos, assim como um
Futuredo Rust só progride quando é pollado por um runtime
Transformando o binding STUN em máquina de estado
- Uma requisição de binding STUN pode ser modelada como uma máquina de estado com os estados
SenteReceived - No exemplo, os estados são expressos com o seguinte enum
SentReceived { address: SocketAddr }
StunBindingmantém o estado atual e uma fila deTransmitpendentes de envio- As principais APIs têm papéis claros
handle_input: entrega à máquina de estado um pacote vindo do resultado deUdpSocket::recvpoll_transmit: permite ao loop de eventos obter umTransmitque a máquina de estado quer emitirpublic_address: consulta o endereço público recebido
- Nessa estrutura, a lógica do protocolo modela apenas o comportamento do programa, sem IO
- O loop de eventos envia ao socket qualquer pacote obtido via
poll_transmit; se não houver nada para enviar, passa ahandle_inputos dados lidos do socket - O loop de eventos funciona sem precisar saber o detalhe de que STUN é um protocolo de request-response
- Como UDP é um protocolo não confiável, pacotes podem se perder, e o STUN exige um temporizador de retransmissão para mitigar isso
Abstraindo também o tempo
- Em protocolos de rede, quando o tempo atual é necessário, quase sempre é para verificar quanto tempo se passou desde algum ponto de referência
- se já se passaram 5 segundos desde o envio da requisição
- se já se passaram 30 segundos desde o último keep-alive
- Nesses casos, não é preciso o horário real de parede, apenas a
Durationem relação a um instante anterior - O
Instantdo Rust não expõe o horário atual; ele permite medir aDurationentre doisInstant - Uma máquina de estado sans-IO pode ter duas APIs para comportamento baseado em tempo
poll_timeout: retorna quando o loop de eventos deve agendar o próximo timer de wake-uphandle_timeout: informa à máquina de estado que o timer expirou
- O exemplo estende a máquina de estado para enviar uma nova binding request quando se passam 5 segundos desde a última resposta
handle_inputrecebe o pacote junto com oInstantatual e o armazena comoState::Received { address, at }- O loop de eventos trata em conjunto a recepção no socket e a expiração do timer, e depois reconfigura o timer com base no resultado de
poll_timeout
Composição e flexibilidade de API
- As APIs principais de
StunBinding—handle_timeout,handle_input,poll_transmitepoll_timeout— não são específicas apenas de STUN - A maioria dos protocolos de rede pode ser implementada nessa forma, ou em alguma variação dela, o que facilita a composição de máquinas de estado
- Para consultar 5 servidores STUN em busca do IP público, é possível criar 5
StunBindinge chamá-los em sequência- nesse caso, a multiplexação de mensagens STUN precisa ser implementada adequadamente, usando
TransactionIdou o endereço do servidor
- nesse caso, a multiplexação de mensagens STUN precisa ser implementada adequadamente, usando
- O
snownetda Firezone combina ICE e WireGuard para fornecer à aplicação um túnel IP que funciona em diferentes ambientes de rede - O
snowneté construído sobre a biblioteca WebRTC sans-IOstr0me a implementação de WireGuard quase sans-IOboringtun - A Firezone não precisa da stack WebRTC completa, mas apenas do
IceAgent, que implementa a RFC 8445 - Como o
str0msegue a abordagem sans-IO, é fácil pegar apenas oIceAgente compô-lo com as máquinas de estado já existentes no código - A connection do
snownetcontém oIceAgente um túnel WireGuard, e encaminha mensagens recebidas para um dos dois
As vantagens de escrever o próprio loop de eventos
- O código sans-IO apenas representa o estado do sistema e não produz efeitos colaterais, então o loop de eventos precisa consultar o estado, executá-lo e entregar novas entradas
- Essa estrutura pode parecer boilerplate, mas escrever o loop de eventos manualmente permite controle fino
- A aplicação pode escolher diretamente estratégias de execução como
- reduzir o número de chamadas de sistema ao enviar pacotes com
sendmmsg - multiplexar vários protocolos em um único socket
- reduzir o número de chamadas de sistema ao enviar pacotes com
- Autores de bibliotecas podem se concentrar em implementar recursos de protocolo, em vez de discutir runtime async ou expor APIs de opções de socket
- O
str0mtrata a enumeração de interfaces de rede como uma preocupação de IO e a deixa para a aplicação - Em vez disso, fornece apenas uma API para adicionar ao estado atual um endereço de socket como candidato ICE
- Com essa estrutura, a Firezone implementou uma otimização que coleta candidatos TURN antecipadamente, antes de a conexão ser criada, reduzindo a latência de estabelecimento
- No ICE, os dois lados primeiro coletam candidatos, isto é, sockets, e depois testam a conectividade entre eles
Testes rápidos e validação de casos de borda
- Como o código sans-IO é essencialmente livre de efeitos colaterais, ele se encaixa muito bem em testes unitários
- Como sockets e tempo são abstraídos, os testes não precisam abrir portas reais nem esperar tempo passar
- Para testar o comportamento após 5 minutos, basta passar à função um
Instantalterado e verificar a mudança de estado - A Firezone fornece um exemplo real de teste para verificar se o
snownetfecha uma conexão ociosa após 5 minutos - A transmissão de dados também dispensa sockets reais: basta tirar um
Transmitde um lado e entregá-lo aohandle_inputda máquina de estado do outro lado - A Firezone implementou uma máquina de estado de referência que representa como o
connlibdeve se comportar - Essa máquina de estado de referência é usada como base dos testes
- Com state machine testing do
proptest, a cada CI são amostrados e executados de forma determinística milhares de cenários, comparando-se a máquina de estado de referência com o estado real doconnlib - Sem IO, também fica fácil testar falhas e comportamentos anômalos como
- perda de pacotes, impedindo o recebimento de resposta
- recebimento de resposta inválida
- RTT muito alto até o servidor
- ausência de uma interface IPv6 funcional
- existência apenas de interfaces IPv6
- Ao separar a implementação do protocolo dos efeitos colaterais reais de IO, a detecção e o tratamento de erros passam a fazer parte do processamento de entrada da máquina de estado
Por que Rust combina bem com sans-IO
- O Rust força a explicitar qual componente ou função possui cada valor
- Ao ler de um
UdpSocket, é preciso fornecer um&mut [u8]como espaço para receber os bytes reais - Só o dono do valor pode torná-lo mutável ou passar a outra função uma referência mutável temporária
- Esse modelo explícito de posse e mutabilidade é a base de recursos do Rust como o borrow checker
- As APIs de máquina de estado em um design sans-IO são todas funções síncronas e não bloqueiam aguardando IO ou passagem do tempo
- Como a máquina de estado é apenas uma estrutura de dados, fica fácil expressar mudanças de estado com
&mut selfe usar o borrow checker para garantir a soundness do código - Em contrapartida, no async Rust o
&mutpode parecer mais difícil de lidar - Funções async em Rust são compiladas em estruturas de dados que implementam
Future - Para dar
spawnem umFutureem runtimes comotokio, essa estrutura de dados precisa ser'static, então ela não pode conter referências como&mut - Para alterar estado fora do
Future, normalmente usa-se um dos seguintes caminhos- ponteiros com contagem de referências e mutex, como
Arc<Mutex<T>> - abordagem de actors, com várias tasks conectadas por channels
- ponteiros com contagem de referências e mutex, como
- As duas abordagens trazem overhead de runtime
- locks podem gerar contenção
- envio de mensagens por channel exige cópias
- Quando várias tasks executam em ordem não determinística dentro do runtime, isso pode levar a race conditions e deadlocks
- Como código de protocolo sans-IO não dá
spawnem tasks, alterar estado exige apenas&mut self - Sem tasks ou threads, não há necessidade de primitivas de sincronização como
Mutex; sem channels, também diminui a necessidade de copiar dados - A Firezone considera que, após migrar para sans-IO, ficou mais fácil entender o código porque diminuiu o trabalho de rastrear a outra ponta de channels, channels fechados ou onde algum
Mutexestá sendo travado
Desvantagens e escopo de aplicação
- sans-IO não é uma solução universal
- Escrever o loop de eventos manualmente dá muito controle, mas pode introduzir bugs sutis difíceis de encontrar no começo
- Por exemplo, se o valor retornado por
poll_timeoutda máquina de estado não avançar no tempo, o loop de eventos pode cair em busy loop - Fluxos de trabalho sequenciais exigem mais código
- Em Rust, funções async são compiladas como máquinas de estado em que cada ponto de
.awaité uma transição para outro estado, o que permite escrever código sequencial junto com IO não bloqueante com relativa facilidade - No sans-IO, essas etapas precisam ser modeladas manualmente como máquina de estado
- Protocolos request-response como
StunBindingnão são difíceis, mas representar fluxos sequenciais maiores pode ficar tedioso - O design sans-IO ainda não está muito disseminado na comunidade Rust
- A maioria das bibliotecas implementa IO bloqueante ou IO não bloqueante em vez de sans-IO
- O
boringtunchamaInstant::nowinternamente e por isso ainda tem algumas partes não totalmente puras; há uma issue relacionada em cloudflare/boringtun#391
Encerrando
- Código sans-IO pode parecer estranho no começo, mas, com familiaridade, combina bem com as ferramentas do Rust para modelagem de máquinas de estado
- Como a estrutura força o tratamento de erros como mais uma entrada, ela também se encaixa bem na forma de escrever código de rede
- Existem outras maneiras de escrever async Rust, e structured concurrency ocupa um meio-termo entre o sans-IO e a abordagem de async Rust discutida aqui
- Para saber mais sobre structured concurrency, vale consultar Let futures be futures de withoutboats
1 comentários
Opiniões do Hacker News
Embora isso esteja sendo apresentado como inovação e avanço, na verdade, antes de existir suporte da linguagem a async/await, era assim que se lidava com assincronia em
$lang, incluindo RustNo desenvolvimento de firmware embarcado em Rust, o maior ganho de produtividade veio quando pudemos parar de implementar manualmente uma máquina de estados entre cada operação de I/O e de mover variáveis locais para estados customizados, deixando que o Rust fizesse isso por nós com a sintaxe async/await
Em Rust, async acaba se desdobrando em uma máquina de estados automática que armazena os valores entre os pontos de I/O (
await)Mas nem sempre é assim. Em casos de uso centrados em pacotes, como QUIC, WebRTC e IP, o I/O em si é fácil. Basta enviar e receber pacotes/datagramas individuais
Não há muitos pontos de
.await, então não há muito para o compilador gerar. Ao mesmo tempo, vários aspectos precisam rodar simultaneamente e, portanto, cada um precisa entrar em seu próprio future/task; por isso, o gerenciamento de estado ao longo desses futures pode facilmente virar código espagueteCom menos pressupostos sobre o ambiente de runtime, ela fica mais fácil de testar e de compor
Em teoria, seria possível fazer a mesma coisa com async/await gerando a máquina de estados, mas, na prática, isso é bastante sofrido, e a maior parte do código async/await não é pura
Linguagens experimentais como Eff, Koka e Frank têm ótimo suporte a esse estilo de programação. Por trás das discussões de I/O em Haskell também há um investimento profundo em técnicas como free monads e suas variações
Mais recentemente, Unison é uma linguagem interessante: ela explora vários conceitos novos, mas coloca no centro um sistema de efeitos extensível, oferecendo bom suporte a esse tipo de código no nível da linguagem
.awaitpor toda parte. Seria bom se fosse invertido:.awaitexecutado por padrão, e só não acontecesse com alguma sintaxe especialAinda assim, na prática, é comum demais ver bibliotecas de protocolo fazendo I/O diretamente :-(
Eu vinha remoendo essa área de problema na cabeça, e essa abordagem combina muito bem com a direção em que eu estava pensando. Dito isso, como na nota de rodapé 3 do texto, ainda há partes a ajustar
O que me levou a pensar nisso foi a discussão sobre cores de funções e uma descoberta acidental. Eu estava criando uma biblioteca VT100 e os testes unitários eram muito difíceis, porque, na prática, eu estava fazendo
parser::new(stdin()). Na terceira ou quarta reescrita, meio sem pensar, mudei o parser paraparser::push(data), e então percebi que Rust estava punindo aquilo que passei a chamar de antipadrão de OOP corporativo de “obsessão por encapsulamento”Agora vejo esse padrão e seus danos em todo lugar, não só em I/O
Ironicamente, essa solução é algo que se aprende tanto antes da universidade quanto no começo dela. A explicação mais simples de um computador é que ele é uma máquina que recebe entrada, processa/transforma dados e produz saída. A razão de isso se relacionar com a discussão sobre cores de funções é que as únicas coisas cujas cores precisam importar são entrada e saída; a lógica central geralmente é transformação de dados
É uma obviedade, mas, olhando para a escala do “debate” sobre cores de funções, muita gente foi condicionada a resolver problemas primeiro com encapsulamento e acabou deixando esse fato passar ou se esquecendo dele. O pessoal de programação funcional provavelmente fica bem satisfeito nesse ponto
Para mim, Rust foi menos uma jornada de aprendizado e mais uma jornada de desaprender e reaprender. É um bom padrão, e pretendo adotá-lo daqui em diante
Edit: código relacionado: https://codeberg.org/jcdickinson/termkit/src/branch/main/src...
parser::push(data)e a parte em que Rust puniu o antipadrão de OOP corporativo?Como alguém que está aprendendo Rust e ainda é bem iniciante, não fica claro para mim qual é o problema óbvio desse padrão, nem como Rust o pune
Havia parâmetros de tipo e traits por toda parte, e eu abusava de structs como se fossem algo parecido com classes que fornecem funcionalidades
Rust se encaixa melhor quando você evita, sempre que possível, parâmetros de tipo e definições próprias de traits
Encapsulamento no sentido de manter invariantes é bom. Isso me lembra o texto “parse, don’t validate”: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
Como esse design se compara ao uso de canais para enviar dados a handlers dedicados? Ao usar canais, havia vários problemas:
(1) com frequência surgia um código em formato de teia, difícil de acompanhar
(2) era preciso implementar manualmente tipos de mensagem que pudessem ser convertidos em mensagens enviáveis pela rede
(3) era preciso passar explicitamente o emissor para as entidades interessadas ou permitidas
(4) dá para saber se o envio da mensagem pelo canal falhou, mas não se aquela mensagem falhou ao ser transmitida pela rede
Ainda assim, é bem conveniente. Por exemplo, se houver um canal
ws_handler, basta enviar dados para ele, e algum handler dedicado poderá enviar essa mensagem, se possívelsans-IO pode ser usado também em aplicações, mas sinto que é especialmente útil em bibliotecas. Em bibliotecas, isso se torna muito mais útil porque não força o consumidor a adotar um modo específico de I/O
Em Rust, já existe uma divisão no ecossistema entre I/O síncrono e assíncrono, além de diferentes runtimes assíncronos, então esse ponto é importante
Mas, como foi dito, eles trazem problemas. Por exemplo, atores/canais podem ser desconectados. Além disso, para ter contrapressão, o canal precisa necessariamente ser limitado. E, como cópias são necessárias, pode ser difícil atingir alta vazão
Algo relacionado para ver: mônadas, especialmente mônadas Free(r) e sistemas de efeitos[0]
A ideia de separar lógica e execução já é um grande tema bastante explorado no ecossistema Haskell
Edit: não foi mencionado como foram encapsuladas as chamadas
tokio::select!que aparecem quando é preciso lidar com tempo. Será que carregam umtokio::Runtimepor aí para transformar o código do loop em async sem que o código externo precise ser async?Edit 2: talvez a intenção não fosse mostrar que a biblioteca encapsulada faz isso, mas sim que a aplicação externa pode usar o binding em um contexto async
Eu estava mais curioso sobre como implementar, no estilo sans-IO, uma função encapsulada que precise esperar por alguma ação ou timer. Ou talvez a resposta esperada seja espera ocupada, ou carregar uma instância própria de runtime async que, na prática, substitui a espera ocupada por algo como
block_in_place[0]: https://okmij.org/ftp/Computation/free-monad.html
StunBinding. Ela representa a funcionalidade de um binding STUN. Não é uma única função que você simplesmente chama; é preciso um loop de eventosO ponto principal é que
StunBindingpode ficar dentro da biblioteca, e a aplicação pode combiná-lo com a máquina de estados do programa. Claro, isso pressupõe que a própria aplicação também esteja estruturada no estilo sans-IOA biblioteca
snownetlinkada faz exatamente isso. O domínio é combinar ICE + WireGuard sem I/O, e ela é usada pela bibliotecaconnlib, que compõe ACLs por cima dissoEdit: não há espera ocupada. Em vez disso,
StunBindingtem uma função que expõe pelopoll_timeouto que ele está aguardando. Cabe ao chamador, ou seja, ao loop de eventos, decidir como concretizar isso. Quandohandle_timeouté chamado com oInstantcorrespondente, a ação apropriada aconteceAh, é o thomaseizinger!
Já tinha olhado por dentro do rust-libp2p, então, no meio da leitura, esse padrão me pareceu muito familiar; parece que não foi coincidência
O Firezone parece legal. Conecte tudo!
Sim, há semelhanças com o rust-libp2p. Mas lá os streams e conexões reais ainda ficam dentro de uma estrutura parecida com
Future, então é mais emaranhado e não é tão estritamente separado como o sans-IO daquiHá um trecho dizendo que workflows sequenciais exigem mais código. Em Rust, funções async são compiladas para máquinas de estados, e cada ponto
.awaitrepresenta uma transição para outro estado. Isso facilita para o desenvolvedor usar código sequencial junto com I/O não bloqueante. Sem async, é preciso escrever manualmente uma máquina de estados para representar várias etapasAlguém já tentou combinar async e sans-IO? Pelo menos conceitualmente, se você escrevesse uma função async que faz await em helpers cientes de sans-IO, o todo deveria ser compilado para uma máquina de estados dentro de uma struct com uma boa interface sans-IO, podendo ser chamada facilmente também por código não async
Nunca fiz isso pessoalmente, mas os principais problemas esperados parecem ser uma usabilidade razoável e o tratamento de
PinRust tem generators/coroutines que conseguem cobrir em alguma medida o caso de uso mencionado, mas hoje é um recurso muito instável
Infelizmente, as coroutines na forma atual têm a limitação incômoda de serem expostas apenas por meio do trait
std::ops::Coroutine. Por isso, não é possível alocar diretamente a máquina de estados interna gerada pelo compilador, mesmo que o tamanho dessa máquina de estados pareça ser uma constante em tempo de compilaçãoPara uma única coroutine que permaneça apenas dentro de uma função com lifetimes definidos, isso não é problema. O compilador consegue perceber isso e alocar a máquina de estados na stack
Mas talvez o uso mais útil de coroutines seja como elementos de fila de um dispositivo de loop de eventos. Essa implementação é impossível sem encaixotar as coroutines.
Vec>não é uma estrutura de dados amigável ao cache, e, se em I/O de altíssima concorrência você precisar de um milhão de elementos em umVec, vai sentir dorSe Rust algum dia ganhar uma sintaxe nativa de generators, isso talvez se torne possível. Dentro do contexto de uma tarefa async, seria possível dizer
yield transmitpara “escrever” dados. Ou seja, todosocket.writevirariayield transmitAo ler dados, o generator seria suspenso (
.await) e aguardaria ser retomado com os dados recebidos. Não sei se existe uma sintaxe assim no nightly, mas deveria ter mais ou menos esta aparência:// Made up
gensyntax: gen(yield_type, resume_type)gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
let req = make_stun_request();
yield Transmit {
server,
payload: req
};
Em níveis mais altos da pilha, os dois combinam muito bem. I/O não bloqueante, ou seja, async, facilita esperar ao mesmo tempo por I/O de socket e por tempo. Também dá para fazer isso em I/O bloqueante configurando um timeout de leitura no socket, mas usar primitivas async é um pouco mais fácil.
Eu também fiquei pensando em como combinar os dois. Um dos problemas a que cheguei é que funções async são compiladas como tipos opacos. Por isso, fica difícil ou impossível usar o recurso em que o compilador gera código para a máquina de estados, já que depois de criada não dá para interagir com essa máquina de estados. De certa forma, isso também quebra o borrow checker.
Por exemplo, suponha que haja uma tarefa async com várias etapas, isto é, vários pontos de
await, e que apenas um trecho dela precise de uma referência mutável para uma estrutura de dados compartilhada. No momento em que isso é expresso como uma funçãoasync, a referência mutável é capturada no tipoFuturecriado, e esse tipo existe ao longo de todas as etapas. Como resultado, o Rust não permite executar mais de uma dessas tarefas ao mesmo tempo.Normalmente, o conselho nessa situação é “capture a referência mutável pelo menor tempo possível”, mas em async não dá para fazer isso. Dividir a função async em várias também fica bagunçado e, para começo de conversa, acaba desfazendo em parte o objetivo de querer expressar tudo em uma única função.
Um tempo atrás tentei codificar o protocolo HTTP/1.1 como uma máquina de estados Sans-IO com pontos de
.awaitpara I/O, mas não fui muito longe. Só que esse I/O, em vez de registrar um waker no runtime async, devolvia o controle para o usuário realizar o I/O diretamente. Dá para pensar no.awaitcomo algo que se desenrola “para cima”, não “para baixo”.No contexto do HTTP/1.1, o código async se tornava uma espécie de blueprint de como o usuário queria o comportamento de chamada. Na época, eu queria fazê-lo funcionar obrigatoriamente em ambientes
no_stde sem alocador, e desisti porque não encontrei uma forma de evitar o despacho dinâmico viaBox, ou seja, a parte que exige um alocador.https://news.ycombinator.com/item?id=40879547
Há um exemplo escrito no formato
async fn stun. O código completo em funcionamento está aqui: https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...Mandou bem! Se você expõe o estado, pode tornar qualquer função async pura. O usuário só precisa empurrar a máquina de estados para o próximo estado.
Uma vez tentei criar bindings do OpenSSL para async Rust, e aquela API async segue um desenho parecido.
A parte que você considerou parecida é que o trabalho em si, agendado como job, é independente de como ele é executado?
Olhando este exemplo [0], aquela API async parece muito mais com futures do Rust.
Dentro de um job, é possível acessar um “wait context”, suspender sob certas condições e disparar um wake para continuar a execução.
[0]: https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
Isso é simplesmente I/O assíncrono comum usando callbacks em vez de corrotinas.
Lendo o artigo e alguns comentários, soa como se estivessem reinventando a arquitetura hexagonal ou o estilo de arquitetura ports/adapters.
Não tenho muita certeza do que devo tirar disso. Tudo o que está sendo discutido já é programação de rede básica.
Parece haver foco em encanamento de nível mais alto e uma imersão excessiva em gerenciamento de estado, mas isso é apenas questão de gosto e não tem relação com redes.
A coisa mais interessante que aprendi no texto é que a Cloudflare opera um servidor STUN público. Mas isso também não ajuda muito. A versão “boa” e “útil” do protocolo STUN foi a primeira, que oferecia suporte ao recurso
change requests, permitindo enumerar NATs. Nas versões posteriores do STUN, esse recurso foi removido graças às “sugestões úteis” dos engenheiros da Cisco que contribuíram para a especificação.Atualmente, em Rust, se você implementar, por exemplo, uma biblioteca WebRTC que usa o runtime async Tokio, ela se torna muito inconveniente para pessoas que usam I/O síncrono, ou outro runtime (smol, async-std etc.), ou iouring diretamente.
Com essa abordagem, você não impõe ao consumidor a escolha de I/O, então consegue tornar a biblioteca útil para mais gente.