1 คะแนน โดย GN⁺ 2024-07-22 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • Pin ของ Rust เป็นองค์ประกอบพื้นฐานที่ถูกนำมาใช้เพื่อจัดการสถานะแบบ self-referential ภายใน Future ที่สร้างโดย async/await ได้อย่างปลอดภัย
  • async Future จะบันทึกสถานะไว้ในแต่ละจุด await ดังนั้นฟิลด์หนึ่งในออบเจ็กต์อาจอ้างอิงไปยังฟิลด์อื่นในออบเจ็กต์เดียวกัน จนกลายเป็น ชนิดแบบ self-referential ได้
  • แนวทางอย่าง move constructor, offset pointer และการออกแบบ ?Move ไม่ได้ถูกเลือกใช้ เนื่องจากแต่ละแบบมีปัญหาเรื่องต้นทุนการติดตามตอนรันไทม์ ความเป็นไปได้ในการคอมไพล์ และ ความเข้ากันได้ย้อนหลัง กับ API เดิม
  • การออกแบบสุดท้ายคือ Pin ซึ่งห่อ pointer แล้วนำเป้าหมายเข้าสู่ pinned typestate และด้วย auto trait Unpin ชนิดส่วนใหญ่จึงยังย้ายได้เหมือนเดิม
  • ความยากของ Pin ไม่ได้มาจากแนวคิดเรื่อง immovability เอง แต่มาจาก ข้อจำกัดของชนิดที่เป็นไลบรารี โดย reborrowing, Pin::set, pinned projection และการโต้ตอบกับ Drop ทำให้การใช้งานยากลงอย่างมาก

ปัญหาที่ทำให้ต้องมี Pin

  • ในระบบนิเวศ async ของ Rust นั้น Pin และ pinning เป็นรากฐานสำคัญ แต่สำหรับคนที่เรียน async Rust ก็ยังเป็นพื้นที่ที่ยากและมักเข้าใจผิดกันมาก
  • เป้าหมายของ Pin ไม่ใช่การทำให้ผู้ใช้สร้างชนิดแบบ self-referential เองได้ด้วย safe Rust เพียงอย่างเดียว
    • แต่คือการทำให้สามารถ จัดการได้อย่างปลอดภัย กับ Future แบบ self-referential ที่คอมไพเลอร์สร้างจาก async function หรือชนิดแบบ self-referential ที่ runtime อย่าง tokio สร้างด้วยโค้ด unsafe
  • ตัวอย่าง async fn bar ต้องบันทึกทั้ง z และ Foo Future ที่อ้างอิง z ไว้ด้วยกันในสถานะ Future เดียวกัน ณ จุด foo(&mut z).await
    • ณ จุดนี้ ฟิลด์หนึ่งภายในออบเจ็กต์ Future อ้างอิงไปยังฟิลด์อื่นในออบเจ็กต์เดียวกัน
    • ชนิด Future แบบนี้จึงกลายเป็น ชนิดแบบ self-referential
  • หากออบเจ็กต์ถูกย้ายหลังจากเข้าสู่สถานะเช่นนี้ reference ภายในจะยังชี้ไปยังตำแหน่งหน่วยความจำเดิม ซึ่งตำแหน่งนั้นอาจเป็นหน่วยความจำที่ตายไปแล้ว หรือถูกนำไปใช้กับค่าอื่นได้
  • ก่อนมี Pin ใน Rust หากมี ownership หรือ mutable reference ก็สามารถย้ายออบเจ็กต์ได้ จึงจำเป็นต้องมีวิธีแสดงข้อห้ามย้ายหลังจากจุดใดจุดหนึ่ง

แนวทางที่ไม่สามารถเป็นคำตอบได้

  • move constructor

    • move constructor คือวิธีที่เมื่อค่าถูกย้าย จะรันโค้ดเหมือน destructor เพื่อแก้ pointer แบบ self-referential ให้ชี้ไปยังตำแหน่งใหม่
    • ใน Rust pointer ไม่ได้อยู่แค่ “ภายใน” ค่าที่ถูกย้ายเท่านั้น แต่อาจอยู่ในเวกเตอร์ของ pointer ที่ชี้ไปยังสถานะของตัวเองได้ด้วย
    • หากต้องติดตาม pointer เหล่านี้ทั้งหมด สุดท้ายก็ต้องมีการจัดการหน่วยความจำตอนรันไทม์ที่คล้าย garbage collection
    • Rust ตัดสินใจตั้งแต่แรกว่าจะไม่มี move constructor และโค้ด unsafe จำนวนมากก็อาศัยสมมติฐานว่าสามารถย้ายค่าด้วยการคัดลอกหน่วยความจำอย่างเดียวได้
    • หากเพิ่ม move constructor ภายหลังจะกลายเป็น breaking change
  • offset pointer

    • offset pointer คือวิธีคอมไพล์ self-reference ให้เป็น offset จากที่อยู่ของออบเจ็กต์ self-referential แทนการใช้ reference ปกติ
    • ตอนคอมไพล์ไม่สามารถตัดสินได้เสมอไปว่า reference ใดเป็น self-reference
    • ในแต่ละ branch ค่าเดียวกันอาจชี้ไปยังภายในออบเจ็กต์ของตัวเอง หรือชี้ไปยังภายนอกก็ได้
    • หากจะรองรับกรณีนี้ ต้องคอมไพล์ reference เป็นรูปแบบคล้าย enum ของ offset กับ reference ซึ่งในช่วงที่ทำ async/await นั้นถูกมองว่าเป็นแนวทางที่ไม่สมจริง

ข้อกำหนดของ pinned typestate

  • Future แบบ self-referential ไม่จำเป็นต้องย้ายไม่ได้ตั้งแต่ต้นเสมอไป แต่ควรย้ายได้อย่างอิสระจนถึงจุดหนึ่งใน lifecycle แล้วหลังจากจุดนั้นจึงห้ามย้าย
    • ระหว่างที่ประกอบ Future เข้ากับ Future อื่น ควรยังย้ายได้
    • หลังจากถูกจัดวางในตำแหน่งที่จะอยู่ระหว่างถูก poll แล้ว ไม่ควรถูกย้ายอีก
  • โมเดลของ Ralf Jung เพิ่มสถานะที่สามสำหรับ Future แบบ self-referential คือ pinned typestate ต่อจาก typestate เดิมอย่าง “owned” และ “shared”
  • เมื่อออบเจ็กต์เข้าสู่ pinned typestate แล้ว จะต้องไม่ถูกย้ายอีก
    • กล่าวให้แม่นยำขึ้นคือ ห้ามทำให้หน่วยความจำของออบเจ็กต์นั้น invalid โดยไม่รัน destructor ก่อน
    • ในทางปฏิบัติสามารถมองเป็นข้อกำหนดว่าไม่ควรย้ายออบเจ็กต์ไปยังตำแหน่งใหม่
  • ชนิดส่วนใหญ่ไม่สามารถมี self-reference ได้ ดังนั้น pinned typestate จึงไม่ได้มีความหมายพิเศษมากนัก
    • สำหรับชนิดเหล่านี้ ควรสามารถหลุดจากข้อจำกัดของ pinning แล้วกลับมาย้ายได้อีก
  • โมเดลเชิงรูปแบบโดยละเอียดของ pinned typestate สรุปไว้ใน A Formal Look at Pinning ของ Ralf Jung

เหตุผลที่การออกแบบ ?Move ล้มเหลว

  • ก่อนมี Pin เคยมีการลองออกแบบบนพื้นฐาน trait ใหม่ชื่อ Move
    • ชนิดส่วนใหญ่ implement Move
    • ชนิดที่อาจมี self-reference จะไม่ implement Move
    • เมื่อสร้าง reference ไปยังค่าของชนิดที่ไม่ implement Move ค่านั้นจะเข้าสู่ pinned typestate และไม่สามารถย้ายได้อีก
  • วิธีนี้เข้าใจง่ายในแง่ที่เชื่อมช่วงเวลาที่สร้าง reference เข้ากับการเปลี่ยนสถานะเป็น pinning เพื่อรับประกันความปลอดภัย
    • และเคยถูก implement ใน branch ของคอมไพเลอร์จริงด้วย
  • ข้อจำกัดพื้นฐานคือ มีกรณีที่อยากอ้างอิงค่าที่ในภายหลังจะกลายเป็น self-referential ชั่วคราว แต่ยังไม่อยาก pin ในตอนนั้น
    • เช่น อาจอยากเก็บค่าไว้ใน Option ชั่วคราว แล้วดึงออกมาด้วย Option::take
  • ปัญหาที่ใหญ่กว่าคือ ความเข้ากันได้ย้อนหลัง
    • ไม่สามารถทำให้ Move เป็น auto trait ได้
    • เพราะมี API stable อย่าง mem::swap อยู่แล้ว ซึ่งถือว่าสามารถย้ายค่าจาก mutable reference ได้เสมอ
  • วิธีเพิ่มด้วย ?Move ก็ไม่เข้ากันย้อนหลังเช่นกันเพราะ associated type
    • ตำแหน่งที่จะเพิ่ม bound แบบ ?Trait ให้ associated type คือส่วนประกาศ trait
    • หากผ่อน bound ของ associated type ใน trait เดิม โค้ดที่พึ่งพา bound นั้นอาจพังได้
    • การดำเนินการพื้นฐานจำนวนมากเกี่ยวพันกับ associated type เช่น associated future type ของ IntoFuture, Target ของ DerefMut, ชนิดคืนค่าของฟังก์ชัน, item ของ iterator, ค่าที่คืนจาก index operator และค่าที่คืนจาก arithmetic operator
  • แม้ใช้ edition ก็แก้ได้ไม่ง่าย
    • เพราะเพื่อให้ crate จาก edition ต่างกันประกอบกันได้ interface ของ trait ต้องคงเหมือนกัน

การออกแบบ Pin

  • การออกแบบสุดท้ายแสดง pinned typestate ไม่ใช่เป็นคุณสมบัติของชนิดออบเจ็กต์ แต่เป็นสถานะที่สร้างโดย pointer แบบพิเศษ
  • Pin เป็น wrapper type ที่ห่อ pointer
    • สามารถห่อ built-in reference type ได้
    • สามารถห่อ smart pointer ที่นิยามในไลบรารี เช่น Box ได้
  • Pin นำเป้าหมายที่ pointer นั้นชี้ไปเข้าสู่ pinned typestate และเป้าหมายนั้นต้องไม่ถูกย้ายอีก
  • เพื่อลดการเปลี่ยนแปลงให้น้อยที่สุด การออกแบบนี้จึง implement เป็น API ของไลบรารี ไม่ใช่ฟีเจอร์ของคอมไพเลอร์
    • โค้ดที่ต้องแก้ไขออบเจ็กต์ที่ถูก pin จริง ๆ ต้องเข้าผ่าน API แบบ unsafe
    • ในตอนนั้นต้องให้หลักประกันว่าออบเจ็กต์จะไม่ถูกย้ายผ่าน mutable reference ปกติ
  • เนื่องจากชนิดส่วนใหญ่ไม่มีความแตกต่างที่มีความหมายระหว่างสถานะ pinned กับสถานะปกติ จึงเพิ่ม auto trait Unpin
    • หากชนิดหนึ่งไม่สามารถเป็น self-referential ได้ ก็สามารถรับ mutable reference จาก pinned pointer ได้โดยไม่ต้องใช้ unsafe
    • ออบเจ็กต์ที่ implement Unpin นั้นปลอดภัยแม้ถูกย้ายออกจาก Pin
  • เนื่องจาก pinning มีผลเฉพาะกับ pinned pointer เท่านั้น reference ปกติที่ไม่ถูก pin จึงยังทำงานกับชนิดที่ไม่ใช่ Unpin ได้ต่อไป
  • คำอธิบายเพิ่มเติมสรุปไว้ในชนิด Pin และโมดูล pin ของเอกสารมาตรฐาน
  • ข้อดีที่ใหญ่ที่สุดของการออกแบบนี้คือสามารถเพิ่มเข้ามาได้โดยไม่ทำให้โค้ดเดิมพัง
    • API ที่สามารถย้ายข้อมูลที่ถูกอ้างอิง เช่น swap ต้องใช้ mutable reference
    • เมื่อ pin ออบเจ็กต์ด้วย Pin แล้ว จะไม่สามารถเรียก API เหล่านั้นกับออบเจ็กต์นั้นได้อีก
    • เพราะ pinned typestate มีผลเฉพาะกับ pinned reference แบบพิเศษ จึงไม่ทำลายการรับประกันความเข้ากันได้ย้อนหลังของภาษา Rust ทั้งหมด

ปัญหาด้านการใช้งานของ Pin

  • Pin ตอบโจทย์ข้อกำหนดได้ในแบบที่เข้ากันย้อนหลัง แต่ทันทีที่ผู้ใช้ต้องจัดการเอง จะเกิด หน้าผาความซับซ้อน
  • คำอธิบายหนึ่งคือ การแก้ไขออบเจ็กต์ที่ถูก pin ต้องใช้โค้ด unsafe
    • อย่างไรก็ตาม ไม่ควรกล่าวเกินจริงกับปัญหานี้
    • สามารถกำหนดค่าให้ pinned object ได้อย่างปลอดภัยด้วย Pin::set
    • โค้ดที่ต้องแก้ไข pinned object จริง ๆ โดยทั่วไปคือโค้ดที่คอมไพเลอร์สร้างขณะ lower async function เป็น Future และผู้ใช้แทบไม่ค่อยเขียนเองโดยตรง
  • คำอธิบายว่า Pin ยากเพราะทำงานแบบมีเงื่อนไขก็ไม่ใช่สาเหตุหลัก
    • Rust มีฟีเจอร์ที่ทำงานแตกต่างกันตามเงื่อนไข แต่ยังทำให้เข้าใจง่ายได้
    • non-lexical lifetimes เป็นกรณีที่ทำให้ lifetime สิ้นสุดในจุดที่ต่างกันในแต่ละ branch
  • ปัญหาหลักคือ Pin เป็น ชนิดของไลบรารี ล้วน ๆ ขณะที่ reference type ปกติเป็นชนิด built-in ของภาษา จึงได้รับการสนับสนุนจาก syntax และ sugar หลายแบบ
    • ฟังก์ชันที่เคยทำได้อย่างเป็นธรรมชาติบน reference ปกติจะหายไปเมื่อเป็น pinned reference
    • mental model ที่ผู้ใช้สร้างขึ้นจากพฤติกรรม reference ที่คอมไพเลอร์ยอมรับ จะใช้ไม่ได้กับ pinned reference

reborrowing และ Pin::as_mut

  • mutable reference ปกติ &mut T ไม่ implement Copy แต่สามารถส่งเป็น argument เดิมหลายครั้งได้
    • เพราะคอมไพเลอร์ทำ reborrowing ให้โดยนัย ราวกับใส่ &mut *x แทน x
  • Pin<&mut T> เป็นชนิดไลบรารีปกติและไม่ได้ implement Copy จึงไม่มีความสะดวกแบบนี้
    • หากใช้ Pin<&mut T> สองครั้งขึ้นไป อาจเกิด error ว่าใช้ค่าหลัง move แล้ว หรือเกิด lifetime error ที่เข้าใจยากกว่า
    • ต้องเรียก Pin::as_mut อย่างชัดเจนเพื่อ reborrow
  • mutable reference ปกติสามารถ assign ได้โดยตรงด้วย dereference และ assignment operator แต่สำหรับ Pin ต้องเรียนรู้เมธอด set
    • เหตุผลที่มี API พิเศษแบบนี้มากขึ้นคือ Pin เป็นชนิดไลบรารีที่ไม่มีการสนับสนุน syntax จากภาษา

pinned projection และ Drop

  • pinned projection คือปัญหาของการได้ pinned reference ไปยังฟิลด์ของออบเจ็กต์ จาก pinned reference ไปยังออบเจ็กต์นั้น
    • projection หมายถึงการเข้าถึงจากออบเจ็กต์ไปยังฟิลด์
  • เรื่องนี้ยากกว่าการเข้าถึงฟิลด์ของ reference ปกติมาก จึงมีการใช้ crate ภายนอกอย่าง pin-project-lite
    • crate เหล่านี้ต้องเรียนรู้ API ใหม่ที่ซับซ้อน รวมถึง macro ด้วย
  • การโต้ตอบที่แย่ที่สุดเกิดขึ้นระหว่าง pinned projection กับ trait Drop
    • Drop::drop รับ mutable reference ปกติ
    • หากชนิดหนึ่งมีฟิลด์แบบ self-referential และทำ pin project ไปยังฟิลด์นั้นเพื่อ poll แล้วใน destructor ย้ายฟิลด์นั้นออกไป ก็อาจทำลายการรับประกันของ pinning ได้
    • เช่น หากใน destructor pin future นั้นไว้บน stack แล้ว poll การรับประกัน pinning เดิมจะถูกละเมิด
  • crate อย่าง pin-project-lite จัดการปัญหานี้ด้วยการจำกัดความสามารถในการนิยาม destructor
    • ในทางปฏิบัติใช้งานได้ แต่เพิ่มความซับซ้อนที่ต้องบันทึกไว้ในเอกสารเมื่ออธิบายการรับประกันของ pinning
    • Drop stable มาก่อน Pin จึงจำเป็นต้องมีทางอ้อม

การประเมินปัจจุบันและทิศทางปรับปรุงถัดไป

  • Pin ทำให้สามารถคอมไพล์ async function ที่มี reference ใด ๆ ให้เป็นออบเจ็กต์ self-referential ที่ปลอดภัยได้
    • reference เป็นส่วนสำคัญของวิธีพื้นฐานที่ผู้ใช้ Rust เขียนโค้ด ดังนั้นหากไม่มีสิ่งนี้ ความน่าใช้งานของ async/await จะลดลงอย่างมาก
  • พร้อมกันนั้น Pin ถูกเพิ่มเข้ามาในรูปแบบที่เข้ากันย้อนหลังอย่างสมบูรณ์กับ Rust เดิม
  • Pin กลายเป็นองค์ประกอบพื้นฐานของระบบนิเวศที่รองรับบริการเครือข่ายประสิทธิภาพสูงและกรณีใช้งานอื่น ๆ ของการเขียนโปรแกรมแบบ asynchronous
  • อย่างไรก็ตาม การจัดการ pinned reference ยากกว่าการจัดการ ordinary reference มาก และ Pin ก็สร้างหน้าผาความซับซ้อนขึ้นจริง
  • แนวคิดหลักของทิศทางปรับปรุงถัดไปคือ pinned places

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

 
GN⁺ 2024-07-22
ความคิดเห็นจาก Hacker News
  • ผมรู้สึกมาตลอดว่า Pin เข้าใจยาก เพราะเอกสารทางการอธิบายไว้ไม่ชัดเจน
    โดยเฉพาะคำอธิบายแบบ “Pin รับประกันว่าอ็อบเจ็กต์จะไม่ถูกย้ายอย่างเด็ดขาด” ที่พบได้บ่อยนั้นจริง ๆ แล้วไม่ถูกต้อง
    จะจริงก็ต่อเมื่ออ็อบเจ็กต์นั้นไม่ใช่ Unpin เท่านั้น และอ็อบเจ็กต์ทั่วไปส่วนใหญ่เป็น Unpin ดังนั้นโดยมากแล้ว Pin จึงไม่ทำอะไรเลย
    ผมใช้เวลานานมากกว่าจะเข้าใจเรื่องนี้ และมองว่าชุดของชนิด T ที่ Pin มีความหมายจริง ๆ นั้นค่อนข้างเฉพาะทางและแปลก แต่เอกสารกลับไม่ได้เน้นจุดนี้มากพอ

    • เป็นข้อเสนอแนะที่ดี และคงจะดีถ้าเอกสารทำให้ส่วนนี้ชัดเจนขึ้น
      แน่นอนว่า future และ stream ซึ่งเป็นชนิดที่มักถูกจัดการในสภาวะ pinned จริง ๆ นั้น มีแนวโน้มสูงกว่ามากที่จะเป็นอ็อบเจ็กต์พิเศษแบบนั้น
      ถึงอย่างนั้นผมก็คิดว่าเอกสารดีขึ้นมากในช่วงไม่กี่ปีที่ผ่านมา
      ตอนที่ผมตรวจดูเพื่อเขียนโพสต์นี้ก็แปลกใจเหมือนกันว่ามันโฟกัสในจุดที่ค่อนข้างเหมาะสมแล้ว และจำได้ว่าราวปี 2019 มันเอนเอียงไปทางการระบุสัญญาเชิงสเปกที่น่าจะอยู่ในเอกสารอ้างอิงของ Rust มากกว่าเอกสาร API ของ std
  • ผมคิดว่าเหตุผลที่ผู้ใช้มองว่า Pin ยาก ก็เพราะว่า Pin เองไม่มีความหมายในตัวมันเอง
    เรื่องนี้ต่างจาก wrapper อื่น ๆ ในภาษา โดยข้อยกเว้นอาจมีแค่ AssertUnwindSafe ที่แทบไม่มีใครใช้ตามวัตถุประสงค์ดั้งเดิมของมัน
    เมื่อคุณมี Pin<&mut InnerType> ก็ไม่มีอะไรในตัวภาษา หรือใน Pin ของไลบรารีมาตรฐาน ที่บอกได้ว่าคุณทำอะไรได้หรือทำอะไรไม่ได้กับมัน
    เพียงแต่ถ้า InnerType ประกาศว่าเป็น Unpin นั่นก็หมายความว่าคุณทำทุกอย่างที่ทำได้กับพอยน์เตอร์ธรรมดาได้
    แต่ Pin ทำงานในลักษณะของการ “ให้นำความหมายมาเอง” โดยผู้ให้บริการ InnerType จะสร้างเมธอดและ API เพิ่มเติมที่ภายในเป็น unsafe เพื่อให้จัดการอ็อบเจ็กต์ที่ถูกตรึงไว้อย่างปลอดภัย
    เป้าหมายของ Pin เองคือการให้ พอยน์เตอร์ที่มีความสามารถโดยเนื้อแท้น้อยกว่า เช่นห้ามแทนที่ผ่าน &mut หรือดึงออกจาก Box แล้วย้าย เพื่อให้ชนิดภายในสามารถอนุญาตความสามารถเพิ่มเติมบนมันได้อย่างปลอดภัย
    ผมคิดว่าความกำกวมของความหมายนี้เองที่ทำให้คนสับสนที่สุด และผมเองก็ใช้เวลาพอสมควรกว่าจะเข้าใจ
    แนวคิดเรื่องฟิลด์เชิงโครงสร้างและฟิลด์ที่ไม่ใช่เชิงโครงสร้าง ก็เป็นเพียงกลไกเพื่อให้รองรับรูปแบบการใช้งานที่พบบ่อยอย่าง “ฟิลด์นี้เป็นข้อมูลธรรมดา แต่ฟิลด์นั้นเก็บอ็อบเจ็กต์ที่อยากถูกตรึงอยู่กับที่ด้วยตัวมันเอง”

    • Pin มีความหมายอยู่สิ ตราบใดที่ชนิดเป้าหมายไม่ได้ implement Unpin มันก็หมายความว่า สิ่งที่พอยน์เตอร์นี้ชี้อยู่จะไม่สามารถถูกย้ายได้อีกต่อไป
      ให้แม่นยำกว่านั้นคือ ไม่สามารถทำให้เป้าหมายเป็นโมฆะได้โดยไม่รัน destructor และนั่นเองคือเหตุผลที่การย้ายจึงเป็นปัญหา
      เมื่อสละสิทธิ์บางอย่างไป คุณก็จะได้สิทธิ์อย่างอื่นกลับมา เช่นสิทธิ์ในการเก็บค่าที่อ้างอิงตัวเองได้
      สัญญาระหว่างคอมโพเนนต์ส่วนใหญ่ก็ทำงานในลักษณะนี้
      เช่นเดียวกัน เมื่อคุณสละสิทธิ์ในการแก้ไขผ่านการอ้างอิง คุณก็สามารถทำให้การอ้างอิงนั้นมี alias พร้อมกันได้
      ทุกครั้งที่คิดถึงประเด็นนี้ ผมจะนึกถึงประโยคหนึ่งจากหนัง Lincoln แม้ว่าจะเป็นหัวข้อที่ต่างกันมากและหนักกว่ามากก็ตาม: “ถ้าเรายอมอยู่ใต้กฎหมาย Alex ยอมถึงขั้นสูญเสียเสรีภาพ — ตัวอย่างเช่น เสรีภาพในการกดขี่ — เราอาจค้นพบเสรีภาพอื่นที่ก่อนหน้านี้เราไม่เคยรู้จักมาก่อน”
      แต่ผมก็เห็นด้วยว่ามันเป็นปัญหาในเชิงการสอน เพราะในโค้ดที่ปลอดภัย คุณใช้สิทธิ์เหล่านั้นโดยตรงไม่ได้
      เนื่องจากมันยากที่จะยกตัวอย่างง่าย ๆ ว่าคุณทำอะไรกับการอ้างอิงแบบ pinned ได้บ้าง นอกจากพูดว่า “เรียกเมธอด poll ที่คอมไพเลอร์สร้างให้”
  • ผมพัฒนา Rust แบบมืออาชีพมาหลายปีแล้ว แต่พูดตรง ๆ คือ ผมก็ยังไม่ได้เข้าใจ Pin ดีขนาดนั้น
    ผมรู้ทฤษฎีนะ แต่ไม่ค่อยมีสัญชาตญาณว่าเมื่อไรควรใช้มัน
    การใช้ Pin ในทางปฏิบัติแทบจะเป็นแนว “ลองทำอะไรบางอย่างแล้วคอมไพเลอร์บ่น จากนั้นก็ลองตรึงนั่นตรึงนี่จนมันคอมไพล์ผ่าน” มากกว่า
    มันยังไม่เคยเป็นอุปสรรคในงานเขียนโค้ดประจำวันถึงขั้นที่ผมต้องนั่งลงทำความเข้าใจอย่างลึกซึ้งจริง ๆ

    • ผมก็เหมือนกัน นี่เป็นหนึ่งในกรณีที่พบบ่อยที่สุดของแนวคิดแบบ “ก็แค่หลีกเลี่ยง unsafe แล้วขอบคุณคนทำคอมไพเลอร์เก่ง ๆ ที่จัดการทุกอย่างไว้ให้แล้วกัน”
      ตรงกันข้ามกับ C++ ที่ผมมักต้องเดินเลาะริมน้ำของสิ่งที่ “ไม่รู้แต่จำเป็นต้องใช้” อยู่บ่อย ๆ แล้วก็โดนจระเข้ลากลงน้ำ
  • เวลาสอน ถ้าอยากให้ชัดเจนว่า trait Unpin ไม่ได้รับผลกระทบจาก Pin ก็ควรใช้อุปมาในโลกจริงของเครื่องมือที่ถูกสร้างมาเพื่อยึดของให้อยู่กับที่ แต่กลับไม่มีผลกับบางสิ่ง
    ตะขอเวลโครไม่ติดกับพื้นผิวเรียบ: Pin → เวลโคร, Unpin → พื้นผิวเรียบ
    แม่เหล็กไม่มีผลกับวัสดุที่ไม่เป็นแม่เหล็ก: Pin → แม่เหล็ก, Unpin → วัสดุไม่เป็นแม่เหล็ก/แก้ว/ทองเหลือง
    กาวไม่ติดพื้นผิวแบบ non-stick: Pin → กาว, Unpin → non-stick
    แบบนี้จะทำให้เห็นชัดว่า “เวลโคร” ยึดของไว้กับที่ได้ แต่ถ้าของชิ้นนั้น “เรียบ” มันก็จะไม่ได้รับผลจากกลไกของเวลโคร
    ถ้าคิดถึงบรรยากาศการตั้งชื่อใน ecosystem ของ Rust ก็คงจะสวยดีถ้าเปลี่ยนชื่อ trait ไปในแนวแม่เหล็กกับไม่เป็นแม่เหล็ก

    • แต่ของที่ผิวเรียบก็เอาเวลโครไปติดไม่ได้ และไม้ก็ไม่สามารถยึดแม่เหล็กไว้ได้
      Unpin น่าจะหมายถึงอ็อบเจ็กต์พร้อมจะถูกตรึงได้ทุกเมื่อหรือเปล่า
      ผมอ่านบทความเมื่อคืนนี้ แต่ก็ลืมไปแล้วว่าการตรึงต้องมีขั้นตอนปรับแก้อะไรหรือไม่
      ดังนั้นผมเลยมองว่า T: Pin + !Unpin ก็คล้ายกระดาษที่ตรึงได้ด้วยลวดเย็บเท่านั้น ส่วน T: Pin + Unpin จะคล้ายรูปภาพที่มีห่วงสำหรับแขวนกับตะปู แล้วสามารถยกลงมาได้อีกโดยไม่ทำให้ห่วงเสียหาย
  • คำว่า “อัตลักษณ์ของค่า” ไม่ได้ถูกนิยามไว้ที่ไหนเลยในบทความนี้ และผมหาไม่เจอในเอกสารของ Mojo ด้วย จึงยังไม่ชัดเจนว่าอะไรคือเหตุผลที่ทำให้ Modular บอกว่า Mojo แก้ปัญหาที่ Pin พยายามแก้ได้
    ผมเองก็ไม่ได้อ้างว่ารู้คำตอบ แต่ทำให้นึกถึงงานบรรยายที่ยอดเยี่ยมของ Dave Abrahams ผู้ซึ่งเคยทำงานเรื่อง value semantics ของ Swift ร่วมกับ Chris Lattner
    ชื่องานบรรยายคือ “Value Semantics: Safety, Independence, Projection, & Future of Programming”
    [0] https://www.youtube.com/watch?v=QthAU-t3PQ4

    • ชัดเจนว่าในความหมายหนึ่ง Mojo รับช่วงแนวคิดเรื่อง value semantics ของ Swift มา แต่ Rust เองก็มี value semantics ในความหมายเดียวกัน
      ขณะที่ Rust มี reference เป็น type ชั้นหนึ่งด้วย ส่วน Swift และในมุมมองของผม Mojo อนุญาต reference ได้แค่ในฐานะวิธีส่งพารามิเตอร์เท่านั้น
      ดูเหมือนว่า Mojo จะขยายพารามิเตอร์ inout ของ Swift ให้มีรูปแบบการส่งแบบ immutable reference ด้วย
      ถ้าไม่ให้เก็บ reference ไว้ภายในอ็อบเจ็กต์ ก็ย่อมแก้ปัญหา “self-referential struct” ได้ เพราะจะไม่สามารถเขียนโค้ดแบบที่ Rust คอมไพล์ได้
      แต่ย่อหน้าที่ถูกอ้างถึงพูดถึง Mojo ไม่ได้เป็นเรื่องนั้นเลย จึงค่อนข้างสับสนว่าหมายถึงอะไรกันแน่
  • สำหรับผม ปัญหาคือถ้ามี reference แบบ &mut ไปยังค่าหนึ่ง เราก็สามารถย้ายค่านั้นได้ด้วย mem::swap/replace หรืออย่างอื่นที่คล้ายกัน
    แต่ในทางปฏิบัติ สถานการณ์ที่ต้องทำแบบนั้นมีไม่บ่อย
    ถ้าไม่อนุญาตสิ่งนี้ การมี reference แบบ &mut ไปยัง ค่าที่อ้างถึงตัวเอง ก็น่าจะปลอดภัยอย่างสมบูรณ์
    อาจมีวิธีให้เลือกทำการย้ายผ่าน reference อย่างชัดเจนเฉพาะเวลาจำเป็น หรือถ้าทำให้ swap และ replace เป็น unsafe ไปเลย ก็คงอาจหลีกเลี่ยงปัญหานี้ทั้งก้อนได้
    อยากให้มีใครมาสำรวจพื้นที่การออกแบบนี้ดู

    • ใช่ ตอนที่ Aaron Turon กำลังทำเรื่องนี้ เขาเคยบอกว่า &mut ทรงพลังเกินไป
      ถ้า &mut ไม่ได้ให้สิทธิ์ในการย้ายค่าที่อยู่ข้างใน การออกแบบทั้งหมดก็คงเรียบง่ายกว่านี้มาก
      จะพูดถึงเรื่องนี้ในบทความถัดไป
      Rust ต้องรักษาความเข้ากันได้ย้อนหลัง และได้ตัดสินใจไปแล้วว่าสามารถย้ายค่าออกจาก &mut ได้ แต่ถ้าไม่ต้องถูกผูกกับการตัดสินใจในอดีต ก็ชัดเจนว่าน่าจะมีการออกแบบที่สะอาดกว่านี้ได้มาก
    • อันนี้จริง แต่ขยายต่อไม่ได้ เพราะมันจะทำให้โค้ดเดิมพังมากเกินไป จนตั้งแต่แรกก็คงเกิดขึ้นได้ยาก
      mem::swap เป็นเพียงหนึ่งในหลายวิธีในการย้ายค่าผ่าน mutable reference และยังมีอีกหลายวิธีมาก
      Option::take เป็นตัวอย่างที่ผมใช้ค่อนข้างบ่อย และถ้าสิ่งนี้เป็น unsafe ก็คงแปลกมากจริง ๆ
  • ชอบเรื่องเล่าเบื้องหลังนี้มาก WithoutBoats พูดคุยอย่างต่อเนื่องอยู่แล้วในประเด็นที่ทันสถานการณ์มากอย่าง async iterator, poll, pin
    https://news.ycombinator.com/from?site=without.boats
    ดูเหมือนจะมีไม่กี่ชุมชนที่ขุดลึกเรื่องภายในยิบย่อยของภาษาอย่างเปิดเผยในระดับนี้ และอ่านแล้วเพลินมาก

    • มันเจ๋งก็จริง แต่ก็หมายความด้วยว่าการพัฒนาภาษาช้ามาก
      async ยังเหมือนสุกไม่เต็มที่อยู่ครึ่งหนึ่งและซับซ้อนมาก
      พูดในฐานะคนที่เขียนโค้ด Rust สัปดาห์ละ 40 ชั่วโมงมาตลอด 3 ปีที่ผ่านมา
  • เราพอจะจินตนาการถึงภาษาที่คล้าย Rust ซึ่งมี move constructor และทุกชนิดย่อยของ Future ที่ถูกสร้างขึ้นเป็นแบบทึบและถูกจัดสรรบนฮีปโดยอัตโนมัติได้
    ถ้าเป็นแบบนั้น ผู้ใช้จะไม่มีทางทำลายมันได้ และเพราะมันเป็นแบบทึบแถมอยู่คนละตำแหน่งบนฮีป จึงไม่มีทางย้ายมันได้ ทำให้ อาจไม่จำเป็นต้องมี Pin
    เพราะการมี move constructor หมายความว่าในเชิงแนวคิด การย้ายคือการทำลายแล้วสร้างใหม่

    • Pin เป็น สถานะ มากกว่าจะเป็นคุณสมบัติของข้อมูลเอง
      ตรงนี้มีผลดีคือสามารถรวมและ inline Future ได้ก่อนรันจริง
      มันคล้ายกับ immutability ของ Rust ด้วย ไม่ใช่ว่ามีหน่วยความจำแบบ immutable แต่มีแค่ immutable reference เท่านั้น
    • ถ้า future ทุกตัวถูกจัดสรรบนฮีป ก็ไม่จำเป็นต้องมี move constructor
      แต่แบบนั้นจะเกิดการจัดสรรแยกทุกครั้งที่มีการเรียก async function และสิ่งนี้แย่มากต่อ memory locality
      virtual stack บางรูปแบบน่าจะดีกว่านั้นมาก แต่ถ้าจะ optimize ให้สแตกเล็กเป็นค่าเริ่มต้น สุดท้ายก็ต้องมี garbage collection อยู่ดี
    • พอเดาได้ว่ามันจะเป็นการเปลี่ยนแปลงที่หนักแค่ไหน แต่ก็คงดีมากถ้า Rust เลือกรับมือแบบตรง ๆ และเพิ่ม Move trait ที่ฝังอยู่ในภาษาในระดับคล้าย Copy เข้าไปใน std
      Move จะเป็นการนิยามฟังก์ชันที่ย้ายค่าจากตำแหน่งหน่วยความจำหนึ่งไปยังอีกตำแหน่งหนึ่ง และ struct ที่ไม่มี impl Move ก็จะย้ายไม่ได้
      type เกือบทั้งหมดน่าจะแค่ใส่ #[derive(Move)] แล้วใช้การย้ายแบบง่ายที่คัดลอกไบต์ก็พอ
      แต่ถ้าทำแบบนี้ ก็จะเปิดทางให้กับ self-referential type, future และอีกหลายอย่างที่ต้องการพฤติกรรมการย้ายที่ซับซ้อนกว่า
      จริง ๆ แล้วอาจสมเหตุสมผลกว่าที่จะแยกเป็นสอง trait เพื่อสะท้อนความต่างระหว่าง Copy กับ Clone
      อันหนึ่งเป็น marker trait ที่บอกคอมไพเลอร์ว่าแค่ย้ายไบต์เฉย ๆ ก็ได้ ส่วนอีกอันเปิดให้มีการ implement “move constructor” แบบกำหนดเอง
      Pin เข้าใจยากเกินไป เลยอยากให้มี Move
      มันเป็นแนวคิดที่ซับซ้อนและถูกห่อด้วยการปฏิเสธซ้อนสองชั้น บางทีก็สามชั้น พอเห็นอะไรอย่าง fn(...) ก็รู้สึกว่า “นี่มันอะไร?” และพอไปถึง pin projection แบบ unsafe ก็หลุดตามไม่ทันแล้ว
      ไม่รู้ว่าเมื่อไหร่ปลอดภัยและเมื่อไหร่ไม่ปลอดภัย สุดท้ายก็แค่วางมือ
      การย้ายจาก Rust ที่ไม่มี Move ไปเป็น Rust ที่มี Move คงลำบาก
      struct แทบทุกตัวที่เขียนมาจนถึงตอนนี้ต้องเพิ่ม #[derive(Move)] และ std ก็เช่นกัน
      สำหรับ type ที่ไม่ได้ถูก pin ใน edition เดิม คอมไพเลอร์จะต้องอนุมานการ implement ของ Move trait ให้
      มันน่าจะทำได้เชิงกลไก แค่เป็นงานเยอะ
      async Rust นั้นแย่มาก โดยเฉพาะเมื่อเทียบกับ future/promise ของภาษาอื่นแทบทั้งหมด
      สักวันหนึ่งคงมีคนปรับปรุงโมเดล memory safety ของ Rust และสร้างภาษาระบบใหม่ที่คล้าย Rust แต่มี Move trait และ future ที่ดีกว่า
      ส่วนตัวแล้วก็อยากให้มี การรันตอนคอมไพล์ แทน macro system ของ Rust ด้วย
      ฉันชอบ Rust และชอบงานทั้งหมดที่ทีมทำมาตลอดหลายปี
      แต่ภาษาที่ฉันตั้งตารอจริง ๆ คือภาษาที่จะมาต่อจาก Rust
      มันจะใช้แนวคิดเดียวกัน แต่เรียนรู้จากความผิดพลาดของ Rust และภาพของภาษาแนว Rust ที่ดีกว่านั้นก็เริ่มชัดขึ้นเรื่อย ๆ
      รอไม่ไหวจริง ๆ
  • นี่เป็นอีกบทความยอดเยี่ยมจาก WithoutBoats
    พูดตรง ๆ นี่เป็นหนึ่งในเรื่องที่ผมรู้สึกโล่งใจที่ใน Rust มันถูกซ่อนและฝังอยู่ภายใน abstraction ของ async runtime
    ถึงอย่างนั้นก็ยังสงสัยว่า นอกจากการ implement Future แบบ custom แล้ว Pin ถูกใช้จริงที่ไหนบ้าง

    • ผมก็สงสัยเหมือนกัน
      ดูจากคำอธิบายแล้ว เหมือนมันน่าจะใช้ใน FFI ได้
      ตัวอย่างเช่น ถ้า extern function คืนค่า *mut T เพื่อส่ง pointer กลับมา ก็ดูเหมือนว่าอาจห่อมันด้วย Pin<&mut T> เพื่อให้ความหมายเชิง semantics ที่ดีกว่าได้
      แต่ในบทความก็บอกว่า “ข้อเท็จจริงอีกอย่างเกี่ยวกับสถานะ pinned ของ type คือสำหรับ type ส่วนใหญ่แล้วมันไม่เกี่ยวข้องเลย ถ้าค่าของ type นั้นไม่มีทางมี self-reference ได้ การ pin มันก็ไม่มีประโยชน์”
      ผมยังใหม่กับ FFI มาก เลยอยากเข้าใจว่าวิธีที่ดีที่สุดในการห่อมันให้เป็น safe Rust คืออะไร
    • ใน FFI บางครั้ง C API เปิดเผยรายการออกมาเป็น pointer ไม่ใช่ reference และห้ามย้าย
      เช่นเดียวกับตอนโต้ตอบกับ system type ที่พึ่งพาที่อยู่หน่วยความจำ
      ยกตัวอย่างเช่น mutex/futex ของบางระบบปฏิบัติการที่ในเอกสารของเคอร์เนลระบุว่า lock object ใน userspace ห้ามเปลี่ยนที่อยู่หลังจาก initialize แล้ว ดังนั้นเท่าที่รู้ std จึงใช้สิ่งที่เทียบได้กับ Pin
      จุดที่แปลกคือแม้ตอนที่ไม่ได้ล็อกอยู่ ที่อยู่ก็ยังห้ามเปลี่ยน
      ปกติแล้วเงื่อนไขนี้มักมีเฉพาะตอนที่ถูกล็อก ซึ่งในกรณีนั้นจะย้ายสิ่งที่มี reference อยู่ไม่ได้อยู่แล้ว จึงไม่จำเป็นต้องมี Pin
  • ดูเหมือนกำลังทุ่มงานมหาศาลเพื่อหลีกเลี่ยงการแก้ปัญหาที่แท้จริงคือ ความไม่มีประสิทธิภาพของเธรด
    โค้ดแบบอะซิงโครนัสทั้งหมดโดยไม่มีข้อยกเว้น แท้จริงแล้วคือการแฮ็กเพื่อทำเธรดน้ำหนักเบา พร้อม syntax sugar จำนวนมากสำหรับจัดการสถานะ
    ในภาษาอย่าง Rust มันเพิ่มความซับซ้อนจำนวนมากอย่างไม่น่าจำเป็น
    ถ้าแก้ปัญหาเรื่องประสิทธิภาพและการขยายขนาดของเธรดได้ ทั้งหมดนี้ก็จะหายไป
    หายวับไปเลย
    คล้ายกับกรณีที่ null ในภาษาอย่าง Java เป็น “ความผิดพลาดที่มีต้นทุนระดับล้านล้าน”
    การตัดสินใจออกแบบเพียงครั้งเดียว หรือในกรณีนี้คือการไม่มีการออกแบบ ก่อให้เกิดความซับซ้อนมหาศาล

    • เธรดไม่รองรับ การยกเลิก ในแบบที่สมเหตุสมผล
      การยกเลิกมีประโยชน์มากในแอปพลิเคชันเครือข่ายและ GUI
      เธรดทำให้ยากที่จะใช้ทั้ง CPU และเครือข่ายได้เต็มที่ โดยไม่ให้ด้านใดด้านหนึ่งถูกใช้งานมากเกินไป
      ถ้าเริ่มส่งต่องานข้าม thread pool ก็เท่ากับเริ่มเดินกลับไปสู่การสร้าง future ขึ้นมาใหม่
      ไม่เช่นนั้นก็ต้องไปทำงานด้วย callback/event ซึ่งทำให้โค้ดแตกเป็นชิ้น ๆ และ async/await ก็คือ syntax sugar ที่ตั้งใจมาแก้สิ่งนี้โดยตรง
      ทางเลือกแทนการยกเลิกและ timeout คือการร้อยวัตถุ Context แบบใน Go เข้าไปทั่วทั้งโค้ด แต่ก็จะเกิดปัญหาที่โค้ดปลายทางไปเรียกฟังก์ชันที่ไม่ปฏิบัติตาม Context อย่างไร้เดียงสา
      สิ่งนี้ดีกว่าปัญหาของฟังก์ชันไม่อะซิงก์ในโค้ดอะซิงก์เพียงเล็กน้อยเท่านั้น
    • น่าสงสัยว่าการเขียน Linux kernel ใหม่เพื่อ “แก้ปัญหาเรื่องประสิทธิภาพและการขยายขนาดของเธรด” จะเป็นไปได้จริงหรือไม่ และถึงจะทำได้ กลุ่มผู้เชี่ยวชาญ Rust ที่ทำให้ Pin ใช้งานได้ก็คงไม่ใช่กลุ่มเดียวกับผู้เชี่ยวชาญเคอร์เนลที่ทำเรื่องนั้นได้
      เลยสงสัยว่าจริง ๆ แล้วควรทำอะไร
      หรือควรจะยกมือแล้วบอกว่า “สักวันอาจมีใครสักคนแก้ Linux แล้วทำให้เธรดเร็วขึ้นแบบมหัศจรรย์ได้ ดังนั้นเราจะไม่เพิ่ม async ให้ภาษา” อย่างนั้นหรือ?
    • การแยกและระบุให้ชัดว่าฟังก์ชันใดซิงโครไนซ์กับโปรเซสที่ทำงานพร้อมกัน และฟังก์ชันใดไม่ทำ ถือเป็นเรื่องที่ดีจริง ๆ
    • น่าเสียดายที่การข้ามขอบเขต user space นั้นมีต้นทุน ไม่ว่า “เธรด” จะเบาแค่ไหนก็ตาม
      อีกทั้งถ้าทำให้ระบบปฏิบัติการเป็นตัวจัดตารางงานของงานอะซิงก์ทั้งหมด ก็จะบังคับให้ทุก runtime ต้องใช้ตัวจัดตารางงานของระบบปฏิบัติการ จนไม่สามารถมี การออกแบบตัวจัดตารางงาน ที่หลากหลายได้
    • การตัดสินใจที่เป็นโทษหลายอย่างของ Rust แสดงให้เห็นว่าวัฒนธรรมที่ผลักดันความผิดพลาดเดิมต่อไปไม่ว่าจะต้องแลกด้วยอะไรก็ตามฝังรากลึกอยู่
      ดูเหมือนว่าจะไม่กลับมาประเมินต้นทุน/ประโยชน์ใหม่อีก แม้จะชัดแล้วว่า “แนวทางที่ต้องการ” ใช้งานจริงไม่ได้
      แนวคิดแบบ “ฉันต้องการฟีเจอร์ X ส่วนผลลัพธ์จะเป็นอย่างไรไม่สน” แทบไม่เคยชนะในการออกแบบภาษาเลย