Aguarde...

4 de abril de 2024

Por que escolher async/await em vez de threads?

Por que escolher async/await em vez de threads?

Um refrão comum é que os threads podem fazer tudo o que asyncpodem await, mas de forma mais simples. Então, por que alguém escolheria asyncawait?

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 asyncacontece 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 asyncawaite 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 asynce 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_clientdemora 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 funcionar handle_client().
  • O cliente nº 2 se conecta ao servidor web. No entanto, como accept()não está em execução no momento, temos que esperar handle_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 é accepteditado pelo servidor. O servidor gera um thread que chama handle_client.
  • O cliente nº 2 tenta se conectar ao servidor.
  • Eventualmente, handle_clientbloqueia 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 é asyncawait.

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 asyncawait. O programa acima em termos de asyncawaitficaria 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 asyncpalavra-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.
  • awaitinclui outra máquina de estado como parte da máquina de estado atualmente em execução. Para accept(), 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, Executorsubstituirá a máquina de estado atual por outra que esteja sendo executada simultaneamente, gerada por meio da spawnfunção.
  • Passamos um asyncbloco para a spawnfunção. Este bloco representa uma máquina de estados totalmente nova, independente daquela criada pela mainfunção. Tudo o que esta máquina de estado faz é executar a handle_clientfunção.
  • Assim que mainrender, 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 asyncawaité 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_clientfunçã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:

  • race função pega dois futuros e os executa ao mesmo tempo.
  • 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 asyncbloco 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. asyncapenas 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 readou 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_timeoutset_write_timeoutque 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 tipos set_read_timeoutset_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 asyncawaitpara 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 asyncawaitcomo 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 asynceu awaitposso ser. Se você implementar seu serviço como uma asyncfunçã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 asyncawaitpara operar seu motor. Isso ocorre porque asyncawaité 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 asyncnã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 asyncawaitcom threads do sistema operacional.

Acho que este é um problema consistente em toda a asynccomunidade. 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 “ asynctem menos sobrecarga”. Fora isso, está tudo igual.”

Esta é a razão pela qual os autores do servidor web mudaram para asyncawait. Foi assim que eles resolveram o problema do C10k. Mas esse não será o motivo pelo qual todo mundo mudará para asyncawait.

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 asyncfluxo 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 asyncRust, ao mesmo tempo que minimizamos seus benefícios semânticos.

Na pior das hipóteses, isso faz com que as pessoas ignorem asyncawaitcomo “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 asynco 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– awaitchave 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 asyncawait seja a escolha padrão sempre que um programador busca simultaneidade. Em vez de tentar sincronizar Rust e asyncRust iguais, devemos aceitar as diferenças.

Postado em BlogTags:
Escreva um comentário