Juntando Tudo: Futures, Tasks e Threads
Como vimos no Capítulo 16, threads oferecem uma forma de concorrência. Neste capítulo, vimos outra abordagem: usar async com futures e streams. Se você está se perguntando quando escolher um método em vez do outro, a resposta é: depende! E, em muitos casos, a escolha não é threads ou async, mas sim threads e async.
Há décadas, muitos sistemas operacionais oferecem modelos de concorrência baseados em threads e, por isso, muitas linguagens de programação também os suportam. No entanto, esses modelos não vêm sem trade-offs. Em muitos sistemas operacionais, threads usam uma quantidade considerável de memória por thread. Além disso, threads só são uma opção quando o sistema operacional e o hardware as suportam. Diferentemente de desktops e dispositivos móveis convencionais, alguns sistemas embarcados nem sequer têm sistema operacional e, portanto, não têm threads.
O modelo async oferece um conjunto diferente, e em última análise
complementar, de trade-offs. No modelo async, operações concorrentes não
precisam de suas próprias threads. Em vez disso, podem ser executadas em
tasks, como quando usamos trpl::spawn_task para iniciar trabalho a partir de
uma função síncrona na seção sobre streams. Uma task é semelhante a uma
thread, mas, em vez de ser gerenciada pelo sistema operacional, é gerenciada
por código em nível de biblioteca: o runtime.
Existe um motivo para as APIs de criação de threads e de criação de tasks serem tão parecidas. Threads funcionam como uma fronteira para conjuntos de operações síncronas; a concorrência é possível entre threads. Tasks funcionam como uma fronteira para conjuntos de operações assíncronas; a concorrência é possível tanto entre quanto dentro das tasks, porque uma task pode alternar entre os futures em seu corpo. Por fim, futures são a unidade mais granular de concorrência em Rust, e cada future pode representar uma árvore de outros futures. O runtime, mais especificamente seu executor, gerencia tasks, e tasks gerenciam futures. Nesse sentido, tasks se parecem com threads leves gerenciadas pelo runtime, com capacidades adicionais que surgem do fato de serem gerenciadas por um runtime e não pelo sistema operacional.
Isso não significa que tasks async sejam sempre melhores do que threads, nem o
contrário. A concorrência com threads é, em alguns aspectos, um modelo de
programação mais simples do que a concorrência com async. Isso pode ser uma
força ou uma fraqueza. Threads são um pouco “dispare e esqueça”: elas não têm
um equivalente nativo a um future e, portanto, simplesmente executam até o
fim, sem interrupção, exceto pelo próprio sistema operacional.
Além disso, threads e tasks muitas vezes funcionam muito bem juntas, porque
tasks podem, pelo menos em alguns runtimes, ser movidas entre threads. De
fato, por baixo dos panos, o runtime que estamos usando, incluindo as funções
spawn_blocking e spawn_task, é multithread por padrão! Muitos runtimes usam
uma abordagem chamada work stealing para mover tasks de forma transparente
entre threads, com base em como elas estão sendo utilizadas no momento, a fim
de melhorar o desempenho geral do sistema. Essa abordagem, na verdade, exige
tasks e threads e, portanto, também futures.
Ao pensar em qual método usar em cada situação, considere estas regras práticas:
- Se o trabalho for muito paralelizável (isto é, CPU-bound), como processar um grande volume de dados em que cada parte pode ser tratada separadamente, threads costumam ser a melhor escolha.
- Se o trabalho for muito concorrente (isto é, I/O-bound), como lidar com mensagens vindas de muitas fontes diferentes e chegando em intervalos ou velocidades diferentes, async costuma ser a melhor escolha.
E, se você precisa tanto de paralelismo quanto de concorrência, não precisa escolher entre threads e async. Você pode usá-los juntos livremente, deixando cada um cumprir o papel em que se sai melhor. Por exemplo, a Listagem 17-25 mostra um exemplo bastante comum desse tipo de combinação em código Rust do mundo real.
extern crate trpl; // for mdbook test
use std::{thread, time::Duration};
fn main() {
let (tx, mut rx) = trpl::channel();
thread::spawn(move || {
for i in 1..11 {
tx.send(i).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
trpl::block_on(async {
while let Some(message) = rx.recv().await {
println!("{message}");
}
});
}
Começamos criando um canal async e, em seguida, iniciando uma thread que toma
ownership do lado transmissor do canal usando a palavra-chave move. Dentro da
thread, enviamos os números de 1 a 10, dormindo um segundo entre cada envio.
Por fim, executamos um future criado com um bloco async passado a
trpl::block_on, como fizemos ao longo deste capítulo. Nesse future,
aguardamos essas mensagens, assim como nos outros exemplos de passagem de
mensagens que vimos.
Voltando ao cenário com o qual abrimos o capítulo, imagine executar um conjunto de tarefas de codificação de vídeo usando uma thread dedicada, porque a codificação de vídeo é limitada por computação, mas notificar a interface de usuário de que essas operações terminaram usando um canal async. Existem inúmeros exemplos desse tipo de combinação em casos de uso do mundo real.
Resumo
Esta não é a última vez que você verá concorrência neste livro. O projeto do Capítulo 21 vai aplicar esses conceitos em uma situação mais realista do que os exemplos mais simples discutidos aqui e comparar de forma mais direta a resolução de problemas com threads versus tasks e futures.
Independentemente de qual dessas abordagens você escolha, Rust oferece as ferramentas necessárias para escrever código seguro, rápido e concorrente, seja para um servidor web de alta vazão, seja para um sistema operacional embarcado.
A seguir, falaremos sobre formas idiomáticas de modelar problemas e estruturar soluções à medida que seus programas em Rust ficarem maiores. Além disso, discutiremos como os idiomatismos de Rust se relacionam com aqueles que você talvez conheça da programação orientada a objetos.