1 pontos por GN⁺ 4 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • std::pin::Pin expressa, no nível de tipos, a garantia de que o valor apontado por um ponteiro não será movido por meio desse ponteiro, sendo necessário para valores cujo endereço precisa ser estável, como tipos que fazem referência a si mesmos
  • Em async/await, variáveis locais e referências que sobrevivem além de um .await podem se tornar campos da máquina de estados gerada pelo compilador, então Future::poll exige Pin para impedir que o future seja movido após começar a ser pollingado
  • Pin impede, em código seguro, que um valor fixado seja movido, mas não proíbe modificações em geral; se T: Unpin não for satisfeito, não é possível extrair com segurança um &mut T de um Pin
  • A maioria dos tipos em Rust é Unpin por padrão, então structs auto-referenciais que não podem ser movidas normalmente precisam incluir um campo PhantomPinned para se tornarem !Unpin
  • Na prática, usa-se Box::pin ou std::pin::pin! ao fazer poll de futures diretamente ou ao passá-los para APIs que exigem futures pinados; ao implementar Future diretamente ou primitivas assíncronas de baixo nível, também é preciso lidar com invariantes unsafe

Por que Pin é necessário

  • std::pin::Pin é um wrapper de ponteiro que representa a garantia de que o valor apontado por esse ponteiro não será movido por meio dele
  • O problema central surge em tipos auto-referenciais
    • A struct de exemplo SelfRef tem data: i32 e ptr: *const i32, e ptr aponta para self.data
    • Se uma instância da struct for movida para outra variável ou retornada por uma função, seu endereço de memória pode mudar
    • O ponteiro bruto ptr continuará apontando para a posição antiga de memória e se tornará um ponteiro pendente
  • Depois que a autorreferência é estabelecida, é necessário um mecanismo que impeça que esse valor seja movido novamente

O problema em async/await e Future

  • async/await e Future são áreas típicas em que Pin aparece com frequência
  • Variáveis locais que sobrevivem além de um ponto .await tornam-se campos da máquina de estados gerada pelo compilador
  • Se uma referência a uma variável local também sobreviver além do mesmo .await, o future gerado pode se tornar auto-referencial
  • Depois que o polling começa, o future pode passar a depender de referências que apontam para outros campos internos
    • Se o future for movido nesse estado, essas referências serão invalidadas
  • Para evitar isso, Future::poll recebe Pin em vez de &mut self
pub trait Future {
    type Output;
    fn poll(self: Pin, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

O que Pin garante e o que não garante

  • Pin<Ptr> fixa o valor apontado, não o próprio ponteiro Ptr
    • Por exemplo, Pin<Box<T>> pode ser movido livremente
    • Nesse caso, o que se move é o valor Box na stack, e não o valor T alocado no heap dentro dele
  • O pinning também não impede mutação por si só
    • Campos ainda podem ser modificados desde que não sejam movidos para fora do lugar
    • Se T: Unpin, Pin<&mut T> se comporta quase como um &mut T comum
impl<'a, T: ?Sized> Pin<&'a mut T> {
    pub const fn get_mut(self) -> &'a mut T
    where
        T: Unpin
    { ... }
}
  • Se o tipo não implementa Unpin, ou seja, é !Unpin, não é possível obter um &mut T apenas com código seguro
  • Nesse caso, é preciso usar métodos unsafe como Pin::get_unchecked_mut, e o código deve garantir que o valor não será movido para fora daquela referência

Unpin e PhantomPinned

  • Tipos que implementam Unpin não dependem de pinning para sua segurança de memória
// std::marker
pub auto trait Unpin {}
  • A maioria dos tipos em Rust pode ser movida sem problema, então é Unpin por padrão
    • Exemplos: i32, String, Vec
  • Unpin é implementado automaticamente para todos os tipos, a menos que se torne explicitamente !Unpin
  • std::marker::PhantomPinned é uma struct marcador que é explicitamente !Unpin
    • Como auto traits se propagam automaticamente, uma struct que contém um campo PhantomPinned também se torna !Unpin
use std::marker::PhantomPinned;

struct SelfRef {
    data: i32,
    ptr: *const i32,
    _phantom: PhantomPinned, // makes the entire struct !Unpin
}
  • Essa é a forma padrão de declarar que uma struct definida pelo usuário se torna insegura se for movida depois de ser fixada
  • Normalmente, o compilador não consegue detectar automaticamente autorreferências criadas com ponteiros brutos unsafe
  • Por isso, o desenvolvedor precisa abrir mão de Unpin explicitamente para structs auto-referenciais
    • Em geral, isso é feito incluindo um campo PhantomPinned
  • Se um tipo auto-referencial permanecer Unpin por engano, código seguro poderá extrair uma referência mutável de um Pin e mover o valor
    • Isso quebrará as suposições feitas pelo código unsafe que criou a autorreferência

Como criar um Pin

  • Pin por si só não fixa um valor

  • Criar um Pin significa provar que o pointee permanecerá em uma posição estável na memória durante a vida útil daquele pin

  • Pin::new

    • A forma mais simples de criação é Pin::new
    let mut value = 42;
    let pinned = Pin::new(&mut value);
    
    • Esse construtor só pode ser usado quando T: Unpin
    • Tipos Unpin não dependem de pinning, então envolvê-los com Pin é sempre seguro
    • Nesse caso, a garantia de pinning é, na prática, um no-op
  • std::pin::pin!

    • Quando é preciso fixar um valor localmente sem alocação no heap, pode-se usar a macro pin!
    use std::pin::pin;
    
    let future = pin!(async {
        println!("Hello");
    });
    
    • Essa macro cria uma variável local e retorna um Pin que aponta para ela
    • O compilador garante que essa variável local não será movida durante o restante de sua vida útil, então é possível fixar com segurança um valor !Unpin na stack
    • Apesar do nome, pin! não fixa a própria memória da stack
    • Ela apenas cria uma referência fixada associada a uma variável local; quando a variável sai de escopo, a garantia de pinning também termina
  • Box::pin

    • Para tipos !Unpin, o construtor mais comum é Box::pin
    let pinned = Box::pin(SelfRef { ... });
    
    • Enquanto pin! cria um Pin ligado a uma variável local, Box::pin retorna um Pin pertencente a um Box
    • Como a alocação no heap em si não se move, o pointee tem uma posição estável na memória durante toda a vida do Box
    • Mesmo que o próprio Box seja movido, o valor que ele possui não se move; apenas o ponteiro dentro do Box é movido
    • A alocação no heap permanece no mesmo endereço
  • Pin::new_unchecked

    • Quando os construtores seguros não conseguem provar que o valor permanecerá no lugar, é possível criar um Pin diretamente com código unsafe
    let pinned = unsafe { Pin::new_unchecked(ptr) };
    
    • Quem chama Pin::new_unchecked promete que o pointee não será movido novamente por nenhum ponteiro durante a vida útil do Pin retornado
    • Se essa promessa for quebrada, pode ocorrer comportamento indefinido em código que depende da garantia de pinning
    • Por isso, esse recurso costuma ser usado apenas ao implementar abstrações de baixo nível capazes de manter essa invariante

Quando você realmente precisa se preocupar com isso

  • Para a maioria dos desenvolvedores Rust, Pin e Unpin funcionam silenciosamente em segundo plano
  • Em geral, você só precisa lidar com isso diretamente em dois casos
    • Consumo de código async: ao fazer poll de um future manualmente ou passá-lo para uma API que exige futures pinados, é comum usar Box::pin(future) para fixá-lo no heap ou std::pin::pin!(future) para fixá-lo localmente na stack
    • Implementação manual de Future: ao escrever máquinas de estado personalizadas ou primitivas assíncronas de baixo nível, você precisa lidar com Pin, e pode precisar de PhantomPinned e código unsafe para preservar as invariantes de pinning
  • Pin é a solução zero-cost do Rust para lidar com tipos sensíveis a endereço
  • Graças a isso, Rust consegue oferecer async/await e outras abstrações auto-referenciais mantendo garantias de segurança de memória sem precisar de garbage collector

1 comentários

 
GN⁺ 4 시간 전
Comentários no Lobste.rs
  • std::pin::Pin parece a Monad do mundo Rust. Depois que você entende, fica impossível não escrever um post de blog sobre isso

    • Esses textos costumam cair na falácia do tutorial de monads
    • Quer dizer que, como no caso das monads, esses posts na prática não conseguem explicar nada direito?
  • Seria bom abordar algumas coisas em que eu e outras pessoas tropeçamos ao tentar entender Pin
    O nome Unpin não é muito bom. Nomes mais precisos, embora também ruins, teriam sido MovableWhenPinned ou PinIsNoOp
    A dupla negação de !Unpin no nightly parece estranha, mas, para manter os tipos existentes como o caso padrão em 99% das situações, foi preciso adicionar o auto trait Unpin, do qual um tipo pode escapar. Se você pensar como !MovableWhenPinned, faz mais sentido
    A alternativa estável, PhantomPinned, também não tem um bom nome, porque estar pinned é um estado temporário causado pela existência de uma referência pinned, não uma característica do tipo. Um nome alternativo teria sido algo como PhantomNotMovableWhenPinned
    Quando comecei a traduzir mentalmente dessa forma, ficou muito mais fácil de entender. Claro, ainda é confuso; talvez eu só tenha tido sorte

    • Concordo totalmente. Antes, !Unpin me dava dor de cabeça, mas ficou um pouco mais tranquilo quando comecei a ler Unpin como SafeToUnpin
  • Fiz essa pergunta antes e acho que alguém respondeu de forma cuidadosa, mas não lembro. Pelo que entendi, Pin surgiu de async, e o problema era que referências a variáveis locais se tornavam autorreferências dentro de um bloco de dados que representa a máquina de estados de uma função específica
    Se o estado async for movido, essas referências a variáveis locais passam a apontar para o local antigo e inválido
    Mas isso não acontece apenas porque referências são ponteiros reais com endereços absolutos completos? Fico me perguntando por que a solução foi remover a capacidade de mover, em vez de tornar as referências relativas
    Fico na dúvida se a resposta é basicamente “porque milhões de anos-engenheiro foram investidos para que compiladores, CPUs e SOs lidem muito bem com ponteiros, então ponteiros são melhores em vários aspectos, e por isso é melhor usar Pin por toda parte”, ou se existe algum motivo rígido pelo qual referências relativas realmente não funcionariam como alternativa

    • O problema não é só uma variável local dentro de um estado async referenciar diretamente outra variável local no mesmo estado. Nesse caso, o compilador conhece todas as variáveis locais, então poderia tornar o acesso relativo. Mas, quando uma referência lá no fundo de um tipo aponta para um valor lá no fundo de outro tipo, fica muito mais complicado
      Se as referências fossem relativas, esses tipos teriam de ter uma representação em memória diferente dependendo de estarem ou não sendo usados dentro de um estado async, e também seria necessário algum conceito de ponteiro-base a ser passado junto para reconstruir o ponteiro real a partir da referência relativa
      Objetos aninhados dentro de uma referência pinned ainda podem ser movidos livremente mesmo que o objeto raiz esteja pinned, então também não dá para dizer que todas essas referências relativas hipotéticas seriam relativas ao mesmo ponteiro-base
      No fim, ponteiros absolutos são necessários, e referências relativas não se encaixam bem. Então, como o compilador Rust conhece os tipos aqui, que tal tornar os objetos móveis rastreando todo o grafo de objetos e corrigindo as referências que apontam para objetos movidos para a nova localização? Isso, na prática, seria criar um coletor de lixo com rastreamento
      Além disso, o compilador Rust não conhece todos os tipos no grafo de objetos. Referências podem ser passadas por FFI, e bibliotecas externas podem armazená-las. Corrigir referências movidas através de uma fronteira FFI é, na prática, um problema intratável
      Por isso é realmente complicado. Também é importante notar que mover objetos em si é uma técnica relativamente nova. Na maioria dos programas C/C++, todos os objetos podem ser vistos como implicitamente pinned. O motivo de pinning ser menos discutido por lá é que os objetos simplesmente não se movem, ou, se se movem, a responsabilidade de não deixar referências pendentes é do programador
    • Pin também é necessário para interoperabilidade com outras linguagens nas quais Rust não pode simplesmente mover memória como se ela fosse um bloco opaco de bits
      Pelo que entendo, um dos problemas da interoperabilidade com C++ é que objetos não são simples blocos de bits que podem ser movidos livremente; no fim, muitos tipos acabam precisando de pinning, e a usabilidade fica desconfortável
      Dito isso, isso se baseia em conversas que tive há pelo menos uns 6 meses com pessoas que trabalhavam nisso, então não sei o quanto a situação melhorou desde então
  • No geral, acho uma boa explicação para ler além da documentação oficial do Rust. A forma de chegar ao problema é um pouco mais suave
    Ainda assim, acho que começar com structs autorreferenciais acaba confundindo mais do que ajudando. Em especial, a frase da introdução “portanto, depois que essa autorreferência é criada, precisamos de uma forma de impedir que SelfRef seja movido” me fez pensar mais no “problema de impedir completamente o movimento” do que no ponto central
    O ponto central, que aparece bem mais tarde, é: “Pin não impede que um valor seja movido fisicamente. Em vez disso, é uma garantia em nível de tipo de que o valor não será movido por meio daquele ponteiro”
    Como não dá para impedir o movimento em si, usa-se Pin para expor dados autorreferenciais em uma API segura apenas atrás de uma referência exclusiva. Talvez eu já entenda Pin demais, mas acho que, se a explicação for um pouco refinada, o leitor vai se perder menos

    • Vou tentar reformular o texto
      Este artigo veio das minhas anotações sobre pinning, e no começo eu também entendia assim. Achei bonito que um problema como “impedir o movimento” pudesse ser resolvido com uma garantia em nível de tipo
      Claro que isso não é o que Pin realmente faz, então faz sentido ajustar o texto para deixar essa parte clara
  • Valeria mencionar em algum ponto do artigo que !UnPin só pode ser expresso no nightly Rust. Esse é o principal motivo de PhantomPinned existir

  • Chamam de “wrapper de ponteiro”, mas mesmo em Rust quase nunca se lida com ponteiros. Não sei por que eu deveria usar isso
    *const é difícil de encontrar na documentação do Rust pelo Google; fico me perguntando se é documentado
    Também preciso saber que “isso se torna um campo da máquina de estados gerada pelo compilador”? Ou algum erro absurdo do compilador está tentando dizer que isso realmente aconteceu?
    “O future gerado se torna autorreferencial” também é algo que acontece implicitamente quando se usa futures?
    Acho que nunca usei Future::poll diretamente
    Dizem que “código seguro não consegue recuperar um &mut T normal”, mas também que “modificações comuns são permitidas”; então como isso funciona?
    Coisas assim me fizeram parar de me aprofundar em Rust

    • Ponteiros brutos são um dos tipos primitivos de Rust. A documentação está aqui e aqui
      Dito isso, também é verdade que quase não é preciso usá-los a menos que você desça para baixo nível. Eu só fui conhecer quando precisei chamar uma biblioteca em C
      Future::poll é a base do código assíncrono em Rust. Você não o chama diretamente; quem chama é o executor. Rust não tem um executor padrão, então é preciso adicionar algo como Tokio, smol ou pollster, e eles usam métodos como poll, definidos no trait Future, para fazer o trabalho
    • Não sou o autor do post original, e esses também não são os únicos motivos, mas os motivos pelos quais precisei lidar com ponteiros em Rust foram FFI e estruturas de dados autorreferenciais, como grafos
      A documentação está em vários lugares, incluindo aqui
      Esperar que outras pessoas expliquem apenas aquilo de que você mesmo precisou é um pouco demais
      Não entendi bem o que você está perguntando com “então como?”