Um refrão comum é que os threads podem fazer tudo o que async
podem await
, mas de forma mais simples. Então, por que alguém escolheria async
/ await
?
Esta é uma pergunta comum que tenho visto muito na comunidade Rust. Francamente, entendo perfeitamente de onde isso vem.
Rust é uma linguagem de baixo nível que não esconde de você a complexidade das corrotinas. Isso se opõe a linguagens como Go, onde async
acontece por padrão, sem que o programador precise sequer considerar isso.
Programadores inteligentes tentam evitar a complexidade. Então, eles veem a complexidade extra em async
/ await
e questionam por que ela é necessária. Esta questão é especialmente pertinente quando se considera que existe uma alternativa razoável em threads de sistema operacional.
Vamos fazer uma viagem mental async
e ver como isso se compara.
Blitz de fundo
Rust é uma linguagem de baixo nível. Normalmente, o código é linear; uma coisa corre atrás da outra. Se parece com isso:
fn main() {
foo();
bar();
baz();
}
Bonito e simples, certo?
No entanto, às vezes você desejará executar muitas coisas ao mesmo tempo. O exemplo canônico disso é um servidor web. Considere o seguinte escrito em código linear:
fn main() -> io::Result<()> {
let socket = TcpListener::bind("0.0.0.0:80")?;
loop {
let (client, _) = socket.accept()?;
handle_client(client)?;
}
}
Imagine que handle_client
demora alguns milissegundos e dois clientes tentam se conectar ao seu servidor web ao mesmo tempo. Você terá um problema sério!
- O cliente nº 1 se conecta ao servidor web e é aceito pela
accept()
função. Ele começa a funcionarhandle_client()
. - O cliente nº 2 se conecta ao servidor web. No entanto, como
accept()
não está em execução no momento, temos que esperarhandle_client()
que o Cliente nº 1 termine de executar. - Depois de esperar alguns milissegundos, voltamos para
accept()
. O cliente nº 2 pode se conectar.
Agora imagine que em vez de dois clientes, existam dois milhões de clientes simultâneos. No final da fila, você terá que esperar alguns minutos antes que o servidor web possa ajudá-lo. Torna-se inescalável muito rapidamente.
Obviamente, a teia embrionária tentou resolver este problema. A solução original foi introduzir o threading. Ao salvar o valor de alguns registradores e da pilha do programa na memória, o sistema operacional pode parar um programa, executar outro programa em seu lugar e, em seguida, retomar a execução desse programa mais tarde. Essencialmente, ele permite que múltiplas rotinas (ou “threads” ou “processos”) sejam executadas na mesma CPU.
Usando threads, podemos reescrever o código acima da seguinte forma:
fn main() -> io::Result<()> {
let socket = TcpListener::bind("0.0.0.0:80")?;
loop {
let (client, _) = socket.accept()?;
thread::spawn(move || handle_client(client));
}
}
Agora, o cliente está sendo tratado por um thread separado daquele que está aguardando novas conexões. Ótimo! Isso evita o problema, permitindo acesso simultâneo ao thread.
- O cliente nº 1 é
accept
editado pelo servidor. O servidor gera um thread que chamahandle_client
. - O cliente nº 2 tenta se conectar ao servidor.
- Eventualmente,
handle_client
bloqueia alguma coisa. O sistema operacional salva o thread que manipula o Cliente nº 1 e traz de volta o thread principal. - O thread principal
accept
é o Cliente nº 2. Ele gera um thread separado para lidar com o Cliente nº 2. Com apenas alguns microssegundos de atraso, o Cliente nº 1 e o Cliente nº 2 são executados em paralelo.
Threads funcionam especialmente bem quando você considera que os servidores web de nível de produção têm dezenas de núcleos de CPU. Não é apenas que o sistema operacional pode dar a ilusão de que todos esses threads são executados ao mesmo tempo; é que o sistema operacional pode realmente fazer com que todos sejam executados ao mesmo tempo.
Eventualmente, por motivos que explicarei mais adiante, os programadores queriam trazer essa simultaneidade do espaço do sistema operacional para o espaço do usuário. Existem muitos modelos diferentes para simultaneidade de espaço de usuário. Há programação, atores e corrotinas orientadas a eventos. O que Rust escolheu é async
/ await
.
Para simplificar demais, você compila o programa como um conjunto de máquinas de estado que podem ser executadas independentemente umas das outras. O próprio Rust fornece um mecanismo para criar máquinas de estado; o mecanismo de async
e await
. O programa acima em termos de async
/ await
ficaria assim, escrito usando smol
:
#[apply(smol_macros::main!)]
async fn main(ex: &smol::Executor) -> io::Result<()> {
let socket = TcpListener::bind("0.0.0.0:80").await?;
loop {
let (client, _) = socket.accept().await?;
ex.spawn(async move {
handle_client(client).await;
}).detach();
}
}
- A função principal é precedida pela
async
palavra-chave. Isso significa que não é uma função tradicional, mas sim uma que retorna uma máquina de estados. Aproximadamente, o conteúdo da função corresponde a essa máquina de estados. await
inclui outra máquina de estado como parte da máquina de estado atualmente em execução. Paraaccept()
, significa que a máquina de estados irá incluí-lo como uma etapa.- Eventualmente, uma das funções internas cederá ou abrirá mão do controle. Por exemplo, quando
accept()
espera por uma nova conexão. Neste ponto, toda a máquina de estados entregará sua execução ao executor de nível superior. Para nós, isso ésmol::Executor
. - Assim que a execução for concluída,
Executor
substituirá a máquina de estado atual por outra que esteja sendo executada simultaneamente, gerada por meio daspawn
função. - Passamos um
async
bloco para aspawn
função. Este bloco representa uma máquina de estados totalmente nova, independente daquela criada pelamain
função. Tudo o que esta máquina de estado faz é executar ahandle_client
função. - Assim que
main
render, um dos clientes é selecionado para funcionar em seu lugar. Assim que o cliente cede, o ciclo se repete. - Agora você pode lidar com milhões de clientes simultâneos.
É claro que a simultaneidade do espaço do usuário como essa introduz um aumento na complexidade. Ao usar threads, você não precisa lidar com executores, tarefas, máquinas de estado e tudo mais.
Se você for uma pessoa razoável, poderá estar se perguntando “por que precisamos fazer tudo isso? Os threads funcionam bem; para 99% dos programas, não precisamos envolver nenhum tipo de simultaneidade de espaço de usuário. A introdução de uma nova complexidade é uma dívida técnica, e a dívida técnica custa-nos tempo e dinheiro.
“Então por que não usaríamos threads?”
Problema de tempo limite
Talvez um dos maiores pontos fortes do Rust seja a capacidade de composição . Ele fornece um conjunto de abstrações que podem ser aninhadas, construídas, reunidas e expandidas.
Lembro que o que me fez continuar com Rust foi a Iterator
característica. Fiquei surpreso ao saber que você poderia fazer algo an Iterator
, aplicar um punhado de combinadores diferentes e depois passar o resultado Iterator
para qualquer função que usasse um Iterator
.
Continua a me impressionar o quão poderoso é. Digamos que você queira receber uma lista de números inteiros de outro thread, pegue apenas aqueles que estão imediatamente disponíveis, descarte quaisquer números inteiros que não sejam pares, adicione um a todos eles e, em seguida, coloque-os em uma nova lista.
Seriam cinquenta linhas e uma função auxiliar em alguns outros idiomas. No Rust isso pode ser feito em cinco:
let (send, recv) = mpsc::channel();
my_list.extend(
recv.try_iter()
.filter(|x| x & 1 == 0)
.map(|x| x + 1)
);
A melhor coisa sobre async
/ await
é que ele permite aplicar essa capacidade de composição a funções vinculadas a E/S. Digamos que você tenha um novo requisito de cliente; você deseja adicionar um tempo limite à função acima. Suponha que nossa handle_client
função acima seja assim:
async fn handle_client(client: TcpStream) -> io::Result<()> {
let mut data = vec![];
client.read_to_end(&mut data).await?;
let response = do_something_with_data(data).await?
client.write_all(&response).await?;
Ok(())
}
Se quisermos adicionar, digamos, um tempo limite de três segundos, podemos combinar dois combinadores para fazer isso:
- A
race
função pega dois futuros e os executa ao mesmo tempo. - O
Timer
futuro espera algum tempo antes de retornar.
Aqui está a aparência do código final:
async fn handle_client(client: TcpStream) -> io::Result<()> {
// Future that handles the actual connection.
let driver = async move {
let mut data = vec![];
client.read_to_end(&mut data).await?;
let response = do_something_with_data(data).await?
client.write_all(&response).await?;
Ok(())
};
// Future that handles waiting for a timeout.
let timeout = async {
Timer::after(Duration::from_secs(3)).await;
// We just hit a timeout! Return an error.
Err(io::ErrorKind::TimedOut.into())
};
// Run both in parallel.
driver.race(timeout).await
}
Acho que este é um processo muito fácil. Tudo o que você precisa fazer é agrupar seu código existente em um async
bloco e competi-lo contra outro futuro.
Um bônus adicional dessa abordagem é que ela funciona com qualquer tipo de stream. Aqui, usamos um TcpStream
. No entanto, podemos substituí-lo facilmente por qualquer coisa que implemente o impl AsyncRead + AsyncWrite
. Pode ser um fluxo GZIP sobre o fluxo normal, ou um soquete Unix, ou um arquivo. async
apenas desliza para qualquer padrão que você precisar.
Tópicos Temáticos
E se quiséssemos implementar isso em nosso exemplo encadeado acima?
fn handle_client(client: TcpStream) -> io::Result<()> {
let mut data = vec![];
client.read_to_end(&mut data)?;
let response = do_something_with_data(data)?
client.write_all(&response)?;
Ok(())
}
Bem, não é fácil. Geralmente, você não pode interromper as chamadas do sistema read
ou write
no código de bloqueio, sem fazer algo catastrófico como fechar o descritor de arquivo (o que não pode ser feito no Rust).
Felizmente, TcpStream
possui duas funções set_read_timeout
e set_write_timeout
que podem ser utilizadas para definir os tempos limite de leitura e escrita, respectivamente. No entanto, não podemos simplesmente usá-lo ingenuamente. Imagine um cliente que envia um byte a cada 2,9 segundos, apenas para zerar o tempo limite.
Então temos que programar um pouco defensivamente aqui. Devido ao poder dos combinadores Rust, podemos escrever nosso próprio encapsulamento de tipo TcpStream
para programar o tempo limite.
// Deadline-aware wrapper around `TcpStream.
struct DeadlineStream {
tcp: TcpStream,
deadline: Instant
}
impl DeadlineStream {
/// Create a new `DeadlineStream` that expires after some time.
fn new(tcp: TcpStream, timeout: Duration) -> Self {
Self {
tcp,
deadline: Instant::now() + timeout,
}
}
}
impl io::Read for DeadlineStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
// Set the deadline.
let time_left = self.deadline.saturating_duration_since(Instant::now());
self.tcp.set_read_timeout(Some(time_left))?;
// Read from the stream.
self.tcp.read(buf)
}
}
impl io::Write for DeadlineStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
// Set the deadline.
let time_left = self.deadline.saturating_duration_since(Instant::now());
self.tcp.set_write_timeout(Some(time_left))?;
// Read from the stream.
self.tcp.write(buf)
}
}
// Create the wrapper.
let client = DeadlineStream::new(client, Duration::from_secs(3));
let mut data = vec![];
client.read_to_end(&mut data)?;
let response = do_something_with_data(data)?
client.write_all(&response)?;
Ok(())
Por um lado, pode-se argumentar que isto é elegante. Usamos os recursos do Rust para resolver o problema com um combinador relativamente simples. Tenho certeza de que funcionaria bem o suficiente.
Por outro lado, é definitivamente hacky.
- Nós nos trancamos em usar
TcpStream
. Não há nenhuma característica no Rust para abstrair o uso dos tiposset_read_timeout
eset_write_timeout
. Portanto, seria necessário muito trabalho adicional para usar qualquer tipo de escritor. - Envolve uma chamada de sistema extra para definir o tempo limite.
- Imagino que esse tipo seja muito mais difícil de usar para os tipos de lógica real que os servidores da Web exigem.
Se eu visse esse código em produção, perguntaria ao autor por que eles evitaram usar async
/ await
para resolver esse problema. Este é o fenômeno que descrevi em meu post “Por que você pode realmente querer assíncrono em seu projeto”. Frequentemente encontro um padrão em que o código síncrono não pode ser usado sem contorção, então tenho que reescrevê-lo em async
.
Histórias de sucesso assíncronas
Há uma razão pela qual o ecossistema HTTP adotou async
/ await
como seu principal mecanismo de tempo de execução, mesmo para clientes. Você pode pegar qualquer função que faça uma chamada HTTP e ajustá-la a qualquer buraco ou caso de uso que você desejar.
tower
é provavelmente o melhor exemplo desse fenômeno que posso imaginar, e é realmente o que me fez perceber o quão poderoso async
eu await
posso ser. Se você implementar seu serviço como uma async
função, obterá tempos limite, limitação de taxa, balanceamento de carga, hedge e tratamento de contrapressão. Tudo isso de graça.
Não importa qual tempo de execução você usou ou o que você realmente está fazendo no seu serviço. Você pode jogá tower
-lo para torná-lo mais robusto.
macroquad
é um motor de jogo Rust em miniatura que visa tornar o desenvolvimento de jogos o mais fácil possível. Sua função principal utiliza async
/ await
para operar seu motor. Isso ocorre porque async
/ await
é realmente a melhor maneira em Rust de expressar uma função linear que precisa ser interrompida para esperar por outra coisa.
Na prática, isso pode ser extremamente poderoso. Imagine sondar simultaneamente uma conexão de rede para o seu servidor de jogo e sua estrutura GUI, no mesmo thread. As possibilidades são infinitas.
Melhorando a imagem do Async
Não acho que o problema seja que algumas pessoas pensem que threads são melhores que async
. Acho que o problema é que os benefícios async
não são amplamente divulgados. Isso leva algumas pessoas a ficarem mal informadas sobre os benefícios do async
.
Se este é um problema educacional, acho que vale a pena dar uma olhada no material educativo. Aqui está o que o Rust Async Book diz ao comparar async
/ await
com threads do sistema operacional.
Acho que este é um problema consistente em toda a async
comunidade. Quando alguém faz a pergunta “por que queremos usar isso em threads de sistema operacional”, as pessoas tendem a acenar com a mão e dizer “ async
tem menos sobrecarga”. Fora isso, está tudo igual.”
Esta é a razão pela qual os autores do servidor web mudaram para async
/ await
. Foi assim que eles resolveram o problema do C10k. Mas esse não será o motivo pelo qual todo mundo mudará para async
/ await
.
Os ganhos de desempenho são inconstantes e podem desaparecer nas circunstâncias erradas. Há muitos casos em que um fluxo de trabalho encadeado pode ser mais rápido que um async
fluxo de trabalho equivalente (principalmente no caso de tarefas vinculadas à CPU). Acho que nós, como comunidade, enfatizamos demais os benefícios efêmeros de desempenho do async
Rust, ao mesmo tempo que minimizamos seus benefícios semânticos.
Na pior das hipóteses, isso faz com que as pessoas ignorem async
/ await
como “uma coisa estranha a que você recorre para casos de uso de nicho”. Deve ser visto como um modelo de programação poderoso que permite expressar de forma sucinta padrões que não podem ser expressos em Rust síncrono sem dezenas de threads e canais.
Também acho que há uma tendência de tentar tornar async
o Rust “exatamente como sincronizar o Rust” de uma forma que incentive a comparação negativa. Por “tendência”, quero dizer que é o roteiro declarado para o projeto Rust, dizendo que “escrever código Rust assíncrono deve ser tão fácil quanto escrever código de sincronização, além da palavra async
– await
chave ocasional”.
Rejeito esse enquadramento porque é fundamentalmente impossível. É como tentar organizar uma festa de pizza em uma pista de esqui. Claro, você provavelmente conseguirá chegar a 99% do caminho, especialmente se for realmente talentoso. Mas há diferenças que o urso médio notará , não importa quão bom você seja.
Não deveríamos tentar forçar nosso modelo a usar expressões hostis para apaziguar programadores que se recusam a adotar outro tipo de padrão. Deveríamos tentar destacar os pontos fortes do ecossistema async
/ Rust await
; sua composibilidade e seu poder. Deveríamos tentar fazer com que async
/ await
seja a escolha padrão sempre que um programador busca simultaneidade. Em vez de tentar sincronizar Rust e async
Rust iguais, devemos aceitar as diferenças.