Uma Análise Mais Detalhada das Traits de Async
Ao longo do capítulo, usamos as traits Future, Stream e StreamExt
de várias maneiras. Até aqui, porém, evitamos entrar muito nos detalhes de
como elas funcionam ou como se encaixam, o que é suficiente na maior parte do
tempo para o trabalho cotidiano com Rust. Às vezes, no entanto, você vai se
deparar com situações em que precisará entender um pouco melhor os detalhes
dessas traits, junto com o tipo Pin e a trait Unpin. Nesta seção, vamos
nos aprofundar apenas o bastante para ajudar nesses cenários, deixando o
mergulho realmente profundo para outras documentações.
A Trait Future
Vamos começar olhando mais de perto como a trait Future funciona. Veja como
o Rust a define:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}
Essa definição de trait inclui vários tipos novos e também uma sintaxe que ainda não vimos, então vamos analisá-la parte por parte.
Primeiro, o tipo associado Output de Future diz em que valor o future
resulta. Isso é análogo ao tipo associado Item da trait Iterator.
Em segundo lugar, Future tem o método poll, que recebe uma referência
especial Pin para seu parâmetro self, além de uma referência mutável para
um Context, e retorna um Poll<Self::Output>. Falaremos mais sobre Pin e
Context daqui a pouco. Por enquanto, vamos focar no que o método retorna, o
tipo Poll:
#![allow(unused)]
fn main() {
pub enum Poll<T> {
Ready(T),
Pending,
}
}
Esse tipo Poll é semelhante a um Option. Ele tem uma variante com valor,
Ready(T), e outra sem valor, Pending. Mas Poll significa algo bem
diferente de Option! A variante Pending indica que o future ainda tem
trabalho a fazer, então o chamador precisará verificá-lo novamente mais tarde.
A variante Ready indica que o Future concluiu seu trabalho e que o valor
T está disponível.
Nota: raramente é necessário chamar
polldiretamente, mas, se você precisar, lembre-se de que, com a maioria dos futures, o chamador não deve chamarpollnovamente depois que o future tiver retornadoReady. Muitos futures entram empanic!se forem consultados outra vez depois de ficarem prontos. Futures que podem ser consultados novamente com segurança dizem isso explicitamente em sua documentação. Isso é semelhante ao comportamento deIterator::next.
Quando você vê um código que usa await, o Rust o compila internamente para
código que chama poll. Se você voltar à Listagem 17-4, em que imprimimos o
título da página de uma única URL quando ela é resolvida, o Rust o compila em
algo mais ou menos assim, embora não exatamente:
match page_title(url).poll() {
Ready(page_title) => match page_title {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
Pending => {
// But what goes here?
}
}
O que devemos fazer quando o future ainda está em Pending? Precisamos de
alguma forma de tentar de novo, e de novo, e de novo, até que ele finalmente
esteja pronto. Em outras palavras, precisamos de um loop:
let mut page_title_fut = page_title(url);
loop {
match page_title_fut.poll() {
Ready(value) => match page_title {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
Pending => {
// continue
}
}
}
Se o Rust o compilasse exatamente para esse código, porém, cada await seria
bloqueante, justamente o oposto do que queremos! Em vez disso, o Rust garante
que o loop possa transferir o controle para algo que consiga pausar o trabalho
nesse future, executar outros futures e depois voltar para verificar este mais
tarde. Como vimos, esse algo é um runtime assíncrono, e esse trabalho de
agendamento e coordenação é uma de suas funções principais.
Na seção “Enviando dados entre duas tarefas usando passagem de
mensagens”, descrevemos a espera em
rx.recv. A chamada a recv retorna um future, e aguardar esse future faz com
que ele seja consultado. Observamos que um runtime pausa o future até que ele
fique pronto com Some(message) ou com None, quando o canal é fechado. Com
essa compreensão mais profunda da trait Future, e especificamente de
Future::poll, podemos ver como isso funciona. O runtime sabe que o future
não está pronto quando ele retorna Poll::Pending. Por outro lado, o runtime
sabe que o future está pronto e o avança quando poll retorna
Poll::Ready(Some(message)) ou Poll::Ready(None).
Os detalhes exatos de como um runtime faz isso estão fora do escopo deste livro, mas o importante é entender a mecânica básica dos futures: um runtime faz poll em cada future sob sua responsabilidade e o coloca de volta em espera quando ele ainda não está pronto.
O tipo Pin e a trait Unpin
Voltando à Listagem 17-13, usamos a macro trpl::join! para aguardar três
futures. No entanto, é comum ter uma coleção, como um vetor, contendo algum
número de futures que só será conhecido em tempo de execução. Vamos alterar a
Listagem 17-13 para o código da Listagem 17-23, que coloca os três futures em
um vetor e chama a função trpl::join_all, que ainda não compilará.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (tx, mut rx) = trpl::channel();
let tx1 = tx.clone();
let tx1_fut = async move {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("future"),
];
for val in vals {
tx1.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let rx_fut = async {
while let Some(value) = rx.recv().await {
println!("received '{value}'");
}
};
let tx_fut = async move {
// --snip--
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];
for val in vals {
tx.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let futures: Vec<Box<dyn Future<Output = ()>>> =
vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];
trpl::join_all(futures).await;
});
}
Colocamos cada future dentro de um Box para transformá-los em objetos trait,
assim como fizemos na seção “Retornando erros de run” no Capítulo 12.
(Abordaremos objetos trait em detalhes no Capítulo 18.) Usar objetos trait nos
permite tratar cada um dos futures anônimos produzidos por esses tipos como o
mesmo tipo, porque todos implementam a trait Future.
Isso pode ser surpreendente. Afinal, nenhum dos blocos async retorna nada, então
cada um produz um Future<Output = ()>. Lembre-se, porém, de que Future é
uma trait, e o compilador cria uma enumeração exclusiva para cada bloco async,
mesmo quando eles têm tipos de saída idênticos. Assim como você não pode
colocar duas structs manuscritas diferentes em um Vec, você também não pode
misturar enums geradas pelo compilador.
Em seguida, passamos a coleção de futures para a função trpl::join_all e
aguardamos o resultado. No entanto, isso não compila; aqui está a parte
relevante das mensagens de erro.
error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
--> src/main.rs:48:33
|
48 | trpl::join_all(futures).await;
| ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
|
= note: consider using the `pin!` macro
consider using `Box::pin` if you need to access the pinned value outside of the current scope
= note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
--> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
|
27 | pub struct JoinAll<F>
| ------- required by a bound in this struct
28 | where
29 | F: Future,
| ^^^^^^ required by this bound in `JoinAll`
A observação nessa mensagem de erro nos diz que devemos usar a macro pin!
para fixar os valores, isto é, colocá-los dentro do tipo Pin, que garante
que eles não serão movidos na memória. A mensagem de erro diz que isso é
necessário porque dyn Future<Output = ()> precisa implementar a trait
Unpin, e atualmente não implementa.
A função trpl::join_all retorna uma struct chamada JoinAll. Essa struct é
genérica sobre um tipo F, que é restringido a implementar a trait Future.
Aguardar diretamente um future com await o fixa implicitamente. É por isso
que não precisamos usar pin! em todo lugar em que queremos aguardar futures.
No entanto, aqui não estamos aguardando diretamente um future. Em vez disso,
construímos um novo future, JoinAll, ao passar uma coleção de futures para a
função join_all. A assinatura de join_all exige que os tipos dos itens da
coleção implementem a trait Future, e Box<T> só implementa Future se o
T encapsulado for um future que implemente a trait Unpin.
Isso é bastante coisa para absorver! Para realmente entender, vamos nos
aprofundar um pouco mais em como a trait Future funciona na prática,
especialmente em relação ao pinning. Observe novamente sua definição:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
// Required method
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}
O parâmetro cx e seu tipo Context são a chave para como um runtime
realmente sabe quando verificar um dado future enquanto ele continua sendo
lazy. Novamente, os detalhes de como isso funciona estão fora do escopo
deste capítulo, e normalmente você só precisa pensar nisso ao escrever uma
implementação personalizada de Future. Em vez disso, vamos nos concentrar no
tipo de self, porque esta é a primeira vez que vemos um método em que self
tem uma anotação de tipo. Uma anotação de tipo para self funciona como as
anotações de tipo para outros parâmetros de função, mas com duas diferenças
principais:
- Informa ao Rust qual tipo
selfdeve ser para que o método seja chamado. - Não pode ser qualquer tipo. Está restrito ao tipo em que o método está
implementado, uma referência ou smart pointer para esse tipo, ou um
Pinenvolvendo uma referência a esse tipo.
Veremos mais sobre essa sintaxe no Capítulo 18. Por
enquanto, basta saber que, se quisermos fazer poll em um future para verificar
se ele está em Pending ou em Ready(Output), precisamos de uma referência
mutável ao tipo encapsulada em Pin.
Pin é um invólucro para tipos parecidos com ponteiros, como &, &mut,
Box e Rc. (Tecnicamente, Pin funciona com tipos que implementam as traits
Deref ou DerefMut, mas isso equivale, na prática, a trabalhar apenas com
referências e smart pointers.) Pin não é um ponteiro em si e não tem
comportamento próprio, como Rc e Arc têm com contagem de referências; ele é
puramente uma ferramenta que o compilador pode usar para impor restrições ao
uso de ponteiros.
Lembrar que await é implementado em termos de chamadas a poll começa a
explicar a mensagem de erro que vimos antes, mas ela falava em Unpin, não em
Pin. Então, como exatamente Pin se relaciona com Unpin, e por que
Future precisa que self esteja em um tipo Pin para chamar poll?
Lembre-se de que, como vimos anteriormente neste capítulo, uma série de pontos de await em um future é compilada em uma máquina de estados, e o compilador garante que essa máquina de estados siga todas as regras normais de segurança de Rust, incluindo borrowing e ownership. Para fazer isso funcionar, Rust analisa quais dados são necessários entre um ponto de await e o próximo, ou entre um ponto de await e o fim do bloco async. Em seguida, ele cria uma variante correspondente na máquina de estados compilada. Cada variante recebe o acesso necessário aos dados que serão usados naquela seção do código-fonte, seja tomando ownership desses dados ou obtendo uma referência mutável ou imutável a eles.
Até aqui, tudo bem: se houver algo errado com ownership ou com referências em
um determinado bloco async, o borrow checker nos avisará. Quando queremos mover
o future correspondente a esse bloco, como ao colocá-lo em um Vec para
passá-lo a join_all, as coisas ficam mais complicadas.
Quando movemos um future, seja inserindo-o em uma estrutura de dados para usá-lo
com join_all ou retornando-o de uma função, isso na verdade significa mover a
máquina de estados que o Rust cria para nós. E, ao contrário da maioria dos
outros tipos em Rust, os futures criados pelo Rust para blocos async podem
acabar contendo referências a si mesmos nos campos de alguma variante, como
mostra a ilustração simplificada da Figura 17-4.
Por padrão, qualquer objeto que tenha uma referência a si mesmo não é seguro para ser movido, porque referências sempre apontam para o endereço de memória real daquilo a que se referem, como mostra a Figura 17-5. Se você mover a própria estrutura de dados, essas referências internas continuarão apontando para o lugar antigo. Só que essa posição de memória agora é inválida. Por um lado, seu valor não será atualizado quando você modificar a estrutura de dados. Mais importante ainda, o computador agora está livre para reutilizar essa memória para outros fins. Você pode acabar lendo depois dados completamente sem relação.
Teoricamente, o compilador Rust poderia tentar atualizar cada referência a um objeto sempre que ele fosse movido, mas isso poderia adicionar muita sobrecarga de desempenho, especialmente se toda uma rede de referências precisasse ser atualizada. Se, em vez disso, pudermos garantir que a estrutura de dados em questão não se move na memória, não precisaremos atualizar referência alguma. É exatamente para isso que serve o borrow checker do Rust: em código seguro, ele impede que você mova qualquer item que tenha uma referência ativa para ele.
Pin se baseia nisso para nos dar exatamente a garantia de que precisamos.
Quando fixamos um valor envolvendo um ponteiro para esse valor em Pin, ele
não pode mais ser movido. Assim, se você tiver Pin<Box<SomeType>>, na
verdade estará fixando o valor SomeType, e não o ponteiro Box. A
Figura 17-6 ilustra esse processo.
Na verdade, o ponteiro Box ainda pode se mover livremente. Lembre-se: o que
nos importa é garantir que os dados referenciados permaneçam no lugar. Se um
ponteiro se move, mas os dados para os quais ele aponta continuam no mesmo
lugar, como na Figura 17-7, não há problema potencial. Como exercício
independente, consulte a documentação desses tipos, bem como a do módulo
std::pin, e tente descobrir como fazer isso com um Pin envolvendo um
Box. O ponto principal é que o tipo autorreferencial em si não pode se mover,
porque continua fixado.
No entanto, a maioria dos tipos é perfeitamente segura para ser movida, mesmo
quando está por trás de um ponteiro Pin. Só precisamos pensar em fixação
quando os itens têm referências internas. Valores primitivos, como números e
booleanos, são seguros porque obviamente não têm referências internas. O mesmo
vale para a maior parte dos tipos com que você normalmente trabalha em Rust.
Você pode mover um Vec, por exemplo, sem se preocupar. Dado o que vimos até
agora, se você tivesse um Pin<Vec<String>>, precisaria fazer tudo por meio
das APIs seguras, mas restritivas, fornecidas por Pin, embora Vec<String>
seja sempre seguro de mover se não houver outras referências a ele. Precisamos
de uma forma de dizer ao compilador que mover itens em casos como esse não é um
problema, e é aí que entra Unpin.
Unpin é uma marker trait, semelhante às traits Send e Sync que vimos no
Capítulo 16 e, portanto, não tem funcionalidade própria. Marker traits existem
apenas para informar ao compilador que é seguro usar o tipo que implementa uma
determinada trait em um contexto específico. Unpin informa ao compilador que
um determinado tipo não precisa manter garantias especiais sobre se o valor
em questão pode ser movido com segurança.
Assim como acontece com Send e Sync, o compilador implementa Unpin
automaticamente para todos os tipos para os quais consegue provar que isso é
seguro. Um caso especial, novamente semelhante a Send e Sync, é quando
Unpin não é implementada para um tipo. A notação para isso é
impl !Unpin for SomeType, em que
SomeType é o nome de um tipo que precisa manter essas
garantias para ser seguro sempre que um ponteiro para ele for usado dentro de
um Pin.
Em outras palavras, há duas coisas a ter em mente sobre a relação entre Pin e
Unpin. Primeiro, Unpin é o caso “normal”, e !Unpin é o caso especial.
Segundo, o fato de um tipo implementar Unpin ou !Unpin só importa quando
você está usando um ponteiro fixado para esse tipo, como
Pin<&mut SomeType>.
Para tornar isso mais concreto, pense em uma String: ela tem um comprimento e
os caracteres Unicode que a compõem. Podemos envolver uma String em Pin,
como mostra a Figura 17-8. No entanto, String implementa Unpin
automaticamente, assim como a maioria dos outros tipos em Rust.
Como resultado, podemos fazer coisas que seriam ilegais se String
implementasse !Unpin, como substituir uma string por outra exatamente no
mesmo local de memória, como na Figura 17-9. Isso não viola o contrato de
Pin, porque String não tem referências internas que tornem sua movimentação
insegura. É justamente por isso que ela implementa Unpin, e não !Unpin.
Agora sabemos o suficiente para entender os erros relatados para aquela chamada
a join_all na Listagem 17-23. Originalmente, tentamos mover os futures
produzidos por blocos async para dentro de um Vec<Box<dyn Future<Output = ()>>>,
mas, como vimos, esses futures podem ter referências internas, então não
implementam Unpin automaticamente. Depois de fixá-los, podemos passar o tipo
Pin resultante para o Vec, confiantes de que os dados subjacentes dos
futures não serão movidos. A Listagem 17-24 mostra como corrigir o código
chamando a macro pin! no ponto em que cada um dos três futures é definido e
ajustando o tipo do objeto trait.
extern crate trpl; // required for mdbook test
use std::pin::{Pin, pin};
// --snip--
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (tx, mut rx) = trpl::channel();
let tx1 = tx.clone();
let tx1_fut = pin!(async move {
// --snip--
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("future"),
];
for val in vals {
tx1.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
});
let rx_fut = pin!(async {
// --snip--
while let Some(value) = rx.recv().await {
println!("received '{value}'");
}
});
let tx_fut = pin!(async move {
// --snip--
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];
for val in vals {
tx.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
});
let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
vec![tx1_fut, rx_fut, tx_fut];
trpl::join_all(futures).await;
});
}
Esse exemplo agora compila e executa, e poderíamos adicionar ou remover futures do vetor em tempo de execução e então aguardar todos eles.
Pin e Unpin são importantes principalmente na construção de bibliotecas de
nível mais baixo, ou quando você está construindo um runtime em si, em vez de
apenas escrever código Rust do dia a dia. Ainda assim, quando você vir essas
traits em mensagens de erro, agora terá uma ideia melhor de como corrigir seu
código.
Nota: Esta combinação de
PineUnpintorna possível implementar toda uma classe de tipos complexos em Rust que de outra forma seriam desafiadores porque são autorreferenciais. Tipos que exigemPinaparecem mais comumente em async Rust hoje, mas, de vez em quando, você também pode vê-los em outros contextos.As especificidades de como
PineUnpinfuncionam, bem como as regras que eles precisam manter, são abordadas extensivamente na documentação da API destd::pin, então, se você tiver interesse em aprender mais, esse é um ótimo lugar para começar.Se você quiser entender como as coisas funcionam nos bastidores com ainda mais detalhes, veja os capítulos 2 e 4 de Programação Assíncrona em Rust.
A Trait Stream
Agora que você tem uma compreensão mais profunda das traits Future, Pin e
Unpin, podemos voltar nossa atenção para a trait Stream. Como você aprendeu
anteriormente neste capítulo, streams são semelhantes a iteradores assíncronos.
Ao contrário de Iterator e Future, porém, Stream ainda não tem uma
definição na biblioteca padrão no momento em que este texto foi escrito. Ainda
assim, há uma definição muito comum vinda do crate futures, usada em todo o
ecossistema.
Vamos revisar as definições das traits Iterator e Future antes de ver como
uma trait Stream pode reuni-las. De Iterator, temos a ideia de uma
sequência: seu método next fornece um Option<Self::Item>. De Future,
temos a ideia de prontidão ao longo do tempo: seu método poll fornece um
Poll<Self::Output>. Para representar uma sequência de itens que ficam prontos
ao longo do tempo, definimos uma trait Stream que combina essas duas ideias:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
trait Stream {
type Item;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut Context<'_>
) -> Poll<Option<Self::Item>>;
}
}
A trait Stream define um tipo associado chamado Item para o tipo de itens
produzidos pela stream. Isso é semelhante a Iterator, em que pode haver de
zero a muitos itens, e diferente de Future, em que sempre há um único
Output, mesmo que ele seja o tipo unitário ().
Stream também define um método para obter esses itens. Nós o chamamos de
poll_next, para deixar claro que ele faz poll da mesma forma que
Future::poll e produz uma sequência de itens do mesmo modo que
Iterator::next. Seu tipo de retorno combina Poll com Option. O tipo
externo é Poll, porque ele precisa ser verificado quanto à prontidão, assim
como acontece com um future. O tipo interno é Option, porque precisa
sinalizar se ainda existem mais mensagens, assim como acontece com um iterador.
Algo muito semelhante a essa definição provavelmente acabará fazendo parte da biblioteca padrão do Rust. Enquanto isso, ela faz parte do conjunto de ferramentas da maioria dos runtimes, então você pode contar com isso, e tudo o que abordaremos a seguir deve se aplicar de modo geral.
Nos exemplos que vimos na seção “Streams: Futures em Sequência”, porém, não usamos poll_next nem Stream; em vez disso,
usamos next e StreamExt. Poderíamos trabalhar diretamente com a API
poll_next, escrevendo manualmente nossas próprias máquinas de estados para
Stream, é claro, assim como poderíamos trabalhar com futures diretamente
por meio do método poll. Usar await é muito mais agradável, no entanto, e a
trait StreamExt fornece o método next para que possamos fazer exatamente
isso:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
trait Stream {
type Item;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Self::Item>>;
}
trait StreamExt: Stream {
async fn next(&mut self) -> Option<Self::Item>
where
Self: Unpin;
// other methods...
}
}
Nota: A definição real que usamos anteriormente neste capítulo parece um pouco diferente disso, pois ela dá suporte a versões do Rust que ainda não suportavam o uso de funções async em traits. Como resultado, ela fica assim:
fn next(&mut self) -> Next<'_, Self> where Self: Unpin;Esse tipo
Nexté umastructque implementaFuturee nos permite nomear o lifetime da referência aselfcomNext<'_, Self>, para queawaitpossa funcionar com esse método.
A trait StreamExt também é o lugar em que vivem todos os métodos interessantes
disponíveis para uso com streams. StreamExt é implementada automaticamente
para cada tipo que implementa Stream, mas essas traits são definidas
separadamente para permitir que a comunidade evolua APIs de conveniência sem
afetar a trait fundamental.
Na versão de StreamExt usada no crate trpl, a trait não apenas define o
método next, como também fornece uma implementação padrão de next que lida
corretamente com os detalhes da chamada a Stream::poll_next. Isso significa
que, mesmo quando você precisa escrever seu próprio tipo de dado de streaming,
você só precisa implementar Stream; depois disso, qualquer pessoa que usar
esse tipo poderá usar StreamExt e seus métodos automaticamente.
Isso é tudo o que abordaremos sobre os detalhes de mais baixo nível dessas traits. Para finalizar, vamos considerar como futures, incluindo streams, tasks e threads se encaixam.