¿Qué es `std::pin::Pin` de Rust?
(vrong.me)std::pin::Pinexpresa 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.awaitpueden convertirse en campos de la máquina de estados generada por el compilador, por lo queFuture::pollrequierePinpara evitar que el future sea movido después del polling Pinimpide mover con código seguro un valor fijado, pero no prohíbe la mutación en general, y siT: Unpinno se cumple, no se puede extraer de forma segura un&mut TdesdePin- 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
PhantomPinnedpara volverse!Unpin - En la práctica, se usa
Box::pinostd::pin::pin!cuando se hacepollmanualmente a un future o se lo pasa a una API que requiere un future fijado; al implementar directamenteFutureo primitivas async de bajo nivel, también hay que manejar invariantesunsafe
Por qué se necesita Pin
std::pin::Pines 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
SelfReftienedata: i32yptr: *const i32, yptrapunta aself.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
ptrseguirá apuntando a la ubicación anterior de memoria y se convertirá en un puntero colgante
- La estructura de ejemplo
- 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/awaity Future son el caso más representativo dondePinaparece con frecuencia- Las variables locales que sobreviven a un punto
.awaitse 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::pollrecibePinen 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 Tusando 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
Unpinno 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
Unpinpor defecto- Ejemplos:
i32,String,Vec
- Ejemplos:
Unpinse implementa automáticamente para todos los tipos a menos que se los marque explícitamente como!Unpinstd::marker::PhantomPinnedes una estructura marcador explícitamente!Unpin- Como los auto traits se propagan automáticamente, una estructura que incluya un campo
PhantomPinnedtambién se vuelve automáticamente!Unpin
- Como los auto traits se propagan automáticamente, una estructura que incluya un campo
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
Unpinpara una estructura autorreferencial- Normalmente se hace incluyendo un campo
PhantomPinned
- Normalmente se hace incluyendo un campo
- Si un tipo autorreferencial queda accidentalmente como
Unpin, código seguro podría extraer una referencia mutable desdePiny mover el valor- Eso rompería los supuestos del código
unsafeque construyó la autorreferencia
- Eso rompería los supuestos del código
Cómo crear un Pin
-
Pinpor sí mismo no fija un valor -
Crear un
Pinsignifica 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
Unpinno dependen del pinning, así que envolverlos enPinsiempre es seguro - En este caso, la garantía de pinning en la práctica es un no-op
- La forma más simple de crearlo es
-
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
Pinque 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
!Unpinen 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
- Cuando se necesita fijar localmente un valor sin asignarlo en el heap, se puede usar el macro
-
Box::pin- Para tipos
!Unpin, el constructor más común esBox::pin
let pinned = Box::pin(SelfRef { ... });- Mientras
pin!crea unPinligado a una variable local,Box::pindevuelve unPinposeído por unBox - 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
Boxen sí se mueva, el valor que posee no se mueve; solo se mueve el puntero dentro delBox - La asignación en el heap permanece en la misma dirección
- Para tipos
-
Pin::new_unchecked- Cuando un constructor seguro no puede demostrar que el valor permanecerá en su lugar, se puede crear un
Pinmanualmente con código unsafe
let pinned = unsafe { Pin::new_unchecked(ptr) };- Quien llama a
Pin::new_uncheckedpromete que, durante toda la vida delPinretornado, 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
- Cuando un constructor seguro no puede demostrar que el valor permanecerá en su lugar, se puede crear un
Cuándo realmente hay que preocuparse
- Para la mayoría de quienes desarrollan en Rust,
PinyUnpinfuncionan 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
pollmanualmente a un future o pasarlo a una API que requiere un future fijado, se lo fija en el heap conBox::pin(future)o en la pila local constd::pin::pin!(future) - Implementación directa de
Future: al escribir una máquina de estados personalizada o primitivas async de bajo nivel, hay que manejarPin, y puede ser necesario usarPhantomPinnedy código unsafe para mantener las invariantes de pinning
- Consumo de código async: si hay que hacer
Pines la solución zero-cost de Rust para tratar problemas de tipos sensibles a la dirección- Gracias a eso, Rust puede usar
async/awaity otras abstracciones autorreferenciales manteniendo las garantías de seguridad de memoria sin garbage collector
1 comentarios
Opiniones en Lobste.rs
std::pin::Pines como una Monad del mundo Rust. Una vez que la entiendes, no puedes evitar escribir una entrada de blogSería bueno cubrir algunas cosas con las que otros y yo nos trabamos al intentar entender
PinEl nombre
Unpinno es muy bueno. Nombres más precisos, aunque tampoco muy buenos, habrían sidoMovableWhenPinnedoPinIsNoOpLa doble negación
!Unpinen nightly se ve rara, pero para dejar los tipos existentes como el caso predeterminado del 99%, hubo que agregar el auto traitUnpin, del que los tipos pueden optar por salirse. Si lo piensas como!MovableWhenPinned, tiene más sentidoPhantomPinned, 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 comoPhantomNotMovableWhenPinnedCuando empecé a traducirlo así en mi cabeza, lo entendí mucho mejor. Claro que todavía puede que estuviera confundido y solo haya tenido suerte
!Unpinme daba dolor de cabeza, pero cuando empecé a leerUnpincomoSafeToUnpin, se volvió un poco más llevaderoHice esta pregunta antes y creo que alguien me respondió con bastante consideración, pero ya no recuerdo. Según entiendo,
Pinsurgió 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 particularSi 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
Pinpor todas partes”, o si hay una razón estricta por la que las referencias relativas realmente no funcionan como alternativaSi 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
Pintambié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 bitsSegú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
SelfRefse mueva” me hizo pensar más en “el problema de impedir completamente el movimiento” que en el punto centralEl punto real está mucho más adelante: “
Pinno 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
Pinpara exponer datos autorreferenciales solo detrás de una referencia exclusiva en una API segura. Puede que yo ya entienda demasiadoPin, pero si se pule un poco la forma de explicarlo, el lector se perdería menosEste 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
Pinhace realmente, así que tiene sentido corregir el artículo para que eso quede claroValdría la pena mencionar en alguna parte del artículo que
!UnPinsolo puede expresarse en nightly Rust. Esa es la razón principal por la que existePhantomPinnedLo 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
*consten 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::polldirectamenteDicen que “el código seguro no puede recuperar un
&mut Tnormal”, pero también que “permite modificaciones normales”; entonces, ¿cómo se hace?Cosas como estas hicieron que dejara de profundizar en Rust
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::polles 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 comopoll, definidos en el traitFuture, para hacer el trabajoLa 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?”