std::pin::Pin ของ Rust คืออะไร?
(vrong.me)std::pin::Pinแสดงถึง การรับประกันในระดับ type ว่าค่าที่ pointer ชี้อยู่จะไม่ถูกย้ายผ่าน pointer นั้น และจำเป็นสำหรับค่าที่ต้องมี address คงที่ เช่น type ที่อ้างอิงถึงภายในตัวเอง- ใน
async/awaitตัวแปรโลคัลและ reference ที่ยังมีชีวิตข้าม.awaitอาจกลายเป็น field ของ state machine ที่ compiler สร้างขึ้น ดังนั้นFuture::pollจึงต้องการPinเพื่อป้องกันไม่ให้ future ถูกย้ายหลังจากเริ่ม poll แล้ว Pinป้องกันการ ย้ายค่าที่ถูก pin ด้วย safe code แต่ไม่ได้ห้ามการแก้ไขทั่วไป และถ้าไม่ใช่T: Unpinก็ไม่สามารถดึง&mut TออกจากPinได้อย่างปลอดภัย- type ส่วนใหญ่ใน Rust เป็น Unpin โดยปริยาย ดังนั้น struct แบบ self-referential ที่ไม่ควรถูกย้าย มักต้องใส่ field
PhantomPinnedเพื่อทำให้เป็น!Unpin - ในทางปฏิบัติ เมื่อ poll future โดยตรงหรือส่งต่อให้ API ที่ต้องการ pinned future จะใช้
Box::pinหรือstd::pin::pin!และเมื่อ implementFutureหรือ async primitive ระดับต่ำเอง ต้องจัดการไปถึง invariant ของunsafe
เหตุผลที่ต้องมี Pin
std::pin::Pinเป็น wrapper ของ pointer ที่แสดงการรับประกันว่าค่าที่ pointer ชี้อยู่จะไม่ถูกย้ายผ่าน pointer นั้น- ปัญหาหลักเกิดขึ้นกับ type แบบอ้างอิงถึงตัวเอง
- struct ตัวอย่าง
SelfRefมีdata: i32และptr: *const i32โดยptrชี้ไปที่self.data - หากย้าย instance ของ struct ไปยังตัวแปรอื่น หรือ return ออกจาก function ตำแหน่งในหน่วยความจำอาจเปลี่ยนได้
- raw pointer
ptrจะยังชี้ไปยังตำแหน่งหน่วยความจำเดิม และกลายเป็น dangling pointer
- struct ตัวอย่าง
- หลังจากตั้งค่า self-reference แล้ว จึงต้องมีกลไกป้องกันไม่ให้ค่านั้นถูกย้ายอีก
ปัญหาที่เกิดขึ้นใน async/await และ Future
async/awaitและ Future เป็นพื้นที่ตัวอย่างที่พบPinบ่อย- ตัวแปรโลคัลที่ยังมีชีวิตข้ามจุด
.awaitจะกลายเป็น field ของ state machine ที่ compiler สร้างขึ้น - หาก reference ไปยังตัวแปรโลคัลใด ๆ ยังมีชีวิตข้าม
.awaitเดียวกัน future ที่ถูกสร้างขึ้นอาจเป็นแบบ self-referential - หลังจากเริ่ม poll แล้ว future อาจพึ่งพา reference ที่ชี้ไปยัง field อื่นภายในตัวเอง
- หาก future ถูกย้ายในสถานะนี้ reference ดังกล่าวจะใช้ไม่ได้
- เพื่อป้องกันสิ่งนี้
Future::pollจึงรับPinแทน&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
{ ... }
}
- หาก type ไม่ implement
Unpinหรือเป็น!Unpinsafe code เพียงอย่างเดียวจะไม่สามารถได้&mut T - ในกรณีนี้ต้องใช้ unsafe method อย่าง
Pin::get_unchecked_mutและ code ต้องรักษาสัญญาว่าค่าจะไม่ถูกย้ายออกจาก reference นั้น
Unpin และ PhantomPinned
- type ที่ implement
Unpinไม่ได้พึ่งพา pinning เพื่อความปลอดภัยของหน่วยความจำ
// std::marker
pub auto trait Unpin {}
- type ส่วนใหญ่ของ Rust ย้ายได้โดยไม่มีปัญหา จึงเป็น
Unpinโดยปริยาย- ตัวอย่าง:
i32,String,Vec
- ตัวอย่าง:
Unpinจะถูก implement ให้อัตโนมัติกับทุก type ตราบเท่าที่ไม่ได้ทำให้เป็น!Unpinอย่างชัดเจนstd::marker::PhantomPinnedเป็น marker struct ที่เป็น!Unpinอย่างชัดเจน- เนื่องจาก auto trait ถูกส่งต่อโดยอัตโนมัติ struct ที่มี field
PhantomPinnedก็จะกลายเป็น!Unpinโดยอัตโนมัติด้วย
- เนื่องจาก auto trait ถูกส่งต่อโดยอัตโนมัติ struct ที่มี field
use std::marker::PhantomPinned;
struct SelfRef {
data: i32,
ptr: *const i32,
_phantom: PhantomPinned, // makes the entire struct !Unpin
}
- นี่คือวิธีมาตรฐานในการประกาศว่า struct ที่ผู้ใช้กำหนดเองจะไม่ปลอดภัยหากถูกย้ายหลังจากถูก pin แล้ว
- โดยทั่วไป compiler ไม่สามารถตรวจจับ self-reference ที่สร้างด้วย unsafe raw pointer ได้โดยอัตโนมัติ
- ดังนั้น developer ต้องสละ
Unpinอย่างชัดเจนสำหรับ struct แบบ self-referential- โดยปกติทำด้วยการใส่ field
PhantomPinned
- โดยปกติทำด้วยการใส่ field
- หาก type แบบ self-referential เผลอยังคงเป็น
Unpinอยู่ safe code จะสามารถดึง mutable reference ออกจากPinแล้วย้ายค่าได้- ซึ่งจะทำลายสมมติฐานของ unsafe code ที่สร้าง self-reference นั้น
วิธีสร้าง Pin
-
ตัว
Pinเองไม่ได้เป็นสิ่งที่ตรึงค่าไว้ -
การสร้าง
Pinคือการพิสูจน์ว่า pointee นั้นจะอยู่ใน ตำแหน่งหน่วยความจำที่คงที่ ตลอด lifetime ของ pin -
Pin::new- วิธีสร้างที่เรียบง่ายที่สุดคือ
Pin::new
let mut value = 42; let pinned = Pin::new(&mut value);- constructor นี้ใช้ได้เฉพาะเมื่อ
T: Unpin - type ที่เป็น
Unpinไม่ได้พึ่งพา pinning ดังนั้นจึงปลอดภัยเสมอเมื่อนำมาห่อด้วยPin - ในกรณีนี้การรับประกันของ pinning แทบจะเป็น no-op
- วิธีสร้างที่เรียบง่ายที่สุดคือ
-
std::pin::pin!- เมื่อต้องการ pin ค่าแบบโลคัลโดยไม่ต้อง allocate บน heap สามารถใช้ macro
pin!ได้
use std::pin::pin; let future = pin!(async { println!("Hello"); });- macro นี้สร้างตัวแปรโลคัล และ return
Pinที่ชี้ไปยังตัวแปรนั้น - compiler รับประกันว่าตัวแปรโลคัลนั้นจะไม่ถูกย้ายตลอด lifetime ที่เหลือ จึงสามารถ pin ค่า
!Unpinบน stack ได้อย่างปลอดภัย - แม้ชื่อจะเป็นเช่นนั้น แต่
pin!ไม่ได้ pin หน่วยความจำบน stack เอง - เพียงแค่สร้าง reference ที่ถูก pin ซึ่งผูกกับตัวแปรโลคัล และเมื่อ variable ออกจาก scope การรับประกัน pinning ก็สิ้นสุดลง
- เมื่อต้องการ pin ค่าแบบโลคัลโดยไม่ต้อง allocate บน heap สามารถใช้ macro
-
Box::pin- constructor ที่พบบ่อยที่สุดสำหรับ type
!UnpinคือBox::pin
let pinned = Box::pin(SelfRef { ... });pin!สร้างPinที่ผูกกับตัวแปรโลคัล แต่Box::pinreturnPinที่Boxเป็นเจ้าของ- heap allocation เองไม่ถูกย้าย ดังนั้น pointee จึงมีตำแหน่งหน่วยความจำที่คงที่ตลอด lifetime ของ
Box - ต่อให้ย้ายตัว
Boxเอง ค่าที่มันเป็นเจ้าของก็ไม่ถูกย้าย มีเพียง pointer ภายในBoxที่ถูกย้าย - heap allocation ยังคงอยู่ที่ address เดิม
- constructor ที่พบบ่อยที่สุดสำหรับ type
-
Pin::new_unchecked- เมื่อ constructor แบบ safe ไม่สามารถพิสูจน์ได้ว่าค่าจะอยู่ที่เดิม สามารถสร้าง
Pinโดยตรงด้วย unsafe code ได้
let pinned = unsafe { Pin::new_unchecked(ptr) };- ผู้เรียก
Pin::new_uncheckedให้สัญญาว่า pointee จะไม่ถูกย้ายอีกผ่าน pointer ใด ๆ ตลอด lifetime ของPinที่ถูก return - หากผิดสัญญานี้ code ที่พึ่งพาการรับประกันของ pinning อาจเกิด undefined behavior
- ดังนั้นโดยทั่วไปจะใช้เฉพาะตอน implement abstraction ระดับต่ำที่สามารถรักษา invariant นี้ได้
- เมื่อ constructor แบบ safe ไม่สามารถพิสูจน์ได้ว่าค่าจะอยู่ที่เดิม สามารถสร้าง
กรณีที่ต้องสนใจจริง ๆ
- สำหรับ developer Rust ส่วนใหญ่
PinและUnpinทำงานอยู่เบื้องหลังอย่างเงียบ ๆ - กรณีที่ต้องสนใจเองมีหลัก ๆ สองแบบ
- การใช้งาน async code: หากต้อง
pollfuture โดยตรงหรือส่งให้ API ที่ต้องการ pinned future ให้ pin ไว้บน heap ด้วยBox::pin(future)หรือ pin บน stack แบบโลคัลด้วยstd::pin::pin!(future) - การ implement Future เอง: เมื่อเขียน state machine แบบกำหนดเองหรือ async primitive ระดับต่ำ ต้องจัดการกับ
Pinและอาจต้องใช้PhantomPinnedกับ unsafe code เพื่อรักษา pinning invariant
- การใช้งาน async code: หากต้อง
Pinคือวิธีแก้ปัญหา type ที่ไวต่อ address ใน Rust แบบ zero-cost- ด้วยสิ่งนี้ Rust สามารถใช้
async/awaitและ abstraction แบบ self-referential อื่น ๆ ได้ โดยยังคงรักษาการรับประกันความปลอดภัยของหน่วยความจำโดยไม่ต้องมี garbage collector
1 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
std::pin::Pinเหมือน Monad แห่งโลก Rust พอเข้าใจแล้วก็อดเขียนบล็อกโพสต์ไม่ได้น่าจะดีถ้าพูดถึงบางจุดที่ทั้งฉันและคนอื่น ๆ เคยติดเวลาพยายามทำความเข้าใจ
Pinชื่อ
Unpinไม่ค่อยดีนัก ชื่อที่แม่นกว่านี้แต่ก็ยังไม่ดีอยู่ดีอาจเป็นMovableWhenPinnedหรือPinIsNoOpdouble negative ของ
!Unpinบน nightly ดูประหลาด แต่ถ้าจะให้กรณีปกติ 99% ของ type เดิมเป็นค่าเริ่มต้น ก็จำเป็นต้องเพิ่ม auto traitUnpinที่ทำให้ type นั้นหลุดจากข้อจำกัดได้ เลยออกมาเป็นแบบนั้นถ้าคิดเสียว่าเป็น
!MovableWhenPinnedก็จะสมเหตุสมผลขึ้นทางเลือกบน stable อย่าง
PhantomPinnedก็ชื่อไม่ค่อยดีเหมือนกัน เพราะสภาวะ pinned เป็นสถานะชั่วคราวที่เกิดจากการมี pinned reference ไม่ใช่คุณสมบัติของ type เองถ้าจะตั้งชื่ออีกแบบอาจเป็น
PhantomNotMovableWhenPinnedพอเริ่มแปลความในหัวแบบนี้แล้วก็เข้าใจขึ้นเยอะ แน่นอนว่ายังสับสนอยู่บ้าง แต่อาจเป็นเพราะโชคดีด้วย
!Unpinทำเอาปวดหัว พอเริ่มอ่านUnpinเป็นSafeToUnpinก็รู้สึกดีขึ้นหน่อยเหมือนเคยถามคำถามนี้มาก่อน แล้วมีคนตอบไว้อย่างรอบคอบ แต่ตอนนี้จำไม่ได้แล้ว ความเข้าใจของฉันคือ
Pinมาจาก async และปัญหาที่ reference ของตัวแปร local กลายเป็น self-reference อยู่ภายในก้อนข้อมูลที่แทน state machine ของฟังก์ชันนั้นถ้า async state ถูกย้าย reference ของตัวแปร local พวกนั้นก็จะยังชี้ไปยังตำแหน่งเดิมที่ไม่ถูกต้อง
แต่นั่นไม่ใช่เพราะ reference เป็น pointer จริงที่มี absolute address เต็มรูปแบบหรอกหรือ? เลยสงสัยว่าทำไมวิธีแก้ถึงเป็นการตัดความสามารถในการย้าย แทนที่จะทำให้ reference เป็น relative address
คำตอบโดยรวมคือ “เพราะคอมไพเลอร์ CPU และ OS ถูกลงทุนรวมกันเป็นเวลาหลายล้านวิศวกร-ปีให้จัดการ pointer ได้ดีมาก pointer เลยดีกว่าในหลายด้าน และดังนั้นการใช้
Pinตรงนั้นตรงนี้จึงดีกว่า” ใช่ไหม หรือมีเหตุผลเชิงแข็งจริง ๆ ว่า relative reference ใช้เป็นทางเลือกไม่ได้ถ้า reference เป็นแบบ relative type เหล่านั้นก็ต้องมี memory representation ต่างกันขึ้นอยู่กับว่าจะถูกใช้ใน async state หรือไม่ และยังต้องมีแนวคิดเรื่อง base pointer ที่ต้องส่งไปด้วยเพื่อกู้ pointer จริงกลับมาจาก relative reference
วัตถุซ้อนอยู่ภายใน pinned reference ยังสามารถถูกย้ายได้อย่างอิสระ แม้ว่าวัตถุรากจะถูก pin อยู่ ดังนั้นก็พูดไม่ได้ด้วยว่า relative reference สมมุติทั้งหมดจะอิง base pointer เดียวกัน
สุดท้ายแล้วก็ยังต้องใช้ absolute pointer และ relative reference ไม่ค่อยเข้ากันนัก ถ้าอย่างนั้น ในเมื่อคอมไพเลอร์ Rust รู้จัก type ทั้งหมดตรงนี้อยู่แล้ว จะให้มันติดตาม object graph ทั้งหมดแล้วเวลา object ถูกย้ายก็คอยแก้ reference ที่ชี้มายัง object นั้นให้เป็นตำแหน่งใหม่ได้ไหม? ถ้าทำแบบนั้นก็แทบจะเท่ากับสร้าง tracing garbage collector ขึ้นมาแล้ว
แถมคอมไพเลอร์ Rust ก็ไม่ได้รู้จักทุก type ใน object graph ด้วย reference อาจถูกส่งข้าม FFI และไลบรารีภายนอกอาจเก็บ reference นั้นไว้ การแก้ moving reference ข้ามขอบเขต FFI จึงแทบเป็นปัญหาที่รับมือไม่ได้
สรุปคือมันยากมาก และอีกประเด็นสำคัญคือการย้าย object เองก็เป็นเทคนิคที่ค่อนข้างใหม่ ในโปรแกรม C/C++ ส่วนใหญ่แทบมองได้ว่า object ทุกตัวถูก pin โดยปริยาย เหตุที่ฝั่งนั้นพูดถึง pinning กันน้อยกว่า ก็เพราะ object มักไม่ถูกย้ายอยู่แล้ว หรือถ้าย้าย ก็เป็นหน้าที่ของโปรแกรมเมอร์ที่จะต้องแน่ใจว่าไม่มี reference ค้างอยู่
Pinยังจำเป็นต่อ การทำงานร่วมกัน ระหว่าง Rust กับภาษาอื่นที่ไม่สามารถย้ายหน่วยความจำแบบตามใจเป็นก้อนบิตทึบ ๆ ได้ด้วยเท่าที่ฉันเข้าใจ ปัญหาหนึ่งของการทำงานร่วมกับ C++ คือ object ไม่ใช่แค่ก้อนบิตธรรมดาที่จะย้ายไปมาได้อย่างอิสระ สุดท้ายจึงต้องใช้ pinning กับ type จำนวนไม่น้อย และทำให้การใช้งานไม่ค่อยสะดวก
อย่างไรก็ตาม นี่อิงจากบทสนทนากับคนที่ทำเรื่องนี้เมื่ออย่างน้อยราว 6 เดือนก่อน จึงไม่รู้ว่าหลังจากนั้นสถานการณ์ดีขึ้นแค่ไหน
โดยรวมแล้วคิดว่าเป็นคำอธิบายที่อ่านง่ายดี นอกเหนือจากเอกสารทางการของ Rust วิธีพาเข้าสู่ปัญหาก็นุ่มนวลกว่าเล็กน้อย
แต่ฉันกลับคิดว่าการเริ่มจาก struct แบบ self-referential ทำให้งงมากกว่าตัดทิ้งเสียอีก โดยเฉพาะประโยคช่วงต้นที่ว่า “ดังนั้นหลังจากสร้าง self-reference แบบนั้นขึ้นมาแล้ว ก็จำเป็นต้องมีวิธีป้องกันการย้าย
SelfRef” มันทำให้เผลอไปนึกถึง “ปัญหาการห้ามย้ายโดยสิ้นเชิง” มากกว่าประเด็นหลักแก่นจริง ๆ อยู่ที่ประโยคซึ่งมาทีหลังมากว่า “
Pinไม่ได้ป้องกันไม่ให้ค่าถูกย้ายทางกายภาพ แต่เป็นการรับประกันในระดับ type ว่าค่านั้นจะไม่ถูกย้ายผ่าน pointer นั้น”เพราะเราไม่สามารถห้ามการย้ายได้จริง จึงใช้
Pinเพื่อให้ API ที่ปลอดภัยเปิดเผยข้อมูลแบบ self-referential ได้เฉพาะหลัง reference แบบเอกสิทธิ์เท่านั้น อาจเป็นเพราะฉันเข้าใจPinไปมากแล้วก็ได้ แต่ถ้าปรับวิธีอธิบายนิดหน่อย คนอ่านน่าจะหลงทางน้อยลงบทความนี้เอามาจากโน้ตของฉันเกี่ยวกับ pinning และตอนแรกฉันเองก็เข้าใจแบบนั้นเหมือนกัน ฉันรู้สึกว่าสวยงามที่ปัญหาอย่าง “การป้องกันการย้าย” สามารถแก้ได้ด้วย การรับประกันในระดับ type
แน่นอนว่านั่นไม่ใช่สิ่งที่
Pinทำจริง ๆ ดังนั้นก็ควรแก้บทความให้จุดนั้นชัดขึ้นน่าจะมีบอกไว้สักแห่งในบทความนี้ว่า
!UnPinเขียนแสดงได้เฉพาะบน nightly Rust นั่นคือเหตุผลหลักที่มีPhantomPinnedพูดว่าเป็น “pointer wrapper” แต่แม้ใน Rust เองก็แทบไม่มีเหตุให้ต้องยุ่งกับ pointer ไม่เข้าใจว่าทำไมต้องใช้
หาเอกสารของ
*constจาก Google ยากมาก เลยสงสัยว่ามีการทำเอกสารไว้หรือเปล่าแล้วเรื่องที่ว่า “กลายเป็น field ของ state machine ที่คอมไพเลอร์สร้างขึ้น” นี่จำเป็นต้องรู้ด้วยหรือ? หรือแค่จะบอกว่าข้อผิดพลาดจากคอมไพเลอร์สุดพิลึกนั้นจริง ๆ แล้วหมายความว่ามีเรื่องแบบนี้เกิดขึ้น?
แล้ว “future ที่สร้างขึ้นกลายเป็น self-referential” นี่เป็นสิ่งที่เกิดขึ้นโดยปริยายทุกครั้งที่ใช้ future หรือเปล่า?
เหมือนฉันไม่เคยเขียน
Future::pollเองโดยตรงบอกว่า “โค้ดที่ปลอดภัยไม่สามารถกู้
&mut Tปกติกลับมาได้” แต่ก็ยังบอกว่า “อนุญาตให้แก้ไขตามปกติได้” งั้นมันทำกันอย่างไร?เรื่องพวกนี้แหละที่ทำให้ฉันเลิกขุดลึก Rust ต่อ
แต่ก็จริงว่าถ้าไม่ลงไปทำงานระดับล่างก็แทบไม่มีเหตุให้ต้องใช้ ฉันเองก็เพิ่งมารู้จักตอนจะเรียกใช้ไลบรารี C
Future::pollเป็นพื้นฐานของโค้ด asynchronous ใน Rust ไม่ใช่ว่าคุณจะเรียกเองโดยตรง แต่ executor จะเป็นคนเรียก Rust ไม่มี executor มาตรฐานในตัว จึงต้องเพิ่มอย่าง Tokio, smol หรือ pollster และพวกนี้ก็ใช้เมธอดอย่างpollที่นิยามใน traitFutureเพื่อจัดการงานเอกสารมีอยู่หลายแห่ง รวมถึง ที่นี่
การคาดหวังให้คนอื่นอธิบายเฉพาะสิ่งที่ตัวคุณเองจำเป็นต้องใช้เท่านั้นก็ดูจะมากเกินไปหน่อย
และตรง “แล้วทำอย่างไร?” ฉันก็ไม่ค่อยแน่ใจว่าคุณกำลังถามอะไร