1 puntos por GN⁺ 4 시간 전 | 1 comentarios | Compartir por WhatsApp
  • std::pin::Pin expresa una garantía a nivel de tipos de que el valor apuntado por un puntero no será movido a través de ese puntero, y es necesario para valores cuya dirección debe permanecer estable, como los tipos que se referencian a sí mismos
  • En async/await, las variables locales y referencias que sobreviven más allá de un .await pueden convertirse en campos de la máquina de estados generada por el compilador, por lo que Future::poll requiere Pin para evitar que el future sea movido después del polling
  • Pin impide mover con código seguro un valor fijado, pero no prohíbe la mutación en general, y si T: Unpin no se cumple, no se puede extraer de forma segura un &mut T desde Pin
  • La mayoría de los tipos en Rust son Unpin por defecto, así que las estructuras autorreferenciales que no deben moverse normalmente necesitan un campo PhantomPinned para volverse !Unpin
  • En la práctica, se usa Box::pin o std::pin::pin! cuando se hace poll manualmente a un future o se lo pasa a una API que requiere un future fijado; al implementar directamente Future o primitivas async de bajo nivel, también hay que manejar invariantes unsafe

Por qué se necesita Pin

  • std::pin::Pin es un wrapper de puntero que representa la garantía de que el valor apuntado por ese puntero no será movido a través de él
  • El problema central aparece en los tipos autorreferenciales
    • La estructura de ejemplo SelfRef tiene data: i32 y ptr: *const i32, y ptr apunta a self.data
    • Si una instancia de la estructura se mueve a otra variable o se retorna desde una función, su dirección de memoria puede cambiar
    • El puntero crudo ptr seguirá apuntando a la ubicación anterior de memoria y se convertirá en un puntero colgante
  • Después de establecer una autorreferencia, hace falta un mecanismo que impida que ese valor vuelva a moverse

El problema en async/await y Future

  • async/await y Future son el caso más representativo donde Pin aparece con frecuencia
  • Las variables locales que sobreviven a un punto .await se convierten en campos de la máquina de estados generada por el compilador
  • Si una referencia a una variable local también sobrevive al mismo .await, el future generado puede volverse autorreferencial
  • Una vez que comienza el polling, el future puede depender de referencias que apuntan a otros campos dentro de sí mismo
    • Si en ese estado el future se mueve, esas referencias se invalidan
  • Para evitarlo, Future::poll recibe Pin en lugar de &mut self
pub trait Future {
    type Output;
    fn poll(self: Pin, cx: &mut Context Pin {
      pub const fn get_mut(self) -> &'a mut T
      where
          T: Unpin
      { ... }
  }
  • Si el tipo no implementa Unpin, es decir, es !Unpin, no se puede obtener &mut T usando solo código seguro
  • En ese caso hay que usar métodos unsafe como Pin::get_unchecked_mut, y el código debe cumplir la promesa de no mover el valor fuera del alcance de esa referencia

Unpin y PhantomPinned

  • Los tipos que implementan Unpin no dependen del pinning para mantener la seguridad de memoria
// std::marker
pub auto trait Unpin {}
  • La mayoría de los tipos en Rust no tienen problema si se mueven, por eso son Unpin por defecto
    • Ejemplos: i32, String, Vec
  • Unpin se implementa automáticamente para todos los tipos a menos que se los marque explícitamente como !Unpin
  • std::marker::PhantomPinned es una estructura marcador explícitamente !Unpin
    • Como los auto traits se propagan automáticamente, una estructura que incluya un campo PhantomPinned también se vuelve automáticamente !Unpin
use std::marker::PhantomPinned;

struct SelfRef {
    data: i32,
    ptr: *const i32,
    _phantom: PhantomPinned, // makes the entire struct !Unpin
}
  • Esta es la forma estándar de declarar que una estructura definida por el usuario deja de ser segura si se mueve después de haber sido fijada
  • El compilador normalmente no puede detectar automáticamente autorreferencias creadas con punteros crudos unsafe
  • Por eso el desarrollador debe renunciar explícitamente a Unpin para una estructura autorreferencial
    • Normalmente se hace incluyendo un campo PhantomPinned
  • Si un tipo autorreferencial queda accidentalmente como Unpin, código seguro podría extraer una referencia mutable desde Pin y mover el valor
    • Eso rompería los supuestos del código unsafe que construyó la autorreferencia

Cómo crear un Pin

  • Pin por sí mismo no fija un valor

  • Crear un Pin significa demostrar que el pointee permanecerá en una ubicación de memoria estable durante la vida útil de ese pin

  • Pin::new

    • La forma más simple de crearlo es Pin::new
    let mut value = 42;
    let pinned = Pin::new(&mut value);
    
    • Este constructor solo puede usarse cuando T: Unpin
    • Los tipos Unpin no dependen del pinning, así que envolverlos en Pin siempre es seguro
    • En este caso, la garantía de pinning en la práctica es un no-op
  • std::pin::pin!

    • Cuando se necesita fijar localmente un valor sin asignarlo en el heap, se puede usar el macro pin!
    use std::pin::pin;
    
    let future = pin!(async {
        println!("Hello");
    });
    
    • Este macro crea una variable local y devuelve un Pin que apunta a esa variable
    • El compilador garantiza que esa variable local no será movida durante el resto de su vida útil, por lo que se puede fijar de forma segura un valor !Unpin en la pila
    • A pesar del nombre, pin! no fija la memoria de la pila en sí
    • Solo crea una referencia fijada ligada a una variable local, y cuando la variable sale de scope, la garantía de pinning también termina
  • Box::pin

    • Para tipos !Unpin, el constructor más común es Box::pin
    let pinned = Box::pin(SelfRef { ... });
    
    • Mientras pin! crea un Pin ligado a una variable local, Box::pin devuelve un Pin poseído por un Box
    • La asignación en el heap en sí no se mueve, así que el pointee tiene una ubicación de memoria estable durante la vida del Box
    • Aunque el Box en sí se mueva, el valor que posee no se mueve; solo se mueve el puntero dentro del Box
    • La asignación en el heap permanece en la misma dirección
  • Pin::new_unchecked

    • Cuando un constructor seguro no puede demostrar que el valor permanecerá en su lugar, se puede crear un Pin manualmente con código unsafe
    let pinned = unsafe { Pin::new_unchecked(ptr) };
    
    • Quien llama a Pin::new_unchecked promete que, durante toda la vida del Pin retornado, el pointee no será movido de nuevo por ningún puntero
    • Si esa promesa se rompe, puede producirse comportamiento indefinido en código que dependa de la garantía de pinning
    • Por eso normalmente solo se usa al implementar abstracciones de bajo nivel que pueden mantener esa invariante

Cuándo realmente hay que preocuparse

  • Para la mayoría de quienes desarrollan en Rust, Pin y Unpin funcionan silenciosamente en segundo plano
  • En general solo hay dos casos en los que hay que prestarles atención directamente
    • Consumo de código async: si hay que hacer poll manualmente a un future o pasarlo a una API que requiere un future fijado, se lo fija en el heap con Box::pin(future) o en la pila local con std::pin::pin!(future)
    • Implementación directa de Future: al escribir una máquina de estados personalizada o primitivas async de bajo nivel, hay que manejar Pin, y puede ser necesario usar PhantomPinned y código unsafe para mantener las invariantes de pinning
  • Pin es la solución zero-cost de Rust para tratar problemas de tipos sensibles a la dirección
  • Gracias a eso, Rust puede usar async/await y otras abstracciones autorreferenciales manteniendo las garantías de seguridad de memoria sin garbage collector

1 comentarios

 
GN⁺ 4 시간 전
Opiniones en Lobste.rs
  • std::pin::Pin es como una Monad del mundo Rust. Una vez que la entiendes, no puedes evitar escribir una entrada de blog

    • Esos textos suelen caer en la monad tutorial fallacy
    • ¿Eso significa, como con Monad, que esos artículos en realidad no logran explicar bien nada?
  • Sería bueno cubrir algunas cosas con las que otros y yo nos trabamos al intentar entender Pin
    El nombre Unpin no es muy bueno. Nombres más precisos, aunque tampoco muy buenos, habrían sido MovableWhenPinned o PinIsNoOp
    La doble negación !Unpin en nightly se ve rara, pero para dejar los tipos existentes como el caso predeterminado del 99%, hubo que agregar el auto trait Unpin, del que los tipos pueden optar por salirse. Si lo piensas como !MovableWhenPinned, tiene más sentido
    PhantomPinned, la alternativa en la versión estable, tampoco tiene un buen nombre, porque estar pinned es un estado temporal causado por tener una referencia pinned, no una característica del tipo. Un nombre alternativo habría sido algo como PhantomNotMovableWhenPinned
    Cuando empecé a traducirlo así en mi cabeza, lo entendí mucho mejor. Claro que todavía puede que estuviera confundido y solo haya tenido suerte

    • Totalmente de acuerdo. Antes !Unpin me daba dolor de cabeza, pero cuando empecé a leer Unpin como SafeToUnpin, se volvió un poco más llevadero
  • Hice esta pregunta antes y creo que alguien me respondió con bastante consideración, pero ya no recuerdo. Según entiendo, Pin surgió de async, y el problema era que las referencias a variables locales se volvían autorreferenciales dentro del bloque de datos que representa la máquina de estados de una función en particular
    Si el estado async se mueve, esas referencias a variables locales pasan a apuntar a la ubicación vieja e incorrecta
    Pero ¿no ocurre eso solo porque las referencias son punteros reales con direcciones absolutas completas? Me pregunto por qué la solución fue eliminar la capacidad de moverse, en vez de convertir las referencias en direcciones relativas
    Me pregunto si la respuesta es, en líneas generales, “se han invertido millones de años-ingeniero en hacer que compiladores, CPU y sistemas operativos manejen muy bien los punteros, por lo que los punteros son mejores en muchos aspectos, y por eso conviene usar Pin por todas partes”, o si hay una razón estricta por la que las referencias relativas realmente no funcionan como alternativa

    • El problema no es solo que una variable local dentro del estado async referencie directamente a otra variable local del mismo estado. En ese caso, como el compilador conoce todas las variables locales, podría hacer que el acceso fuera relativo. Pero si una referencia en lo profundo de un tipo apunta a un valor en lo profundo de otro tipo, la cosa se vuelve mucho más complicada
      Si las referencias fueran relativas, esos tipos tendrían que tener una representación en memoria distinta según se usen o no dentro de un estado async, y también haría falta el concepto de un puntero base que se pasara junto con ellas para reconstruir el puntero real a partir de la referencia relativa
      Los objetos anidados dentro de una referencia pinned todavía pueden moverse libremente aunque el objeto raíz esté pinned, así que tampoco se puede decir que todas esas referencias relativas hipotéticas sean relativas al mismo puntero base
      Al final se necesitan punteros absolutos, y las referencias relativas no encajan bien. Entonces, como el compilador de Rust conoce los tipos que están aquí, ¿qué tal si hace movible todo el grafo de objetos rastreándolo y corrigiendo las referencias que apuntan al objeto movido hacia su nueva ubicación? Eso sería, en la práctica, construir un recolector de basura con trazado
      Además, el compilador de Rust no conoce todos los tipos del grafo de objetos. Las referencias pueden pasarse por FFI y una biblioteca externa puede guardarlas. Arreglar referencias movidas que cruzan una frontera FFI es, en la práctica, un problema difícil de manejar
      Por eso es realmente complicado. También es importante que mover objetos en sí sea una técnica relativamente nueva. En la mayoría de los programas C/C++, se puede considerar que todos los objetos están pinned de forma implícita. En ese mundo se habla menos de pinning porque los objetos simplemente no se mueven o, si se mueven, la responsabilidad de no dejar referencias colgantes recae en el programador
    • Pin también es necesario para la interoperabilidad con otros lenguajes en los que Rust no puede mover la memoria arbitrariamente como si fuera una masa opaca de bits
      Según entiendo, uno de los problemas de interoperar con C++ es que los objetos no son simples masas de bits que puedan moverse libremente, y al final bastantes tipos necesitan pinning, lo que vuelve incómodo su uso
      Dicho eso, esto se basa en conversaciones que tuve con gente que trabajaba en ello hace al menos unos 6 meses, así que no sé cuánto habrá mejorado la situación desde entonces
  • En general, me parece una buena explicación para leer junto con la documentación oficial de Rust. La forma de entrar al problema es un poco más suave
    Sin embargo, creo que empezar con estructuras autorreferenciales confunde más que omitirlo. En particular, la frase de la introducción “por lo tanto, después de crear esa autorreferencia, se necesita alguna forma de impedir que SelfRef se mueva” me hizo pensar más en “el problema de impedir completamente el movimiento” que en el punto central
    El punto real está mucho más adelante: “Pin no impide que un valor se mueva físicamente. En cambio, es una garantía a nivel de tipos de que el valor no se moverá a través de ese puntero”
    Como no se puede impedir el movimiento en sí, se usa Pin para exponer datos autorreferenciales solo detrás de una referencia exclusiva en una API segura. Puede que yo ya entienda demasiado Pin, pero si se pule un poco la forma de explicarlo, el lector se perdería menos

    • Voy a cambiar el texto para expresarlo así
      Este artículo salió de mis notas sobre pinning, y al principio yo también lo entendía de esa manera. Me pareció hermoso que algo como “impedir el movimiento” pudiera resolverse con una garantía a nivel de tipos
      Por supuesto, eso no es lo que Pin hace realmente, así que tiene sentido corregir el artículo para que eso quede claro
  • Valdría la pena mencionar en alguna parte del artículo que !UnPin solo puede expresarse en nightly Rust. Esa es la razón principal por la que existe PhantomPinned

  • Lo llaman “envoltorio de punteros”, pero incluso en Rust casi nunca hay que lidiar con punteros. No sé por qué tendría que usarlo
    Es difícil encontrar la documentación de Rust para *const en Google; me pregunto si está documentado
    ¿También hay que saber que “se convierte en un campo de la máquina de estados generada por el compilador”? ¿O un error absurdo del compilador en realidad intenta decir que eso ocurrió?
    ¿Que “el future generado se vuelve autorreferencial” también es algo que ocurre implícitamente al usar futures?
    Creo que nunca he usado Future::poll directamente
    Dicen que “el código seguro no puede recuperar un &mut T normal”, pero también que “permite modificaciones normales”; entonces, ¿cómo se hace?
    Cosas como estas hicieron que dejara de profundizar en Rust

    • Los punteros sin procesar son uno de los tipos primitivos de Rust. La documentación está aquí y aquí
      Dicho eso, también es cierto que casi no los usas salvo que bajes a bajo nivel. Yo solo los conocí cuando tuve que llamar a una biblioteca en C
      Future::poll es la base del código asíncrono en Rust. No lo llamas directamente; lo llama el ejecutor. Rust no trae un ejecutor predeterminado, así que hay que agregar algo como Tokio, smol o pollster, y estos usan métodos como poll, definidos en el trait Future, para hacer el trabajo
    • No soy el autor del artículo original y estas tampoco son las únicas razones, pero los motivos por los que tuve que lidiar con punteros en Rust fueron FFI y estructuras de datos autorreferenciales como grafos
      La documentación está en varios lugares, incluido este
      Esperar que otros solo expliquen exactamente lo que uno necesitaba me parece un poco excesivo
      No tengo muy claro qué estás preguntando con “entonces, ¿cómo?”