1 คะแนน โดย GN⁺ 4 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • 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! และเมื่อ implement Future หรือ 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
  • หลังจากตั้งค่า 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 หรือเป็น !Unpin safe 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 โดยอัตโนมัติด้วย
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
  • หาก 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 ก็สิ้นสุดลง
  • Box::pin

    • constructor ที่พบบ่อยที่สุดสำหรับ type !Unpin คือ Box::pin
    let pinned = Box::pin(SelfRef { ... });
    
    • pin! สร้าง Pin ที่ผูกกับตัวแปรโลคัล แต่ Box::pin return Pin ที่ Box เป็นเจ้าของ
    • heap allocation เองไม่ถูกย้าย ดังนั้น pointee จึงมีตำแหน่งหน่วยความจำที่คงที่ตลอด lifetime ของ Box
    • ต่อให้ย้ายตัว Box เอง ค่าที่มันเป็นเจ้าของก็ไม่ถูกย้าย มีเพียง pointer ภายใน Box ที่ถูกย้าย
    • heap allocation ยังคงอยู่ที่ address เดิม
  • 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 นี้ได้

กรณีที่ต้องสนใจจริง ๆ

  • สำหรับ developer Rust ส่วนใหญ่ Pin และ Unpin ทำงานอยู่เบื้องหลังอย่างเงียบ ๆ
  • กรณีที่ต้องสนใจเองมีหลัก ๆ สองแบบ
    • การใช้งาน async code: หากต้อง poll future โดยตรงหรือส่งให้ 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
  • Pin คือวิธีแก้ปัญหา type ที่ไวต่อ address ใน Rust แบบ zero-cost
  • ด้วยสิ่งนี้ Rust สามารถใช้ async/await และ abstraction แบบ self-referential อื่น ๆ ได้ โดยยังคงรักษาการรับประกันความปลอดภัยของหน่วยความจำโดยไม่ต้องมี garbage collector

1 ความคิดเห็น

 
GN⁺ 4 시간 전
ความคิดเห็นจาก Lobste.rs
  • std::pin::Pin เหมือน Monad แห่งโลก Rust พอเข้าใจแล้วก็อดเขียนบล็อกโพสต์ไม่ได้

    • แต่บทความแบบนั้นมักตกหลุม monad tutorial fallacy ได้ง่าย
    • หมายถึงเหมือนตอน Monad ใช่ไหม ที่บทความบล็อกพวกนั้นจริง ๆ แล้วอธิบายอะไรสำคัญ ๆ ไม่ได้เลย?
  • น่าจะดีถ้าพูดถึงบางจุดที่ทั้งฉันและคนอื่น ๆ เคยติดเวลาพยายามทำความเข้าใจ Pin
    ชื่อ Unpin ไม่ค่อยดีนัก ชื่อที่แม่นกว่านี้แต่ก็ยังไม่ดีอยู่ดีอาจเป็น MovableWhenPinned หรือ PinIsNoOp
    double negative ของ !Unpin บน nightly ดูประหลาด แต่ถ้าจะให้กรณีปกติ 99% ของ type เดิมเป็นค่าเริ่มต้น ก็จำเป็นต้องเพิ่ม auto trait Unpin ที่ทำให้ 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 ใช้เป็นทางเลือกไม่ได้

    • ไม่ได้เป็นปัญหาเฉพาะกรณีที่ตัวแปร local ภายใน async state อ้างถึงตัวแปร local อื่นใน state เดียวกันเท่านั้น ถ้าเป็นแค่นั้น คอมไพเลอร์รู้จักตัวแปร local ทั้งหมดอยู่แล้ว จึงอาจทำให้การเข้าถึงเป็นแบบ relative ได้ แต่ถ้า reference ที่อยู่ลึกใน type หนึ่งชี้ไปยังค่าที่อยู่ลึกในอีก type หนึ่ง เรื่องจะซับซ้อนกว่ามาก
      ถ้า 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 ต่อ

    • raw pointer เป็นหนึ่งใน primitive type ของ Rust เอกสารอยู่ ที่นี่ และ ที่นี่
      แต่ก็จริงว่าถ้าไม่ลงไปทำงานระดับล่างก็แทบไม่มีเหตุให้ต้องใช้ ฉันเองก็เพิ่งมารู้จักตอนจะเรียกใช้ไลบรารี C
      Future::poll เป็นพื้นฐานของโค้ด asynchronous ใน Rust ไม่ใช่ว่าคุณจะเรียกเองโดยตรง แต่ executor จะเป็นคนเรียก Rust ไม่มี executor มาตรฐานในตัว จึงต้องเพิ่มอย่าง Tokio, smol หรือ pollster และพวกนี้ก็ใช้เมธอดอย่าง poll ที่นิยามใน trait Future เพื่อจัดการงาน
    • ฉันไม่ใช่ผู้เขียนต้นฉบับ และนี่ก็ไม่ใช่เหตุผลเพียงอย่างเดียว แต่เหตุผลที่ฉันต้องยุ่งกับ pointer ใน Rust คือ FFI และ โครงสร้างข้อมูลแบบ self-referential อย่างกราฟ
      เอกสารมีอยู่หลายแห่ง รวมถึง ที่นี่
      การคาดหวังให้คนอื่นอธิบายเฉพาะสิ่งที่ตัวคุณเองจำเป็นต้องใช้เท่านั้นก็ดูจะมากเกินไปหน่อย
      และตรง “แล้วทำอย่างไร?” ฉันก็ไม่ค่อยแน่ใจว่าคุณกำลังถามอะไร