3 pontos por GN⁺ 2024-07-05 | 1 comentários | Compartilhar no WhatsApp
  • 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_timeout e poll_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 Instant e Transmit, 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, rustls e 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_transmit e handle_input se repetem em várias camadas
  • 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
    • quinn: implementação independente de QUIC
    • quiche: implementação de QUIC da Cloudflare
    • str0m: implementação WebRTC sans-IO

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 connlib da 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 UdpSocket async do tokio quanto 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::UdpSocket ele vira async, e sobre std::net::UdpSocket ele vira bloqueante
  • Em vez de chamar UdpSocket::send diretamente, o sans-IO cria uma abstração que representa a intenção de transmissão
  • No exemplo, Transmit contém as seguintes informações
    • SocketAddr de destino
    • payload a ser enviado
  • O código do protocolo não escreve no socket diretamente; ele emite Transmit
  • A chamada real a UdpSocket::send ou send_to fica a cargo do loop de eventos
  • O código sans-IO precisa ser dirigido por um loop de eventos, assim como um Future do 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 Sent e Received
  • No exemplo, os estados são expressos com o seguinte enum
    • Sent
    • Received { address: SocketAddr }
  • StunBinding mantém o estado atual e uma fila de Transmit pendentes de envio
  • As principais APIs têm papéis claros
    • handle_input: entrega à máquina de estado um pacote vindo do resultado de UdpSocket::recv
    • poll_transmit: permite ao loop de eventos obter um Transmit que a máquina de estado quer emitir
    • public_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 a handle_input os 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 Duration em relação a um instante anterior
  • O Instant do Rust não expõe o horário atual; ele permite medir a Duration entre dois Instant
  • 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-up
    • handle_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_input recebe o pacote junto com o Instant atual e o armazena como State::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 StunBindinghandle_timeout, handle_input, poll_transmit e poll_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 StunBinding e chamá-los em sequência
    • nesse caso, a multiplexação de mensagens STUN precisa ser implementada adequadamente, usando TransactionId ou o endereço do servidor
  • O snownet da 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-IO str0m e a implementação de WireGuard quase sans-IO boringtun
  • A Firezone não precisa da stack WebRTC completa, mas apenas do IceAgent, que implementa a RFC 8445
  • Como o str0m segue a abordagem sans-IO, é fácil pegar apenas o IceAgent e compô-lo com as máquinas de estado já existentes no código
  • A connection do snownet contém o IceAgent e 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
  • 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 str0m trata 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 Instant alterado e verificar a mudança de estado
  • A Firezone fornece um exemplo real de teste para verificar se o snownet fecha uma conexão ociosa após 5 minutos
  • A transmissão de dados também dispensa sockets reais: basta tirar um Transmit de um lado e entregá-lo ao handle_input da máquina de estado do outro lado
  • A Firezone implementou uma máquina de estado de referência que representa como o connlib deve 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 do connlib
  • 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 self e usar o borrow checker para garantir a soundness do código
  • Em contrapartida, no async Rust o &mut pode parecer mais difícil de lidar
  • Funções async em Rust são compiladas em estruturas de dados que implementam Future
  • Para dar spawn em um Future em runtimes como tokio, 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
  • 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á spawn em 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 Mutex está 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_timeout da 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 StunBinding nã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 boringtun chama Instant::now internamente 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

 
GN⁺ 2024-07-05
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 Rust
    No 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)

    • Eu pretendia abordar esse ponto no fim do texto. Se o que você está implementando é, em grande parte, operações de I/O sequenciais, esse modelo fica bem doloroso
      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 espaguete
    • Acho que essa explicação não está exatamente correta. O ponto importante aqui é que a máquina de estados não faz I/O diretamente. Ela sempre delega esse trabalho ao event loop que a hospeda, então pode ser interpretada em vários contextos
      Com 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
    • Nos anos 1980, era assim que se fazia I/O em assembly. De que outro jeito você escreveria uma implementação de YMODEM baseada em interrupções?
    • Sim. A única coisa irritante em async é ter que escrever .await por toda parte. Seria bom se fosse invertido: .await executado por padrão, e só não acontecesse com alguma sintaxe especial
    • Não foi apresentado como revolucionário. O texto diz isto: “Nós não inventamos este padrão! No mundo Python, existe até um site dedicado a ele.”
      Ainda 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 para parser::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...

    • Você poderia explicar melhor a parte em que mudou para 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
    • Eu também vim do mundo OOP para Rust, e já se passaram 6 anos. Nos primeiros 2 ou 3 anos, escrevi códigos horríveis
      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ível

    • O item 4 pode ser implementado passando junto com a mensagem um canal para devolver o resultado. Se quiser, também dá para bloquear o lado emissor até o ponto da chamada
      sans-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
    • Se for aceitável levar o software para um design parecido com atores, canais funcionam bem
      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 um tokio::Runtime por 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

    • Aqui, a “função encapsulada” é a struct StunBinding. Ela representa a funcionalidade de um binding STUN. Não é uma única função que você simplesmente chama; é preciso um loop de eventos
      O ponto principal é que StunBinding pode 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-IO
      A biblioteca snownet linkada faz exatamente isso. O domínio é combinar ICE + WireGuard sem I/O, e ela é usada pela biblioteca connlib, que compõe ACLs por cima disso
      Edit: não há espera ocupada. Em vez disso, StunBinding tem uma função que expõe pelo poll_timeout o que ele está aguardando. Cabe ao chamador, ou seja, ao loop de eventos, decidir como concretizar isso. Quando handle_timeout é chamado com o Instant correspondente, a ação apropriada acontece
  • Ah, é 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!

    • Obrigado!
      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 daqui
  • Há 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 .await representa 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 etapas
    Algué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 Pin

    • Rust 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ção
      Para 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 um Vec, vai sentir dor

    • Se 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 transmit para “escrever” dados. Ou seja, todo socket.write viraria yield transmit
      Ao 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 gen syntax: gen(yield_type, resume_type)
      gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
      let req = make_stun_request();

yield Transmit {
server,
payload: req
};

let res = .await; // Sintaxe inventada de "suspender e retomar com argumento".

let addr = parse_stun_response(res);

addr  
}
  • 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ção async, a referência mutável é capturada no tipo Future criado, 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 .await para 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 .await como 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_std e sem alocador, e desisti porque não encontrei uma forma de evitar o despacho dinâmico via Box, 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.

    • Procurando rapidamente, o OpenSSL tem uma API de “async job”. Mas ela parece fazer I/O. Além disso, está escrito que criar jobs é uma operação muito cara, então eles devem ser reutilizados.
      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.

    • No contexto de Rust, vejo a grande vantagem como resolver o problema da cor das funções. Como o texto explica, os testes também ficam realmente simples.
      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.