4 คะแนน โดย GN⁺ 2024-03-31 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • แม้ในโค้ดอย่างเคอร์เนลและไดรเวอร์ที่ ประสิทธิภาพและการทำงานพร้อมกัน สำคัญมาก การคืนหน่วยความจำก็ไม่ได้จบแค่การเรียก free() แบบง่าย ๆ และอาจต้องใช้เทคนิคการแชร์ข้อมูลโดยไม่ใช้ล็อก
  • RCU (Read, Copy, Update) คือวิธีที่คัดลอกข้อมูลซึ่งถูกอ่านบ่อยและเปลี่ยนไม่บ่อย จากนั้นสลับพอยน์เตอร์แบบอะตอมมิก เพื่อไม่ให้เส้นทางการอ่านถูกบล็อก
  • หาก delete เวอร์ชันเก่าทันที อาจเกิด use-after-free ในเธรดที่ยังอ่านอยู่ได้ ดังนั้น RCU จึงติดตามช่วงการอ่านและเลื่อนการคืนหน่วยความจำออกไปจนกว่าจะปลอดภัย
  • RCU ถูกใช้จริงหลายหมื่นครั้งใน Linux และมีอยู่ทั้งในไลบรารี C++ ของ Folly และ crossbeam-epoch ของ Rust โดยมีลักษณะคล้าย GC ตรงที่ จะค่อยไปเก็บกวาดทีหลังตามการใช้งาน
  • แนวคิดแบบแบ่งขั้วว่าการจัดการหน่วยความจำด้วยมือนั้นเร็วและคาดเดาได้กว่าเสมอเป็นเรื่องที่อ่อนแรง เพราะ free(), การนับจำนวนอ้างอิง, และพฤติกรรมหน่วยความจำของ OS ต่างก็มีต้นทุนและความไม่แน่นอน ทำให้ GC สมัยใหม่ ก็เป็นเครื่องมือของงาน system programming ได้เช่นกัน

เหตุผลที่โค้ดเคอร์เนลใช้เทคนิคอย่าง RCU

  • ระบบปฏิบัติการเป็นซอฟต์แวร์ที่จัดอยู่ในกลุ่มที่ ไวต่อประสิทธิภาพ มากที่สุดในบรรดาโปรแกรมที่รันทุกวัน
  • เมื่อ OS เร็วขึ้น ผู้ใช้ก็ทำงานคำนวณได้มากขึ้น ดังนั้นนักพัฒนาเคอร์เนลและไดรเวอร์จึงทุ่มเทอย่างมากกับการปรับแต่งโค้ด
  • ระบบปฏิบัติการต้องรับมือไม่ใช่แค่โปรเซสและเธรดใน user space แต่รวมถึงหลายเธรดของตัวเคอร์เนลเองและตัวจัดการ hardware interrupt ด้วย
  • เมื่อ latency เพิ่มขึ้นก็เท่ากับแย่งเวลาของผู้ใช้ไป จึงเกิดเทคนิคหลายแบบในโค้ดเคอร์เนลสำหรับแชร์ข้อมูลระหว่างเธรดโดยไม่ใช้ล็อก

การทำงานพื้นฐานของ RCU

  • RCU (Read, Copy, Update) เป็นวิธีที่เหมาะกับข้อมูลที่ถูกอ่านบ่อยมากแต่เขียนไม่บ่อย
    • ตัวอย่างเช่นชุดอุปกรณ์ USB ที่เชื่อมต่ออยู่ ซึ่งแทบไม่เปลี่ยนแต่ก็เปลี่ยนได้
    • การเปลี่ยนแปลงต้องเกิดขึ้นแบบอะตอมมิก และต้องไม่ไปขัดขวางผู้อ่านที่กำลังอ่านอยู่แล้ว
  • ฝั่งผู้เขียนจะอัปเดตสถานะที่แชร์ตามลำดับดังนี้
    • อ่านข้อมูลเดิมจากพอยน์เตอร์
    • คัดลอกข้อมูลเดิมและใส่การเปลี่ยนแปลงที่ต้องการเพื่อสร้างเวอร์ชันใหม่
    • อัปเดตพอยน์เตอร์แบบอะตอมมิกให้ชี้ไปยังเวอร์ชันใหม่
  • ฝั่งผู้อ่านเพียงแค่อ่าน shared pointer ดังนั้นเส้นทางการอ่านจึงเรียบง่ายและทำงานได้โดยไม่ต้องรอ
  • วิธีนี้ใช้งานง่ายและเป็น wait-free แต่หากไม่เก็บกวาดเวอร์ชันเก่า ก็จะเกิด memory leak

เวอร์ชันเก่าที่ปล่อยทิ้งทันทีไม่ได้

  • หากสลับพอยน์เตอร์ไปยังเวอร์ชันใหม่แล้ว delete เวอร์ชันเก่าทันที ก็จะเสี่ยงเกิด use-after-free
  • เพราะทำงานโดยไม่มีล็อก ฝั่งผู้เขียนจึงไม่อาจรู้ได้ว่ายังมีผู้อ่านคนใดกำลังอ่านเวอร์ชันเก่าอยู่หรือไม่
  • ผู้อ่านสามารถระบุ critical section ฝั่งอ่านได้ด้วย rcu_read_lock() และ rcu_read_unlock()
    • ผู้อ่านยังคงไม่ถูกบล็อก
    • ฝั่งผู้เขียนจะไม่ลบข้อมูลเก่าจนกว่าผู้อ่านเหล่านั้นจะออกจากช่วงดังกล่าว
  • rcu_synchronize() ไม่จำเป็นต้องรอจนผู้อ่านทุกคนหายไป แต่รอเพียงให้ ผู้อ่านก่อนหน้า ที่ยังมีโอกาสเห็นเวอร์ชันเก่าทำงานเสร็จเท่านั้น
    • ผู้อ่านที่เห็นพอยน์เตอร์ใหม่แล้วจะใช้เวอร์ชันใหม่ จึงไม่เกี่ยวกับอายุของเวอร์ชันเก่า

การคืนหน่วยความจำแบบหน่วงเวลาและรูปแบบของ GC

  • แม้ฝั่งผู้เขียนจะไม่รออยู่ภายในฟังก์ชันอัปเดต หากข้อมูลเก่าถูกคืนหน่วยความจำอย่างปลอดภัยในภายหลังก็ยังถือว่าโค้ดทำงานถูกต้อง
  • แนวทางอย่าง rcu_defer(old) ทำให้สามารถคืนหน่วยความจำ old ได้เมื่อใดก็ได้หลังจากที่ผู้อ่านปัจจุบันออกจาก critical section แล้ว
  • รูปแบบที่มีเธรดเฉพาะคอยเก็บกวาดเวอร์ชันเก่าที่ไม่มีการอ้างอิงเป็นระยะ ๆ นั้นคล้ายกับ generational GC
  • RCU ไม่ใช่แค่การทดลองทางความคิด แต่เป็นเทคนิคที่ถูกใช้แพร่หลายในโลกจริง
    • Linux ใช้ RCU หลายหมื่นครั้ง
    • Facebook มี RCU ให้ใช้ใน Folly C++ library
    • ใน Rust มีการใช้งานในชื่อ crossbeam-epoch และเป็นพื้นฐานของไลบรารีด้าน concurrency ที่ได้รับความนิยม
  • ประเด็นที่สำคัญยิ่งกว่าการถกเถียงจัดหมวดว่า RCU เป็น “GC จริง” หรือไม่ คือโครงสร้างที่หน่วยความจำจะถูกเก็บกวาดภายหลังตามว่า ยังถูกใช้งานอยู่หรือไม่ ซึ่งเหมือนกับ GC

ต้นทุนแฝงของการคืนหน่วยความจำด้วยมือ

  • ความเชื่อว่า GC มีประสิทธิภาพด้อยกว่าการจัดการหน่วยความจำด้วยมือโดยเนื้อแท้นั้น เมื่อดูรายละเอียดการใช้งานจริงแล้วจะอ่อนแรงลงมาก
  • free() ไม่ได้ฟรี

    • ตัวจัดสรรหน่วยความจำแบบทั่วไปต้องดูแลสถานะโกลบอลภายใน เช่น page ที่ได้มาจากเคอร์เนล การแบ่ง bucket ตามขนาด และ bucket ที่กำลังใช้งานอยู่
    • หลายเธรดอาจแย่งกันล็อกสถานะของตัวจัดสรรจนเกิด contention ได้
    • แม้จะมี thread-local pool อย่าง jemalloc ก็ยังต้องมีโค้ดเพิ่มเติมเพื่อซิงก์สิ่งเหล่านี้
  • RAII และ lifetime ก็ไม่ได้ลบต้นทุนของ allocator ออกไป

    • lifetime ของ Rust หรือ RAII ของ C++ ช่วยเรื่องการทำให้การคืนหน่วยความจำเป็นอัตโนมัติและถูกต้อง แต่ไม่ได้ลบความซับซ้อนของโครงสร้างภายใน allocator
    • ในหลายสถานการณ์ก็ยังต้องกลับไปใช้ shared_ptr หรือ Arc
    • สิ่งนี้ต้องมี metadata เพิ่มเติมในรูปของ reference count และค่าดังกล่าวอาจวิ่งไปมาระหว่างคอร์และแคชจนก่อให้เกิดต้นทุน
    • อีกทั้งยังอาจทำให้วงจรในกราฟ liveness รั่วไหลได้
  • GC ก็มีการปรับแต่งที่ให้ประโยชน์ได้เช่นกัน

    • moving generational GC จะจัดเรียง heap ให้แน่นอีกครั้งเป็นระยะ
    • การจัดสรรอาจใกล้เคียงกับการเพิ่มค่าพอยน์เตอร์ จึงให้ throughput สูงได้
    • ความเป็นลำดับของการจัดสรรยังช่วยเรื่อง locality และทำให้ประสิทธิภาพของแคชดีขึ้นด้วย

ความเข้าใจผิดเรื่องการควบคุมการจัดการหน่วยความจำ

  • นักพัฒนาจำนวนมากที่ไม่ชอบ GC กำลังสร้างระบบแบบ soft real-time
    • เช่น ต้องการให้เกมมี FPS สูงหรือให้ codec สำหรับสตรีมมิงบีบอัดได้เร็วที่สุด
    • แต่ไม่ได้มีข้อกำหนดด้านเวลาแบบแข็งที่ถ้าช้าลง 1 มิลลิวินาทีเป็นครั้งคราวแล้วระบบจะพังหรือมีคนเสียชีวิต
  • ความเชื่อที่ว่าโปรแกรมเมอร์สามารถกำหนดได้ว่าเมื่อใดการจัดการหน่วยความจำจะเกิดขึ้นนั้นไม่ง่ายอย่างที่คิด
    • ระบบปฏิบัติการทำหน้าที่ abstraction การโต้ตอบกับฮาร์ดแวร์
    • โดยปกติ Linux แทบไม่ทำอะไรเลยตอนมีคำขอใช้หน่วยความจำ และอาจค่อยจัดสรรจริงเมื่อมีการใช้งาน
    • เมื่อมี madvise(), memory-mapped I/O และ file system cache เข้ามาปะปน ก็ไม่มีคำตอบง่าย ๆ ว่า “อะไรถูกจัดสรรเมื่อไร”
    • ในวันที่โชคร้าย การ dereference พอยน์เตอร์ธรรมดาอาจนำไปสู่ disk I/O ได้
  • ความเชื่อที่ว่าโปรแกรมเมอร์รู้เสมอว่าควรหยุดเพื่อจัดการหน่วยความจำเมื่อใดก็มีข้อจำกัดเช่นกัน
    • บางกรณีก็ชัดเจน เช่น หน้าจอโหลดของวิดีโอเกม
    • แต่ในซอฟต์แวร์จำนวนมาก คำตอบเดียวคือทำตอนที่ระบบไม่ยุ่งกับงานที่สำคัญกว่า
    • โค้ดแต่ละส่วนที่ใช้ shared_ptr และ Arc ไม่อาจรู้ล่วงหน้าได้ว่าตัวเองจะกลายเป็นเจ้าของคนสุดท้ายและต้องรับภาระการเก็บกวาดหรือไม่
  • ความเชื่อที่ว่าการเรียก free() เท่ากับคืนหน่วยความจำให้ OS ทันทีนั้นก็ไม่ถูกเสมอไป
    • หน่วยความจำถูกจัดสรรจาก OS เป็นหน่วย page
    • allocator มักยึด page ไว้เพื่อนำกลับมาใช้ใหม่จนกว่าโปรแกรมจะจบการทำงาน
    • OS เองก็อาจดึง page กลับคืนผ่าน swap ได้

เหตุผลที่ GC มองได้ว่าเป็นเครื่องมือของ system programming

  • ไม่ใช่ว่าซอฟต์แวร์ทุกชนิดจะได้ประโยชน์จาก GC
  • แต่แม้จะเข้าใกล้ปี 2024 แล้ว การถกเถียงเรื่อง GC ในหมู่โปรแกรมเมอร์ระบบก็มักถูกกลบด้วยการแบ่งขั้วผิด ๆ และความกลัว ความไม่แน่นอน และความสงสัย
  • แนวคิดที่ว่าภาษาแบบมี GC “ช้ากว่าอย่างชัดเจน” เมื่อเทียบกับภาษาที่จัดการหน่วยความจำด้วยมือนั้น ไม่ใช่ข้อเท็จจริง แต่ใกล้เคียงกับ อุดมการณ์ มากกว่า
  • แม้แต่ในทีมที่สร้างระบบซึ่งมีชีวิตคนเป็นเดิมพัน ก็มีกรณีที่ใช้ภาษาแบบมี GC ซึ่งมีการจัดสรรแทบทุกบรรทัด และยังให้ latency ระดับต่ำกว่าไมโครวินาทีได้
  • หากมีบางส่วนของระบบที่จำเป็นต้องรันภายใน n clock cycle จริง ๆ ก็สามารถแยกเฉพาะส่วนนั้นออกไปเป็นโค้ดที่ไม่ใช้ GC หรือย้ายไปไว้ในฮาร์ดแวร์ได้
  • GC ไม่ใช่คำตอบสารพัดนึก แต่เป็นหนึ่งใน เครื่องมือในกล่องเครื่องมือ ที่สามารถใช้งานได้โดยไม่ต้องหวาดกลัว

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

 
GN⁺ 2024-03-31
ความคิดเห็นบน Hacker News
  • เทคนิค การเก็บกวาดขยะ (garbage collection) แบบขนาน สมัยใหม่ที่มีแนวโน้มดี น่าจับตาดู MPL หรือ MaPLe และ Automatic Management of Parallelism รุ่นใหม่ของมัน
    แก่นสำคัญที่ได้รับรางวัล POPL 2024 distinguished paper award และ ACM SIGPLAN dissertation award 2023 มีสองอย่าง: a) การเก็บกวาดขยะแบบขนานที่พิสูจน์ได้ว่ามีประสิทธิภาพ โดยอิงกับ disentanglement, b) การควบคุมความละเอียดระดับงานโดยอัตโนมัติที่พิสูจน์ได้ว่ามีประสิทธิภาพ
    [1] MaPLe (MPL): https://github.com/MPLLang/mpl
    [2] Automatic Parallelism Management: https://dl.acm.org/doi/10.1145/3632880

    • Standard ML และชุมชนของมันมีผลงานที่ค่อนข้างน่าประทับใจต่อวรรณกรรมด้านการจัดการหน่วยความจำ
      นอกจากบทความวิจัยที่ลิงก์ไว้แล้ว ยังมี MLKit ซึ่งเป็นหนึ่งในผู้ใช้ยุคแรกและผู้บุกเบิกด้านการจัดการหน่วยความจำแบบ region-based ด้วย
    • สงสัยว่า “พิสูจน์ได้ว่ามีประสิทธิภาพ” ในที่นี้หมายถึงอะไร
    • สงสัยว่าแนวทางนี้เทียบกับงานสนับสนุน parallelism แบบมัลติคอร์ที่เพิ่งทำใน OCaml แล้วเป็นอย่างไร
    • อยากถามคนที่รู้ดีกว่าว่า สิ่งนี้นำไปใช้กับภาษาอื่นได้มากแค่ไหน?
      เช่น แนวทางแบบนี้จะทำให้ garbage collection ของ Go เร็วขึ้นอย่างมีนัยสำคัญได้ไหม หรือจะไปชนกับปัญหาจากการออกแบบภาษาที่มีอยู่เดิม?
  • กรณีการใช้งาน RCU นั้นน่าเชื่อถือ แต่ประสบการณ์กับ garbage collection ในสถานการณ์อื่น ๆ ไม่ค่อยดีนัก
    บทความนี้อ่านแล้วเหมือนเป็นการโต้แย้งว่าโซลูชันการจัดการหน่วยความจำแบบปรับแต่งเฉพาะงานสามารถให้ประสิทธิภาพสูงสุดได้ มากกว่าจะเป็นการอ้างว่า lifetime แบบ static โดยทั่วไปดีกว่า lifetime แบบ dynamic
    ผมไม่ได้เชื่อว่า free() คืนหน่วยความจำให้ระบบปฏิบัติการ แต่คิดว่ามันคืนให้ allocator ซึ่งดีกว่าการคืนให้ระบบปฏิบัติการมาก และ system call ก็ช้า เพียงแต่ว่า allocator อย่าง mimalloc ก็อาจทำให้หน่วยความจำที่ถูกปล่อยไม่ถูกนำไปใช้กับ malloc ครั้งถัดไปทันที แต่เปิดให้ใช้ได้เป็นรอบ ๆ เท่านั้น
    เมื่อจัดสรร 800 ไบต์แล้ว free ทันที ทำซ้ำ 1 ล้านครั้ง แล้วนับจำนวน pointer ที่ไม่ซ้ำกัน ผลคือ glibc malloc ได้ 1, jemalloc ได้ 1, mimalloc ได้ 4, ส่วน Julia garbage collector ได้ 62767
    62767 ตัว หรือประมาณ 48MiB ไม่ได้แย่มากนัก แต่ก็ยังไล่ข้อมูลออกจาก L3 cache ของเครื่องผมอยู่ดี ถ้าใช้ garbage collection ก็แทบรับประกันได้ว่า allocation ใหม่จะมาจาก RAM ไม่ใช่ cache และทำลายประสิทธิภาพของโค้ดที่มี allocation จำนวนมาก สิ่งสำคัญไม่ใช่แค่ความเร็วของการจัดการหน่วยความจำเอง แต่รวมถึงว่าเราทำงานกับหน่วยความจำที่มันให้มาได้เร็วแค่ไหนด้วย
    ผมโพสต์ benchmark ที่แสดงเรื่องนี้ใน Julia ไว้ที่: https://discourse.julialang.org/t/blog-post-rust-vs-julia-in...
    malloc/free เปิดโอกาสให้ working memory จริง ๆ ยังคงร้อนอยู่ใน cache ได้ หากมันเล็กพอ allocator อย่าง mimalloc ก็ออกแบบมาให้ allocation ที่ต่อเนื่องกันถูกวางใกล้กันเหมือน compacting garbage collection และ pointer ที่ไม่ซ้ำกัน 4 ตัวของ mimalloc ที่ผมเห็นนั้นห่างกัน 896 ไบต์
    ถ้าผมมีประสบการณ์กับ compacting garbage collection มากกว่านี้ อาจจะมองโลกในแง่ร้ายน้อยลงก็ได้ แต่ผมมองว่า garbage collection เป็นโซลูชันสำหรับการจัดการหน่วยความจำอย่างปลอดภัยที่ซับซ้อนกว่าสิ่งอย่าง borrow checker ของ Rust มาก ความซับซ้อนนั้นถูกผลักไปให้ผู้พัฒนา compiler และ runtime ดังนั้นผู้ใช้มักจะโอเค และเมื่อเขียนโค้ดที่ไม่ไวต่อประสิทธิภาพก็เป็นการประนีประนอมที่ยอมรับได้ RAII ที่มี static lifetime ก็เป็นการประนีประนอมที่สมเหตุสมผลสำหรับโค้ดที่ไม่จำเป็นต้องใช้แนวทางปรับแต่งเฉพาะมากกว่านั้น และตัวอย่างในบทความก็เป็นกรณีที่ต้องใช้โซลูชันเฉพาะอย่างชัดเจน

    • แค่บอกว่า garbage collection ให้ pointer มากกว่า ดังนั้น cache locality จึงแย่ ยังไม่เพียงพอ
      Compacting garbage collection มักใช้ cache ได้ดีกว่า malloc แทบเสมอในโปรแกรมที่รันนาน เพราะ heap fragmentation ทำให้สิ้นเปลือง entry ของ TLB cache และช่องว่างระหว่าง object ตัว bump allocator ของ compacting garbage collection จะให้ pointer ใหม่ทุก allocation เพราะ free ไม่ได้เรียกคืนหน่วยความจำ แต่ allocation เหล่านั้นเป็นลำดับต่อเนื่องกัน และถ้าเป็นกรณีที่ค่อย ๆ ใช้ heap ไปเรื่อย ๆ พร้อมแตะเฉพาะ object ล่าสุดเป็นหลัก ก็ยังคงอยู่ใน cache ได้ การ benchmark ผลกระทบลูกโซ่ของ allocator และ garbage collector นั้นยากมาก และผมมอง benchmark สังเคราะห์แบบนี้แทบทั้งหมดด้วยความกังขา
    • บทความอธิบายว่าทำไมวิธีนี้จึงทำงานได้ดีในบริบทของ RCU และทำไมโดยทั่วไปจึงไม่ค่อยดี แล้วก็วางทิ้งไว้และไม่สนใจต่อ
      สิ่งที่ผู้คนกังวลคือกระบวนการตรวจว่าไม่ได้ใช้หน่วยความจำนั้นอีกแล้ว ไม่มีประสิทธิภาพและไม่กำหนดแน่นอน เมื่อเทียบกับการบอก allocator โดยตรงว่าเลิกใช้ resource แล้ว ผมไม่เคยเห็นใครกังวลเรื่องการเลื่อนการปล่อยหน่วยความจำออกไปในตัวมันเอง
      การไล่ดู live set ทั้งหมดเกิดขึ้นไม่บ่อย และตลอด 30 ปีที่ผ่านมา algorithm ของ garbage collection ก็ถูกปรับปรุงจนเกือบมีสติปัญญาแล้ว แต่ประโยคนี้ไม่ว่าจะตั้งใจหรือไม่ก็ตามกลับละเลยจุดที่ผู้คนมองว่าเป็นปัญหาจริง ๆ เวลา service มีปัญหาจาก garbage collection มันให้ความรู้สึกเหมือนต้องเรียกหมอผีมาปรับจูนโน่นนี่ แล้วหวังว่าจะส่งวิญญาณอาฆาตกลับไปยังโลกเงาได้
      ถ้าใช้วิธีทำเครื่องหมายขยะและรับการแจ้งเตือนเมื่อมันไม่ถูกใช้อีกต่อไป กระบวนการทั้งหมดนั้นก็หายไป แม้การจัดสรรหน่วยความจำใน garbage collection อาจเร็วมาก แต่ถ้าจะเปรียบเทียบอย่างยุติธรรม ก็ต้องรวมต้นทุนของการทำเครื่องหมายและการ compact แบบเฉลี่ยต่อครั้งเข้าไปด้วย
      ปัญหาใหญ่อีกอย่างคือ โดยทั่วไปแล้ว garbage collection ต้องใช้หน่วยความจำมากกว่าการจัดการหน่วยความจำแบบ manual อย่างมากเพื่อให้ได้ประสิทธิภาพเดียวกัน ยังต้องมี CPU ส่วนเกินเพื่อคอยตรวจซ้ำ ๆ ว่ายังมี reference อยู่หรือไม่ และต้องยอมรับการคัดลอกหน่วยความจำเพิ่มเติมสำหรับการ compact แบบมองโลกในแง่ดีด้วย
      สุดท้าย บทความวิจารณ์ว่าเมื่อ lifetime ไม่ชัดเจนก็ต้องใช้การจัดการหน่วยความจำแบบ manual อย่าง Arc/Rc ของ Rust แต่กลับมองข้ามว่าในภาษา garbage-collected ก็เชื่อไม่ได้ว่า finalizer จะถูกเรียกแน่นอน จึงต้องสร้าง infrastructure ที่แทบเหมือนกันขึ้นมาเพื่อปิด external resource
      การถกเถียงนี้เกิดซ้ำมามากพอแล้วตลอด 20–30 ปีที่ผ่านมา และบทความนี้ดูเหมือนไม่ได้นำอะไรใหม่มา นอกจากปัดความกังวลที่ชอบธรรมเกี่ยวกับ garbage collection ให้เป็น meme เท่านั้น meme สนุกก็ดี แต่คำตอบคือไม่มีคำตอบที่ถูกต้องแบบทั่วไป แค่ใช้เครื่องมือที่เหมาะกับการตอบข้อจำกัดด้านการออกแบบของระบบก็พอ
    • ผมไม่ค่อยเข้าใจข้ออ้างนี้ ใน generational garbage collection generation 0 มีโอกาสสูงที่จะอยู่ใน cache และการสร้าง–ทำลายส่วนใหญ่ก็เกิดขึ้นตรงนั้น
      allocation อื่น ๆ ที่มีอายุนานกว่านั้นโดยนิยามแล้วควบคุมจากมุมมองของ cache ได้ไม่ง่าย locality เป็นหนึ่งในข้อดีใหญ่ของ garbage collection และปัญหาเดียวที่ผมรู้จักคือการ mark/sweep แบบ stop-the-world ผมรู้ว่า garbage collection สมัยใหม่มี background thread แต่ก็เข้าใจว่ายังมี event แบบ stop-the-world เกิดขึ้นอยู่ดี
    • ที่ว่า free() คืนหน่วยความจำให้ allocator นั้นถูกต้อง แต่การจัดการ memory fragmentation ใน server ที่รันนานไม่สนุกเลย
      โดยเฉพาะ internal fragmentation ของ page ที่จัดการโดย slab allocator และแม้จะไม่ใช่ปัญหาที่พบบ่อย แต่เป็นปัญหาที่รับมือยาก
    • ถ้าการใช้ cache เป็นเรื่องที่สนใจมากขนาดนั้น arena allocation ก็ทำงานได้ดีเหมือนกับในการจัดสรรหน่วยความจำแบบ manual
      โชคดีที่มีไม่กี่พื้นที่ที่ garbage collection ต้องแข่งกับตัวอย่างที่จัดฉากมาให้สะดวกแบบนี้
  • ยกเว้นกรณีพิเศษที่สามารถจัดการหน่วยความจำทั้งหมดด้วย arena ได้ง่าย ๆ tracing garbage collection ที่ดีนั้นแซงหน้าการจัดการหน่วยความจำแบบแมนนวลในด้าน throughput มานานแล้ว และช่วงหลัง ๆ ผลกระทบต่อ latency ก็ถือว่ายอมรับได้เพียงพอสำหรับแอปพลิเคชันส่วนใหญ่
    ZGC ของ OpenJDK มีเวลาหยุดโดยทั่วไปอยู่ในระดับไมโครวินาทีหลักสิบถึงหลักร้อย และในอัตราการจัดสรรที่สมเหตุสมผล กรณีเลวร้ายที่สุดก็แทบไม่เกิน 1ms ซึ่งอยู่ในช่วงใกล้เคียงกับการหยุดที่เกิดจากระบบปฏิบัติการ
    trade-off ที่สำคัญจริง ๆ มีแค่ปริมาณการใช้หน่วยความจำเท่านั้น นอกจาก niche เฉพาะทาง คือกรณีที่ arena เหมาะกับทุกอย่าง และ worst-case latency อยู่ในช่วงไมโครวินาทีระดับต่ำ คำถามหลักมีเพียงข้อเดียว: แอปพลิเคชันของฉันรันในสภาพแวดล้อมที่มีข้อจำกัดด้านหน่วยความจำหรือไม่ หรือคุ้มไหมที่จะยอมเสียอย่างอื่นเพื่อให้ใช้ RAM น้อยลง

    • จากประสบการณ์ของผม กลับเป็นตรงกันข้าม อายุการใช้งานเฉพาะของแต่ละ object เป็นกรณีพิเศษที่พบไม่บ่อย และโค้ดจริงส่วนใหญ่มี object ที่เกี่ยวข้องกันจำนวนมากซึ่งมีอายุเท่ากันหรือใกล้เคียงกันมาก
      ในโค้ดแบบนั้น การติดตามอายุของ object ทีละตัวถือว่าเกินจำเป็น สุดท้ายแล้วการจัดการหน่วยความจำเป็นเรื่องของอายุการใช้งาน และการมีอายุการใช้งานแยกกันน้อยชุดย่อมดีกว่ามีจำนวนมากเสมอ เพราะไม่ว่าจะเป็นแบบแมนนวลหรืออัตโนมัติ งานที่ต้องทำก็ลดลง
      การไม่ต้องคิดถึงอายุของ object นั้นสะดวกมาก และนั่นทำให้ภาษาที่มี garbage collection ประสบความสำเร็จ แม้ภายในของ garbage collector ที่ดีจะซับซ้อนมากก็ตาม
    • เวลาหยุดถูกแก้ไปได้ระดับหนึ่งแล้ว แต่ การใช้ CPU ของ garbage collection ยังค่อนข้างสูงอยู่
      และยังคงได้รับผลกระทบจาก tail latency ที่คาดเดายาก รวมถึงกรณียกเว้นต่าง ๆ หลายอย่าง
    • ที่ทำงานกำลัง benchmark ZGC กับ Shenandoah อยู่ โดย p100 stop time มักต่ำกว่า 500us
      ZGC ดูเหมือนจะหยุดน้อยครั้งกว่า Shenandoah ดังนั้นจึงน่าจะทำงานมากกว่าต่อการหยุดหนึ่งครั้ง และให้ประสิทธิภาพดีกว่าเล็กน้อย
      ยังต้องทดสอบใน production อยู่ แต่จนถึงตอนนี้ ถ้าใช้ ZGC และ generational ZGC ตั้งแต่ Java 21 เป็นต้นไป การหยุดจาก garbage collection โดยรวมดูเหมือนเป็นปัญหาที่แก้ได้แล้ว
    • เกณฑ์ที่ว่าต้องสามารถจัดการหน่วยความจำทั้งหมดด้วย arena ได้ง่าย ๆ ดูไม่ยุติธรรม ถ้า object ส่วนใหญ่ สามารถจัดสรรด้วย arena ได้ง่าย เพียงเท่านั้นก็ทำให้ความจำเป็นของ garbage collection ส่วนใหญ่หายไปแล้ว
      ตามมุมมองของ Jai การจัดสรรหน่วยความจำแบ่งได้เป็น 4 แบบตามความพบบ่อย: 1) สิ่งที่มีอายุสั้นมากจนวางไว้บน function stack ได้, 2) สิ่งที่มีอายุสั้นและมี lifetime ชัดเจน จึงวางไว้ใน memory arena ระดับ frame/request ได้, 3) สิ่งที่มีอายุยาวและมี owner ชัดเจน จึงจัดการด้วย pool เฉพาะของ subsystem ได้, 4) สิ่งที่มีอายุยาวและ owner ไม่ชัดเจน จึงต้องใช้ dynamic memory management
      หากจะอ้างว่า tracing garbage collection โดยทั่วไปเหนือกว่าการจัดการหน่วยความจำแบบแมนนวล ก็ควรเปรียบเทียบกับระบบที่เขียนโดยมีมุมมองแบบนี้อยู่ในใจ ไม่ใช่ระบบที่เรียก malloc/free กระจัดกระจายไปทั่ว เมื่อเทียบกับแนวปฏิบัติ C++/Rust สมัยใหม่ อาจยุติธรรมกว่า
      ผมเห็นด้วยว่าในระบบส่วนใหญ่ การพึ่งพา tracing garbage collection น่าจะใช้งานได้จริงกว่ามาก แต่นั่นเป็นข้ออ้างอีกแบบหนึ่งโดยสิ้นเชิง
    • ต้องมีหลักฐานรองรับ
  • บทความเริ่มจากสร้างแรงจูงใจให้ RCU แล้วกลับลำมาเริ่มสนับสนุน garbage collection แบบทั่วไปในภาพรวม
    ยังไม่ถึงขั้นเป็นม้าโทรจัน แต่รู้สึกว่าเป็นการเปลี่ยนทิศทางที่ค่อนข้างฉับพลัน

    • ผมคงไม่เรียก RCU ว่า garbage collection เพราะไม่มีจุดใดเลยที่ object อยู่ในสถานะขยะ
      object จะอยู่ในหนึ่งในสามสถานะ และเปลี่ยนสถานะให้เร็วที่สุดเท่าที่ทำได้: active, obsolete but alive for old readers, deallocated
      ขึ้นอยู่กับวิธีเขียนโค้ด น่าจะสามารถนำ object ที่ “obsolete-but-alive” กลับมาใช้ซ้ำอย่างปลอดภัยสำหรับการจัดสรร “new” ได้ด้วย แต่ยังไม่ได้วิเคราะห์ประสิทธิภาพอย่างครบถ้วน
      เช่นเดียวกับที่พบได้บ่อยในการถกเรื่อง garbage collection ประเด็นว่าเมื่อใดควร “ถอย” ไปใช้ shared_ptr/Arc นั้นคลุมเครือมาก ในทางปฏิบัติ แก่นของระบบที่อิง reference counting อย่างจริงจังคือการหลีกเลี่ยง reference count นั่นคือพิสูจน์ให้ได้ว่ามี ownership อยู่แล้ว หรือหลีกเลี่ยง indirect reference ไปเลย การไม่ต้องทำอะไรเลยย่อมดีกว่า “สักวันหนึ่งค่อยทำอะไรบางอย่าง” ของ garbage collection แน่นอน
  • ในซอฟต์แวร์ที่ผมใช้มีสองกรณี: (1) hot path ที่ใช้ allocator แบบปรับแต่งเอง เสมอและหลีกเลี่ยงการจัดสรร, (2) ทุกอย่างที่เหลือ
    ในกรณี (1) ไม่ว่าจะมี garbage collection หรือไม่ก็ไม่ต่างกัน และผมจะหลีกเลี่ยงมันอยู่ดี ส่วนกรณี (2) garbage collection นั้นสะดวกและถูกต้องจริง ๆ

    • เห็นด้วย เคยอยู่ในที่ที่ใช้ Java/C++ ร่วมกันและพยายามแก้ dichotomy นี้ด้วย interoperability แต่สุดท้ายปัญหามากกว่าที่แก้ได้
      งานที่ Java ทำมากับ garbage collector สมัยใหม่นั้นน่าประทับใจ แต่แม้แต่พวกเขาก็ยอมรับโดยอ้อมผ่าน Valhalla ว่ายังมีที่ทางสำหรับโค้ดแบบไม่จัดสรร/จัดสรรน้อย
  • ข้อสังเกตที่ว่า ระบบปฏิบัติการสมัยใหม่สำหรับผู้ใช้ทั่วไป กล่าวคือระบบปฏิบัติการที่ไม่ใช่ RTOS เฉพาะทาง มี garbage collection ในตัวนั้น คลาดเคลื่อนไปเล็กน้อยตรงนี้
    เราแค่ไม่ได้เรียกมันแบบนั้น แต่เรียกว่าการจัดการหน่วยความจำเท่านั้นเอง แล้วภาษาที่มี garbage collection ในตัวเรียกว่าอะไร? ก็เรียกว่าภาษาที่จัดการหน่วยความจำให้
    มักเห็นรูปแบบนี้ในโปรแกรม C รุ่นเก่าที่ทำงานแบบ “จากบนลงล่าง” คือจัดสรรหน่วยความจำ เคลียร์ทรัพยากรของระบบ แต่ไม่สนใจ free เพราะเมื่อโปรแกรมจบ ระบบปฏิบัติการก็ยึดหน่วยความจำนั้นกลับไปทั้งหมดอยู่แล้ว จะทำไปทำไม
    ตรงนี้มีโอกาสที่จะสร้างระบบปฏิบัติการที่จัดการทรัพยากรเหมือน garbage collector ระดับระบบปฏิบัติการ ซึ่งแยกตัวจากโปรแกรมน้อยกว่า garbage collector ใน runtime ของภาษา แต่โดยทั่วไปในภาษาที่มี garbage collection ตัว garbage collector จะพัวพันอย่างซับซ้อนกับแทบทุกบรรทัดของ runtime ดังนั้นการทำให้เฉพาะดิสโทรสำหรับระบบปฏิบัติการหนึ่งส่งต่อการควบคุมนั้นให้ระบบปฏิบัติการจึงไม่ใช่เรื่องปฏิบัติได้จริง
    ถึงอย่างนั้นก็ยังน่าเสียดาย เพราะยังมีพื้นที่ให้ปรับปรุงปัญหาเรื้อรังจำนวนมากที่เกิดจากการแยกการจัดการหน่วยความจำระดับโปรแกรมกับระดับระบบปฏิบัติการออกจากกันอย่างประดิษฐ์

    • เมื่อโปรแกรมจบ ระบบปฏิบัติการจะจัดการให้ ดังนั้นไม่ใช่แค่หน่วยความจำ แต่ทรัพยากรอื่นอย่างไฟล์ ซ็อกเก็ต เธรด ก็ไม่จำเป็นต้องปล่อยเองด้วยซ้ำ หากไม่ใช่ AmigaOS
      เหตุผลเดียวที่ต้องปล่อยหน่วยความจำคือ เพื่อให้แอปพลิเคชันที่รันระยะยาวนำกลับมาใช้กับการจัดสรรอื่นได้ โดยไม่ต้องขอหน่วยความจำใหม่จากระบบปฏิบัติการเพิ่ม สำหรับเครื่องมือบรรทัดคำสั่งที่รันครั้งเดียวแล้วจบ โดยทั่วไปไม่จำเป็น
    • ระบบปฏิบัติการรู้ว่าจะปล่อยหน่วยความจำได้ก็ต่อเมื่อโปรเซสสิ้นสุดลงเท่านั้น ไฟล์แฮนเดิลหรือทรัพยากรอื่นก็เช่นกัน
      หากโปรเซสถูกออกแบบมาให้จบเมื่อทำงานเสร็จ ก็สามารถใช้ระบบปฏิบัติการเหมือน garbage collector ได้
      แต่ไม่เคยมีวิธีที่ระบบปฏิบัติการจะรู้ได้ว่าหน่วยความจำใดในโปรแกรมที่กำลังรันอยู่ไม่ได้ถูกใช้งานแล้ว อาจยกเว้นระบบปฏิบัติการวิจัยเฉพาะทางที่เข้าใจยากบางตัว ดังนั้นแทนที่จะเป็นโอกาสที่พลาดไป ผมมองว่าสิ่งที่บอกว่าพลาดนั้นไม่ได้มีอยู่ในรูปแบบที่มีความหมายตั้งแต่แรก
      ในทางกลับกัน วิธีเขียนโปรแกรมโดยใช้โปรแกรมที่เรียบง่ายมากและมีอายุสั้นนั้นสมเหตุสมผลอย่างสมบูรณ์ เครื่องมือ CLI และภาษาสคริปต์ที่ใช้สคริปต์มันทำงานแบบนั้น เว็บเซิร์ฟเวอร์สมัยก่อนก็ทำงานแบบนั้นผ่าน CGI เป็นต้น และแม้วันนี้ก็ยังเป็นแนวทางที่สมเหตุสมผลอย่างเพียงพอ
    • Epsilon GC ของ Java ก็คือสิ่งนั้นนั่นเอง
  • (1) การเปลี่ยนจาก RCU ไปเป็น garbage collection แบบ tracing ทั่วไป ให้ความรู้สึกเหมือนล่อด้วยเหยื่อแล้วสลับของ
    (2) การจัดการหน่วยความจำด้วยมือไม่ได้มีแค่การเรียก malloc/free แต่รวมถึงเรื่องการจัดวางด้วย เช่น การแยกอาร์เรย์ของ struct, inlining, offset โดยนัย, packing เป็นต้น

    • สำหรับข้อ (2) Virgil มีความสามารถหลายอย่างที่ควบคุมการจัดวางหน่วยความจำได้ในหลายระดับ
      ดูเหมือนว่ากำลังพูดถึง “อาร์เรย์ของ struct” ซึ่งสามารถทำได้ด้วยอาร์เรย์ของ tuple และจะถูก flatten/normalize ตามธรรมชาติตาม target กล่าวคือบน target แบบเนทีฟจะกลายเป็นอาร์เรย์ของ struct
      ยังสามารถกำหนดการจัดวางที่แม่นยำระดับไบต์ได้ด้วย[1] โดยหลักใช้สำหรับการเชื่อมต่อกับซอฟต์แวร์อื่นหรือการพาร์สฟอร์แมตไบนารี สามารถ unbox ชนิดข้อมูลแบบพีชคณิตได้ และในเร็ว ๆ นี้จะควบคุมได้ถึงการเข้ารหัสที่แม่นยำของชนิดข้อมูลแบบพีชคณิต
      Virgil ใช้ garbage collection
      [1] https://github.com/titzer/virgil/blob/master/doc/tutorial/La...
    • ไม่เห็นด้วยว่าข้อ 2 คือการจัดการหน่วยความจำด้วยมือ
      แม้ว่าภาษา managed สมัยใหม่จะขาดการควบคุมการจัดวางหน่วยความจำอย่างชัดเจน แต่แม้แต่ภาษา low-level ก็ยังห่างไกลจากความสมบูรณ์แบบ และก็มีวิธีส่งผลต่อมันได้อย่างแน่นอน
  • สิ่งหนึ่งที่บทความนี้ตกหล่นไปคือ async/await เข้ากันได้ดีกับ garbage collection มาก
    ผมไม่ชอบ async/await ด้วยเหตุผลด้านสไตล์ส่วนตัวที่ค่อนข้างเฉพาะตัว แต่จะไม่พูดยาว
    เคยใช้บ่อยใน TypeScript/JavaScript และเคยใช้ใน Dart ด้วย ซึ่งที่นั่นมันทำงานอย่างที่คาดไว้
    เคยใช้ใน Rust ด้วย แต่ในความเห็นผมมันคือหายนะ การฝืนยัดการจัดการหน่วยความจำแบบที่จำเป็นต่อการใช้ async/await ใน runtime แบบหลายเธรดเข้าไปนั้นเป็นภาพนรก
    https://doc.rust-lang.org/std/pin/index.html

    • ในโค้ด async ทั่วไปของ Rust ไม่จำเป็นต้องใช้ pinning สิ่งนี้มีไว้สำหรับผู้เขียนไลบรารี
  • มีจุดหนึ่งที่บทความจำนวนมากที่สนับสนุน garbage collection มักข้ามไป และบทความนี้ก็ดูเหมือนจะเป็นเช่นนั้น นั่นคือ หน่วยความจำ เป็นเพียงทรัพยากรชนิดหนึ่งเท่านั้น
    โดยเฉพาะในการเขียนโปรแกรมระบบ โค้ดที่ถูกต้องยังต้องจัดการทรัพยากรภายนอกอย่าง file handle, socket ฯลฯ ด้วย garbage collection แก้ปัญหาได้เฉพาะส่วนหน่วยความจำของแอปพลิเคชัน จึงไม่ได้ช่วยอะไรเลยกับการจัดการทรัพยากรภายนอกเหล่านี้ ตรงกันข้าม มันอาจทำให้ซับซ้อนขึ้นมากด้วยซ้ำ แค่ดูว่าต้องทำอะไรบ้างเพื่อ implement IDisposable ที่ไม่ใช่เรื่องเล็กใน .NET ให้ถูกต้องก็เห็นแล้ว
    จากประสบการณ์ของผม แนวทางอย่าง RAII หรือ reference counting ทำให้จัดการหน่วยความจำกับทรัพยากรภายนอกในแบบที่เป็นหนึ่งเดียวกันได้ง่ายกว่ามาก และทำให้เขียนโค้ดที่ถูกต้องรวมถึงให้เหตุผลกับมันได้ง่ายขึ้นด้วย
    อย่างไรก็ตาม ผมไม่ได้ต่อต้าน garbage collection อย่างออกหน้าออกตา มันเป็นเครื่องมือที่มีข้อดีข้อเสียเหมือนอย่างอื่น ๆ แนวทาง RCU แบบ “manual GC” ที่บทความพูดถึงก็น่าสนใจสำหรับงานบางประเภท

    • ระหว่างหน่วยความจำกับทรัพยากรอื่นมีความแตกต่างอย่างมาก หน่วยความจำ เป็นองค์ประกอบพื้นฐานของการคำนวณทุกอย่าง เช่นเดียวกับพลังประมวลผล
      ไม่ใช่เรื่องบังเอิญที่โมเดลการคำนวณเชิงทฤษฎีส่วนใหญ่สมมติว่ามีหน่วยความจำไม่จำกัด ในซอฟต์แวร์บางประเภทอย่างเคอร์เนลของระบบปฏิบัติการหรือแอปพลิเคชัน hard real-time แม้แต่พลังประมวลผลก็ยังถูกจัดสรรแบบ manual แต่แทบไม่มีภาษาใดที่บังคับให้ต้องจัดสรรพลังประมวลผลแบบ manual
      ด้วยเหตุผลคล้ายกัน การจัดการหน่วยความจำอัตโนมัติจึงมีประโยชน์มากในการทำ abstraction ของการคำนวณ มันทำให้รายละเอียดเรื่องหน่วยความจำของ subroutine ไม่รั่วไหลออกไปถึงผู้เรียก และในแง่การใช้ CPU รายละเอียดแบบนี้ก็มักปรากฏให้เห็นเพียงไม่บ่อย
      การคำนวณที่ไม่ใช่เรื่องจิ๊บจ๊อยทุกอย่างล้วนเกี่ยวข้องกับการประมวลผลและหน่วยความจำในปริมาณที่ไม่ใช่ค่าคงที่บางอย่าง แต่ I/O มักเกิดขึ้นที่ขอบของระบบ แน่นอนว่าการจัดการ I/O ก็สำคัญมาก แต่ในแง่ความเป็นศูนย์กลางของแนวคิดเรื่องการคำนวณและ abstraction ของการคำนวณ มันไม่ใช่แก่นเท่าการประมวลผลกับหน่วยความจำ
    • ถูกต้อง และข้ออ้างเรื่อง memory safety ก็ใช้ได้กับทรัพยากรอื่นด้วย
      ตัวอย่างเช่น Rust ในที่สุดก็มี I/O safety ดังนั้น file handle อย่าง OwnedFd บน Unix หรือ handle อย่าง OwnedHandle บน Windows จึงกลายเป็น object ที่มี ownership ไม่ใช่ integer อย่างเลข 4
      มองผิวเผินอาจดูเหมือนเป็นเรื่องการหลีกเลี่ยงความผิดพลาดโง่ ๆ เช่นเอา handle ไปทำ arithmetic หรือใช้ค่าที่สงวนไว้เป็น sentinel ผิด แต่ด้วย ownership model แม้จะทำงานยาก ๆ กับ handle ก็มี ownership ที่ชัดเจน และยังโปร่งใสต่อผู้ดูแลโค้ดในภายหลังด้วย
    • พอย้ายจาก C++ มาใช้ C# เป็นหลัก ผมชอบการจัดการหน่วยความจำแบบ garbage collection แต่ไม่ชอบการต้องตาม file handle, socket ฯลฯ
      ทำให้รู้สึกถึงคุณค่าของ RAII อย่างมากจริง ๆ
    • สิ่งที่มักถูกมองข้ามเวลาวิจารณ์ภาษาที่มี garbage collection คือ ส่วนใหญ่ในนั้นมีฟีเจอร์สำหรับการจัดการทรัพยากรแบบ deterministic อยู่แล้ว แต่หลายคนไม่ยอมเรียนรู้
      บางภาษามี RAII บางภาษามี keyword ให้ใช้ บางภาษามีการจัดการแบบ arena หรือ lambda ที่มีการจัดการโดยนัย บางภาษาได้รับความช่วยเหลือจาก type system บ้าง และบางภาษาก็ผสมสิ่งข้างต้นเข้าด้วยกันเล็กน้อย
      ยิ่งไปกว่านั้น เช่นเดียวกับที่นักพัฒนาระบบต้องพึ่งพา static analyzer, static analyzer ของภาษาเหล่านั้นก็สามารถตรวจสอบสิ่งที่พลาดไปเมื่อ type system เพียงอย่างเดียวไม่เพียงพอได้
    • แน่นอนว่า RAII ยอดเยี่ยม แต่ถ้าเป็นภาษาที่มี garbage collection และการจัดการ exception ที่เหมาะสม ก็สามารถจัดการทรัพยากรได้อย่างปลอดภัย
      ตัวอย่างเช่น คำสั่ง try-with-resources ของ Java รับประกันว่าทรัพยากรจะถูกปล่อยอย่างปลอดภัยแม้เกิด exception: https://docs.oracle.com/javase/tutorial/essential/exceptions...
      แค่มี building block พื้นฐานแบบนี้ ก็สามารถสร้างระบบที่ค่อนข้างแข็งแรงและ resource-safe ได้แล้ว