1 คะแนน โดย GN⁺ 2023-10-08 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ข้อถกเถียงเรื่อง Rust async runtime ไม่ได้อยู่ที่ว่าจะใช้ “OS thread ต่อคอร์” หรือไม่เท่านั้น แต่ใกล้เคียงกับการเลือกว่า จะปรับสมดุลโหลดด้วย work-stealing หรือจะลดการย้ายข้อมูลด้วยแนวทาง share-nothing
  • async Rust ตอบโจทย์เงื่อนไขหลักของ thread-per-core อยู่แล้ว ได้แก่การจัดการ concurrency ใน user space และ asynchronous I/O ประเด็นที่เหลือคือ จะหลีกเลี่ยงการแชร์สถานะข้ามคอร์ได้มากแค่ไหน
  • work-stealing พยายามลดเวลาว่างของเธรดเพื่อปรับปรุง tail latency และการใช้ CPU แต่ใน Rust สถานะที่ถูกใช้ข้ามจุด yield ต้องปลอดภัยต่อเธรด จึงมีข้อจำกัด Send ตามมา
  • share-nothing สามารถลด latency ได้ด้วยการทำให้ข้อมูลค้างอยู่ในแคชของคอร์ใดคอร์หนึ่ง แต่ความยากแตกต่างกันมากระหว่างกรณีที่แบ่งสถานะได้ง่ายอย่าง key-value store กับกรณีที่ต้องมีธุรกรรมหรือการเปลี่ยนแปลงแบบ atomic
  • งานวิจัยของ Enberg เปรียบเทียบ share-nothing กับ shared-state แต่ทั้งสองฝั่งไม่ได้ใช้ work-stealing ดังนั้นจึงยากที่จะนำผลลัพธ์นั้นไปเชื่อมตรงๆ เพื่อวิจารณ์ executor แบบ work-stealing ของ Rust

แก่นของข้อถกเถียงเรื่อง Rust async runtime

  • ในชุมชน Rust ยังมีการถกเถียงกันต่อเนื่องเกี่ยวกับการตัดสินใจที่ runtime async หลักๆ อย่าง tokio ใช้ multi-threaded executor เป็นค่าเริ่มต้น และปรับสมดุล task หลายตัวแบบไดนามิกด้วย work-stealing
  • ผู้ใช้บางส่วนวิจารณ์ว่าค่าเริ่มต้นนี้ทำให้ข้อจำกัด Send + 'static หรือ Send + Sync + 'static กระทบประสบการณ์การเขียนโค้ด
  • บางคนชอบเซิร์ฟเวอร์แบบเธรดเดียว แต่ในที่นี้ตั้งสมมติฐานว่าระบบ Rust ต้องการใช้ เวลา CPU หนึ่งคอร์ขึ้นไป
  • ทางเลือกที่ถูกเสนออย่าง “thread-per-core” ถูกคาดหวังว่าจะเร็วกว่าและ implement ได้ง่ายกว่า แต่การได้ทั้ง performance และความง่ายในการ implement พร้อมกันนั้นทำได้ยาก

ความสับสนที่ชื่อ “thread-per-core” สร้างขึ้น

  • async executor แบบ multi-threaded ที่ใช้กันอยู่เดิมก็ถือเป็น thread-per-core ในความหมายกว้างได้เช่นกัน
    • สร้าง OS thread ต่อคอร์
    • และ schedule task จำนวนมากกว่าจำนวนคอร์มากไว้บนเธรดเหล่านั้น
  • Pekka Enberg สรุป thread-per-core ว่าเป็นการผสานแนวคิดสามอย่าง
    • จัดการ concurrency ใน user space แทน kernel thread ที่มีต้นทุนสูง
    • ใช้ asynchronous I/O เพื่อไม่ให้เธรดประจำคอร์ถูกบล็อก
    • แบ่งข้อมูลระหว่าง CPU core เพื่อขจัดต้นทุน synchronization และการย้ายข้อมูลระหว่าง CPU cache
  • Enberg มองว่าสองข้อแรกสำคัญต่อการสร้างระบบ throughput สูง แต่ข้อที่สามอาจจำเป็นเฉพาะในเครื่อง multicore ขนาดใหญ่มากเท่านั้น
  • หากใช้ async Rust เงื่อนไขสองข้อแรกคือ concurrency ใน user space และ asynchronous I/O ก็ถูกตอบสนองอยู่แล้ว
  • ดังนั้นศูนย์กลางของข้อถกเถียงจึงไม่ใช่ thread-per-core เอง แต่เป็นว่าจะเลือก optimization แบบ work-stealing หรือ share-nothing

เป้าหมายและต้นทุนของ work-stealing

  • work-stealing เป็น optimization ที่ทำให้ทุกเธรดมีงานทำอย่างต่อเนื่อง เพื่อลด tail latency
  • ในระบบจริง ปริมาณงานที่แต่ละ task ต้องใช้แตกต่างกัน
    • HTTP request หนึ่งอาจต้องใช้งานมากกว่า request อื่นมาก
    • แม้ตอนแรกจะแบ่งงานต่อเธรดอย่างสมดุลแล้ว แต่เมื่อเวลาผ่านไปปริมาณงานอาจแตกต่างกันจากความต่างของ task ที่คาดเดาไม่ได้
  • เมื่อโหลดสูงสุด บางเธรดอาจได้รับงานมากกว่าที่จัดการได้ ขณะที่เธรดอื่นยังว่างอยู่
  • tokio, async-std และ smol ต่าง implement work-stealing โดยมีเป้าหมายเพื่อลด tail latency และปรับปรุงการใช้ CPU
  • ต้นทุนคือ task อาจหยุดบนเธรดหนึ่งแล้วไปเริ่มต่อบนอีกเธรดหนึ่งได้
    • สถานะที่ถูกใช้ข้ามจุด yield ต้องปลอดภัยต่อเธรด
    • ใน Rust API สิ่งนี้ปรากฏในรูปแบบที่ future ต้องเป็น Send
    • หากไม่เข้าใจสถานะของระบบอย่างชัดเจน ก็ยากที่จะตัดสินใจว่าจะรับประกัน Send ด้วยวิธีใด
  • เมื่อสถานะย้ายไปยังเธรดอื่น จะเกิดต้นทุน synchronization และ cache miss ซึ่งขัดกับหลัก share-nothing ที่แต่ละ CPU จัดการเฉพาะสถานะของตัวเอง

ตรรกะด้าน performance ของ share-nothing

  • share-nothing เป็นการออกแบบที่พยายามลด tail latency โดยให้ข้อมูลอยู่ในแคชที่เร็วกว่าของ CPU core ใด core หนึ่ง แทนที่จะอยู่ในแคชที่ช้ากว่าซึ่งหลายคอร์แชร์กัน
  • งานวิจัยของ Enberg The Impact of Thread-Per-Core Architecture on Application Tail Latency benchmark key-value store แบบ share-nothing กับ memcached โครงสร้าง shared-state และแสดงให้เห็นการปรับปรุง tail latency
  • วิธีที่ชุมชน Rust อ้างงานนี้แบบง่ายๆ เช่น “performance ดีขึ้น 71%” นั้นตื้นเขินและไม่ค่อยช่วยอะไร
  • key-value store ของ Enberg แบ่งสถานะและ connection เพื่อให้เป็นโครงสร้าง share-nothing
    • แบ่ง keyspace ไปยังหลายเธรดด้วย hash function
    • แบ่ง TCP connection ขาเข้าระหว่างเธรดด้วย SO_REUSEPORT
    • route request จากเธรดที่จัดการ connection ไปยังเธรดที่จัดการ keyspace นั้นผ่าน message-passing channel
  • memcached ให้ทุกเธรดแชร์ ownership ของ keyspace ที่ถูกแบ่งไว้ และแต่ละ partition ถูกป้องกันด้วย mutex
  • ผลลัพธ์ของ Enberg แสดงให้เห็นว่าโครงสร้างที่ใช้ channel สามารถทำ tail latency ได้ต่ำกว่า mutex
    • มองได้ว่า cache miss ลดลงเพราะ partition เดิมถูกเข้าถึงซ้ำๆ และค้างอยู่ในแคชของคอร์หนึ่ง
  • อย่างไรก็ตาม การออกแบบที่หลีกเลี่ยงการย้ายข้อมูลด้วยฟีเจอร์ระดับสูงของ kernel และโครงสร้างที่วางแผนอย่างรอบคอบนั้น ยากจะบอกว่า implement ง่ายกว่าวิธีห่อข้อมูลไว้ใน mutex

กรณีที่แบ่งสถานะได้ง่ายและกรณีที่ยาก

  • key-value store เป็นตัวอย่างที่เหมาะกับ share-nothing เพราะแบ่งสถานะของแอปพลิเคชันไปยังหลายเธรดได้ง่าย
  • หากในแอปพลิเคชันที่ซับซ้อนกว่า ต้องเปลี่ยนสถานะของหลาย partition แบบ transaction หรือแบบ atomic ก็จำเป็นต้องระมัดระวังมากขึ้นอย่างมากเพื่อ implement ให้ถูกต้อง
  • การสนับสนุน share-nothing มีบางส่วนที่คล้ายกับกระแสเกินจริงในอดีตรอบฐานข้อมูลแบบ eventual consistency
    • performance อาจดีขึ้นได้
    • แต่ต้องออกแบบอย่างรอบคอบเพื่อหลีกเลี่ยงบั๊กที่เกิดจากความไม่สอดคล้องของข้อมูล

ระยะห่างระหว่างงานวิจัยของ Enberg กับข้อถกเถียงเรื่อง work-stealing ของ Rust

  • ทั้ง implementation ของ Enberg และ memcached ต่างไม่ได้ใช้ work-stealing
  • ด้วยเหตุนี้จึงยากที่จะเชื่อมผลลัพธ์ performance หลักของงานวิจัยเข้ากับสถาปัตยกรรม work-stealing ของ Rust โดยตรง
  • หากเพิ่ม work-stealing เข้าไปในโครงสร้างของ Enberg อาจทำให้การย้ายข้อมูลเพิ่มขึ้นระดับหนึ่ง แต่ก็มีความเป็นไปได้ว่าจะเป็นวิธีเพิ่มการใช้ CPU
  • ในกรณีเพิ่ม work-stealing ให้ memcached ผู้เขียนมองว่ายากจะจินตนาการเหตุผลว่าทำไมมันจึงจะไม่ช่วย
  • implementation ในงานวิจัยถูกออกแบบให้แบ่งงานไว้ล่วงหน้าอย่างสม่ำเสมอ ด้วยการแบ่ง keyspace อย่างสมดุลและ SO_REUSEPORT
  • ในสภาพแวดล้อมจริง อาจเกิดความไม่สมดุลแบบไดนามิกได้
    • hot key ได้รับการอ่านและเขียนมากกว่า ทำให้ปริมาณงานของเธรดที่จัดการ keyspace นั้นเพิ่มขึ้น
    • connection บางรายการทำ request มากกว่า connection อื่น ทำให้ปริมาณงานของเธรดที่จัดการ connection นั้นเพิ่มขึ้น
  • เข้าใจได้ว่า benchmark ในงานวิจัยไม่ได้จำลองเงื่อนไขความไม่สมดุลเหล่านี้ เพราะแต่ละ connection ทำงานปริมาณคงที่กับ key แบบสุ่ม
  • แม้ในระบบ share-nothing ก็สามารถออกแบบเพื่อลดความไม่สมดุลได้ เช่น cache hot key ไว้ใน partition เพิ่มเติม
  • อาจตรึง task บางส่วนไว้กับคอร์เฉพาะเพื่อหลีกเลี่ยงการย้ายสถานะ ขณะเดียวกันก็ใช้ work-stealing รูปแบบอื่นเป็น optimization ได้

ข้อสรุปเชิงปฏิบัติ

  • หากออกแบบระบบอย่างรอบคอบเพื่อหลีกเลี่ยงการย้ายข้อมูลระหว่าง CPU cache ก็สามารถได้ performance ดีกว่ากรณีที่ไม่ทำเช่นนั้น
  • แต่ถ้าระดับความไม่พอใจหลักคือการต้องเพิ่มข้อจำกัด Send ใน generics ก็คงยากจะมองว่ากำลังทำ performance engineering แบบละเอียดระดับนั้นอยู่
  • หากระบบจะใช้ shared state อยู่แล้ว ก็ยากจะบอกว่า work-stealing ไม่ช่วยปรับปรุงการใช้ CPU ในสถานการณ์ที่มีโหลด

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

 
GN⁺ 2023-10-08
ความคิดเห็นจาก Hacker News
  • โดยส่วนตัวแล้ว บทความนี้ให้ความรู้สึกว่า พลาดภาพใหญ่ไปแล้วไปยึดติดกับรายละเอียด
    แก่นของข้อถกเถียงไม่ใช่ executor แบบ work-stealing ที่มีเธรดต่อคอร์ แต่เป็นว่า async/await ใน Rust เป็น abstraction ที่ดีสำหรับสิ่งนั้นหรือไม่
    ยิ่งใช้โค้ด async มากเท่าไร ก็ยิ่งรู้สึกว่า abstraction รั่ว และเขียนโปรแกรมโต้ตอบกับมันได้ยาก
    โมเดล concurrency ทางเลือกที่ผู้คนต้องการคือ structured concurrency ผ่าน stackful coroutine และ channel บน executor แบบ work-stealing
    จนกว่าจะมีใครสักคนทำสิ่งนั้นขึ้นมาแล้วเปรียบเทียบกับ async/await และโมเดลที่อิง future การถกเถียงอย่างมีประสิทธิผลก็คงทำได้ยาก คนที่ไม่ชอบ async ก็จะหลีกเลี่ยง ส่วนคนที่ไม่สนใจ Send + Sync + 'static ก็จะใช้ต่อไป

    • ถ้าอย่างนั้น หากไม่ได้ต้องการระดับ C10M scale จริง ๆ ก็แค่ใช้เธรดต่อการเชื่อมต่อไปเลย และไม่ต้องใช้สิ่งที่ซับซ้อนกว่านั้นไม่ได้หรือ
      งานที่ต้องการสเกลระดับนั้นน่าจะพบได้น้อยมากในกรณีส่วนใหญ่
      บทความทำนองนี้มักพูดแค่ว่า “kernel thread แพง” แล้วปล่อยผ่านไปราวกับว่านั่นเป็นความจริงโดยเนื้อแท้ แต่ในความเป็นจริงไม่ใช่
      ถ้างานไม่ได้เอาแต่สร้าง task ที่ไม่ทำอะไรเลย overhead ของ “เธรดจริง” ก็น่าจะเล็ก และความเรียบง่ายที่ได้กลับมานั้นมหาศาล
    • ผมมองว่าข้อถกเถียง async/await เทียบกับ stackful coroutine น่าสนใจกว่า แต่ข้อถกเถียงในบทความนี้ไม่ใช่เรื่องนั้น
      ข้อความอ้างอิงที่ withoutboats พูดถึงคือส่วนในบทความที่ลิงก์ไว้ ซึ่งวิจารณ์ multithreading โดยปริยาย และ work stealing โดยเฉพาะ
      [1] https://www.reddit.com/r/rust/comments/16p47f1/the_state_of_...
    • ในโลกนี้มีหลายหัวข้อที่ผู้คนเห็นไม่ตรงกัน
      บทความนี้แค่พูดถึง ข้อถกเถียงอีกเรื่องหนึ่ง ไม่ใช่ข้อถกเถียงที่ผมอยากให้เขียน
    • สามารถใช้ async/await ร่วมกับ channel โดยจำกัดให้อาร์กิวเมนต์ของฟังก์ชัน async ส่งได้เฉพาะ immutable reference หรือชนิดที่ copy ได้ และให้ชนิดที่แชร์ร่วมกันแบบแก้ไขได้สื่อสารผ่าน channel
      จะสร้าง เซิร์ฟเวอร์แบบ Erlang ที่เป็นเจ้าของชนิดที่แก้ไขได้และสื่อสารผ่าน channel ก็ได้ หรือจะเดินหน้าด้วย Arc ก็ได้
      Rust ให้พลังในการทำได้ทั้งสองแบบ
    • เท่าที่ผมเข้าใจ ไม่มีอะไรขัดขวางการใช้ channel บนการรองรับ async ที่มีอยู่ของ Rust
      stackful coroutine ดูไม่มีความหมายมากนักในจุดนั้น เพราะถ้าถึงขั้นนั้นก็ใช้เธรดแยกไปเลยก็ได้
  • ปัญหาดั้งเดิมที่โมเดลเธรดต่อคอร์พยายามแก้เมื่อราว 15 ปีก่อน คือการได้ scalability และ efficiency ด้านการคำนวณ บนเซิร์ฟเวอร์ multi-core ทั่วไป
    ตรงข้ามกับข้ออ้างบางอย่าง เธรดต่อคอร์มีเป้าหมายอย่างชัดเจนเพื่อปรับแต่ง workload ที่เน้น CPU และแม้ภายหลังจะต้องมีการจัดการ I/O ที่ซับซ้อนขึ้น แต่ก็ปรากฏว่ามันยอดเยี่ยมกับ workload ที่เน้น I/O throughput สูงด้วย
    เมื่อเห็นบทความแบบนี้ รู้สึกเหมือนกำลังทำซ้ำอย่างรวดเร็วกับความผิดพลาดด้านการออกแบบซอฟต์แวร์หลายอย่างที่เคยเกิดขึ้นตอนสถาปัตยกรรมเธรดต่อคอร์ถูกนำมาใช้
    ถ้าพูดอย่างเป็นธรรม วิทยาการคอมพิวเตอร์ที่เกี่ยวกับเธรดต่อคอร์ส่วนใหญ่มาจาก HPC และมีเอกสารอธิบายค่อนข้างน้อย
    บทความนี้โฟกัสที่ปัญหายุ่งยากของสถาปัตยกรรมเธรดต่อคอร์ นั่นคือการทำสมดุลงานระหว่างคอร์
    โมเดลพื้นฐานมีสี่แบบคือ push/pull ของ data/load และ work stealing โดยเนื้อแท้แล้วคือโมเดล load pull
    วิธีนี้มี overhead ต่ำเฉพาะตอนที่แทบไม่ต้องใช้มัน กล่าวคือเมื่อมีสมดุลตามธรรมชาติ ซึ่งในปัญหาจริงพบได้ยาก
    ใน workload ที่น่าสนใจกว่าซึ่งมีการเอียงของโหลดแบบไดนามิกระหว่างคอร์เกิดขึ้นบ่อย work stealing จะกลายเป็นคอขวดด้านประสิทธิภาพเพราะ overhead จากการประสานงาน
    ถึงอย่างนั้นมันก็เข้าใจง่าย จึงยังใช้กับ workload ที่เหมาะสม แต่ไม่ค่อย generalize ได้ดี
    ในบรรดา workload ที่พบไม่บ่อยและไม่ได้กล่าวถึงในบทความ อาจมีบางกรณีที่มันเป็นตัวเลือกที่ดีที่สุด
    โมเดลที่ดูจะได้รับแรงหนุนมากที่สุดในตอนนี้คือ data push ซึ่งเข้าใจยากกว่า แต่ต้องการการประสานงานระหว่างเธรดน้อยกว่ามาก
    โมเดลนี้ก็มี workload ที่ไม่เหมาะเช่นกัน แต่ generalize ได้ดีกับ workload ส่วนใหญ่ที่พบบ่อย
    สถาปัตยกรรมเธรดต่อคอร์จะยังคงอยู่ต่อไป เพราะในแง่ scalability และ efficiency เอาชนะได้ยาก
    เพียงแต่ซอฟต์แวร์เอนจิเนียร์ส่วนใหญ่ยังขาดสัญชาตญาณว่า การออกแบบเธรดต่อคอร์สมัยใหม่และเป็น idiomatic ควรมีหน้าตาอย่างไร และยิ่งแย่ลงไปอีกเพราะมีบทความหรือเปเปอร์ที่เจาะลึกหัวข้อนี้ค่อนข้างน้อย

    • เหตุผลหลักที่เฟรมเวิร์ก async ของ Rust ใช้ work stealing น่าจะเป็นเพราะเปิดใช้ในระดับเฟรมเวิร์กได้ง่าย และช่วยปรับปรุงประสิทธิภาพให้หลายแอปพลิเคชัน โดยเฉพาะแอปที่ไม่ได้ออกแบบมาอย่างอุดมคติ
      ดูจากคำแนะนำตัวในโปรไฟล์กับคำอธิบายนี้แล้ว แอปพลิเคชันแบบนั้นคงไม่ใช่ประเภทที่คุณมักรับมือเป็นหลัก
      ถ้ามีลิงก์เอกสารที่เกี่ยวข้องก็อยากอ่าน
    • อยากรู้ว่าไอเดีย data push คืออะไร
  • เห็นด้วยกับประโยคที่ว่า “การบอกว่าเน้น I/O หมายความว่าในทางปฏิบัติเมื่อเขียนด้วย Rust งานไม่ได้มากพอจะทำให้คอร์เดียวเต็ม ถ้าอย่างนั้นก็แน่นอนว่าให้เขียนระบบแบบเธรดเดียว”
    แอปพลิเคชันจำนวนมากที่ผมใช้เป็น daemon ที่ตอบสนองต่อ event อยู่เบื้องหลัง และถ้าทำเป็นเธรดเดียวก็สามารถตัด overhead ของ Arc กับ Mutex ออกได้
    overhead นี้ ณ จุดนั้นส่วนใหญ่ใกล้เคียงกับภาระทางไวยากรณ์มากกว่า แต่ทำให้ debug และบำรุงรักษาง่ายขึ้น
    สิ่งที่ผมชอบใน Rust คือจ่ายต้นทุนเฉพาะกับสิ่งที่จำเป็นเท่านั้น
    บทความต้นทางที่โพสต์นี้ตอบกลับ วิจารณ์ว่า tokio และไลบรารี async อื่น ๆ ทำให้การกลับไปใช้ สถาปัตยกรรมเธรดเดียว ที่เรียบง่ายเป็นเรื่องยาก
    แม้จะมีการพูดเกินไปบ้าง แต่โดยรวมแล้วผมเห็นด้วยกับคำวิจารณ์นั้น
    การทำให้ค่าเริ่มต้นซับซ้อนขึ้นเพียงเพราะดีกว่าสำหรับแอปพลิเคชัน throughput สูง ดูเหมือนจะสวนทางกับอุดมคติของ Rust

    • เคยเขียนบริการแบบนั้นมาแล้ว แต่ผมคงไม่เรียกมันว่าเน้น I/O
      มันไม่ได้ถูกจำกัดด้วย throughput แต่เป็นรูปแบบที่ส่วนใหญ่อยู่ในสถานะ idle แล้วพอมีงานเข้ามาก็พยายามทำให้เสร็จเร็ว ๆ เพื่อลดการใช้ทรัพยากรระบบ
      เว้นแต่ว่าจะมีงานขนาดใหญ่ทะลักเข้ามาเป็นครั้งคราวและ latency สำคัญมากในตอนนั้น การใช้มากกว่าหนึ่งเธรดก็มีแต่เพิ่มความซับซ้อนและ overhead โดยไม่มีประโยชน์
    • ใช่ ไม่ใช่ทุกอย่างที่ต้องการ concurrency จะเป็นเว็บเซิร์ฟเวอร์
      ในระบบปฏิบัติการ system service ทุกตัวต้องจัดการคำขอ IPC พร้อมกันได้ แต่ส่วนใหญ่ทำด้วยเธรดเดียวเพื่อลดการใช้ CPU โดยรวม
      ถ้าทำบริการหลายสิบตัวบนอุปกรณ์ 4 คอร์ให้เป็นเธรดต่อคอร์ ก็เป็นการเปลือง CPU และ RAM
    • tokio รองรับ executor แบบเธรดเดียว ที่ใช้ได้เมื่อจำเป็นจริง ๆ และก็ไม่ยากด้วย
      ใน API ของ tokio เรียกว่า LocalSet
      https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
    • กำลังจะเขียนถึงคำอ้างเดียวกันพอดี
      ต่อให้เธรดเดียวบน CPU เดียวก็เพียงพอแล้ว คุณก็ยังอาจต้องการ concurrency อยู่ดี
    • แทนที่จะใช้ Arc กับ Mutex ก็คงไปใช้ Rc กับ RefCell ซึ่งในแง่โค้ดก็น่าจะซับซ้อนและยืดยาวพอ ๆ กันไม่ใช่หรือ
      เข้าใจว่าประสิทธิภาพต่ำกว่าก็จริง แต่ในกรณีที่อธิบายมา ค่าใช้จ่ายเพิ่มของ atomic operation ไม่กี่ครั้งก็น่าจะเล็กน้อยจนมองข้ามได้อยู่แล้วไม่ใช่หรือ
  • ถ้าพูดถึงข้อความที่อ้างมา ผมเห็นด้วยว่าการพูดว่า “บาปกำเนิดของการเขียนโปรแกรม async ใน Rust คือการทำให้ค่าเริ่มต้นเป็นแบบหลายเธรด… Send + 'static หรือแย่กว่านั้นคือ Send + Sync + 'static ฆ่าความสนุกในการใช้ Rust” ฟังดูดราม่าเกินไป
    ต่อให้เอา Send + Sync ออก ผมก็ไม่คิดว่าจะต่างกันมากขนาดนั้น
    สิ่งที่กวนใจที่สุดคือ 'static และนั่นไม่ได้เป็นเพราะ work stealing
    สิ่งที่ต้องการคือ concurrency แบบมี scope
    เช่นแบบ <https://github.com/tokio-rs/tokio/issues/2596>
    อีกสิ่งที่ตอนนี้ไม่ชอบจริง ๆ ใน Rust async คือ การขาด instrumentation
    ที่บริษัทมีปัญหา production ที่บาง task หยุดนิ่งไปเฉย ๆ และผมอยากทำสิ่งที่เทียบเท่ากับ gdb; thread apply all bt
    อย่างน้อยก็หวังว่า <https://github.com/tokio-rs/tokio/issues/5638> จะถูกรวมเข้ามา
    ตอนนี้ก็มีอยู่แบบ experimental แต่จากประสบการณ์ของผม บางครั้งมัน panic
    วันนี้ผมจะเขียน PR จริง ๆ เพื่อทดลองใช้เวอร์ชันทดลองตอน SIGTERM
    เพราะยังไงก็กำลังปิดโปรแกรมอยู่ จึงตัดสินว่าแม้จะ crash ก็ไม่เป็นไร
    ไม่มีข้อร้องเรียนใดในนี้ที่แก้ได้ด้วยการเอา work stealing ออก
    ต่อให้ยกเพิ่มอีก การลบ work stealing ก็คงแทบไม่ช่วยอะไรเลย

    • Java ถูกวิจารณ์เยอะก็จริง แต่เรื่องนี้มันทำได้ดีมาตั้งแต่ประมาณ 25 ปีก่อน และมีประโยชน์มหาศาลในการแก้ปัญหา
      ไม่ต้องใช้ debugger ด้วยซ้ำ แค่ส่ง SIGQUIT ไปที่ JVM ก็จะ dump stack trace ของทุกเธรดไปยัง stderr แล้วทำงานต่อ
      รวมถึงบอกด้วยว่าแต่ละเธรดถือ lock อะไรอยู่หรือกำลังรออะไร
      ทุกครั้งที่ใช้ภาษาอื่น ผมคิดถึงฟีเจอร์นี้
      ใช้ทำ profiling ชั่วคราวใน production ได้ด้วย
      แค่เก็บ snapshot หลายครั้ง แล้วใช้ grep/sed/sort/uniq หา hotspot ก็พอ
    • เพื่อบรรเทาปัญหาแบบนี้ ที่บริษัทเราใช้ไลบรารีที่ log เป็นระยะว่า task ใดกำลังทำงานอยู่ และแต่ละ task ตอนนี้อยู่ที่ไฟล์/เลขบรรทัดไหน
      ต้องใส่ r.set_location(file!(), line!()); เองก่อนทุกจุด await แต่ก็ช่วยอธิบายได้หลายครั้งว่าทำไมระบบถึงดูเหมือนหยุดนิ่ง
      [1] https://github.com/antialize/tokio-tasks/blob/main/src/run_t... มี set_location() และใน task.rs มี list_tasks()
  • ปัญหานี้ไม่มีคำตอบตายตัว ทุกอย่างขึ้นอยู่กับ กรณีการใช้งาน
    สุดท้ายแล้วเป็นเรื่องของเวิร์กโหลดที่เน้น I/O กับเน้น CPU และสิ่งอย่างการขับแคชออกหรือการแย่ง lock ส่งผลเสียมากแค่ไหน
    ถ้าจะเอา HTTP server ที่คุยกับฐานข้อมูลภายนอกและมี business logic เบา ๆ ตรงกลางไปวางบน shared virtual server การทำ work stealing และการใช้ thread ซ้ำก็ดูสมเหตุสมผลในเชิงสัญชาตญาณ
    แน่นอนว่าต้อง benchmark เสมอ
    ในทางกลับกัน ถ้ากำลังสร้างฐานข้อมูลหรือระบบคล้ายกัน และภายใต้โหลดมี concurrency สูงกับ context switch จำนวนมากจนทำให้เกิด cache eviction และ contention ไปทั่ว ก็จะลำบาก
    ตอนนั้น thread-per-core มีเหตุผลมาก และ async framework เองอาจไม่มีความหมายด้วยซ้ำ
    ไม่มีคำตอบแบบฟันธงว่าอะไร “ดีกว่า”
    ต้อง profile แอปพลิเคชัน
    อย่างที่เคยพูดไปก่อนหน้านี้ รู้สึกว่าโฟกัสของ Rust โดยรวมกำลังถูกบิดเบือนจากการหลั่งไหลเข้ามาอย่างมากของการ พัฒนาเว็บเซอร์วิส
    ยังไม่แน่ใจว่า Rust เป็นภาษาที่เหมาะกับงานแบบนั้นหรือไม่ แต่ดูเหมือนมันทำงานได้ค่อนข้างดีสำหรับพวกเขา ก็ถือว่าเป็นอย่างนั้น
    เพียงแต่การพูดคุยสาธารณะเกี่ยวกับภาษาและ crate ที่ถูกดันขึ้นมาอยู่แถวหน้าตอนนี้สะท้อนอคตินี้โดยรวม
    และก็เป็นอคติของวิศวกรซอฟต์แวร์จำนวนมากในฟอรัมนี้ด้วย

    • ไม่ค่อยเห็นปัญหาที่ async ไปกระทบส่วนอื่นของ Rust เท่าไร
      แอปพลิเคชันระบบอย่าง game engine, ไลบรารีเข้ารหัส, kernel, command-line tool, compiler ฯลฯ ยังถูกสร้างได้สำเร็จโดยไม่ต้องแตะ async
      กำลังดูแล ไลบรารีเข้ารหัส ขนาดใหญ่อยู่ และไม่ได้รับผลกระทบจากงานฝั่ง async เลย
    • เห็นด้วยว่ามีนักพัฒนาเว็บไหลเข้ามา
      ถ้าผลคือทำให้ประโยชน์ของ Rust ด้าน systems programming เสียไป ก็คงน่าเสียดาย
    • คิดว่าเว็บแอปน่าจะเหมาะกับ thread-per-core เสียอีก
      ตัวแอปพลิเคชันเองแทบไม่มีสถานะนอกเหนือจาก request และ socket listener กับ database connection ก็สามารถแบ่งตาม thread ได้
      สถานะที่เหลืออยู่ก็น่าจะค่อนข้างเป็น static ระหว่าง request ส่วนใหญ่ จึงไม่น่าทำให้ cache invalidation เกิดบ่อย
      เมื่อไม่มี shared state การจัดการ ownership ก็ควรง่ายด้วย
    • คิดว่าสิ่งนี้เกิดขึ้นเพราะชุมชน Rust ดึงนักพัฒนากลุ่มนั้นเข้ามาค่อนข้างแข็งขันเพื่อให้ได้ตลาดและการรับรู้
      ไม่ได้จะบอกว่าดีหรือไม่ดี แต่ตอนนี้ Rust ต้องอยู่กับกระแสไม่รู้จบของไลบรารีและเฟรมเวิร์กเกี่ยวกับเว็บที่คุณภาพปะปนกันไป
      และเพราะไลบรารีพื้นฐานหลักกับ crate จำนวนไม่น้อยเลือกแนวทาง async-first async จึงจะยังเป็นหัวข้อถกเถียงต่อไป
      ตอนนี้ถึงขั้นที่นักพัฒนาทั่วไปจะเขียนโค้ด synchronous ธรรมดาเพื่อแก้ปัญหาธุรกิจได้ยาก เว้นแต่จะประกาศเป็นแนวทางหลักของโปรเจกต์ว่าจะไม่ใช้ async
  • การกำหนด ขอบเขต Send เพื่อให้ย้าย task ระหว่าง thread ของ executor ได้ ผมมองว่าเป็นข้อบกพร่องชัดเจนของระบบ async ใน Rust เอง
    เพราะมันขัดขวางการทำ API แบบมี scope ร่วมกับปัญหาพื้นฐานของ async Drop
    เช่นเดียวกับ thread แค่ให้มีขอบเขต Send เฉพาะกับฟังก์ชันอย่าง spawn หรือการส่งข้อมูลผ่าน channel ก็น่าจะเพียงพอแล้ว
    แนวทางไม่แชร์มักเป็นแค่ workaround เพื่อซ่อนข้อบกพร่องนี้มากกว่า
    การตรึง task ไว้กับ thread/core ใดโดยเลือกได้มีข้อดีและในบางสถานการณ์ก็มีประโยชน์จริง ๆ แต่นั่นเป็นประเด็นที่ละเอียดกว่า และไม่ค่อยเกี่ยวกับความไม่พอใจของผู้ใช้ async ที่เกี่ยวกับ Send

  • เป็นบทความที่ดี และแนะนำให้อ่านต่อ อย่าดูแค่ชื่อเรื่อง
    ประโยคที่ชอบที่สุดคือ “ถ้าข้อร้องเรียนใหญ่ที่สุดคือการเพิ่มขอบเขต Send ให้กับ generic บางตัว ก็ยากที่จะเชื่อว่าคนคนนั้นกำลังทำวิศวกรรมในระดับนั้นอยู่”
    แก้ไข: เห็นด้วยอย่างยิ่งกับคอมเมนต์ของ “duped”
    เพราะไม่รู้บริบทที่กว้างกว่าของการถกเถียงนี้ ผมอาจยกประโยคนี้มาเร็วเกินไป

  • ตรงที่ว่า “คนที่มองสถานะของระบบไม่ถูกต้องอาจหาวิธีที่ดีที่สุดในการรับประกันว่า future เป็น Send ได้ยาก” ฟังดูค่อนข้าง หยิ่ง ไปหน่อยไหม
    ปัญหาเรื่อง lifetime 'static และข้อจำกัด Send/Sync เป็นสิ่งที่นักพัฒนารู้สึกได้ทั่วไป และผมไม่ได้รู้สึกว่าพวกเขาโง่

    • นั่นเป็นการอ้างกลับไปถึงสิ่งที่พูดก่อนหน้า
      ผู้คนบอกว่าการไม่ทำ work stealing ง่ายกว่าและเร็วกว่า
      ข้อโต้แย้งของผมคือมันต้องเป็นอย่างใดอย่างหนึ่ง
      ถ้าแนวทางไม่แชร์จะเร็วกว่า ก็ต้องออกแบบโค้ดในแบบที่ไม่ได้ง่ายไปกว่าการทำให้สถาปัตยกรรม shared state ปลอดภัยต่อ thread
      ย่อหน้าถัดไปมีประโยคคู่ขนานที่ตอบกับคำว่า “ช้า”
      ผมไม่ได้คิดว่าคนที่มีปัญหากับการทำให้ Rust แบบ parallel/concurrent compile ผ่านนั้นโง่
      แค่ไม่ชอบเวลาพวกเขาทำเหมือน API ที่เราสร้างให้ทำลายชีวิตพวกเขา
    • ที่ใส่ “ยาก” ไว้ในเครื่องหมายคำพูด เพราะมันไม่จำเป็นต้องยากกว่าเสมอไป
      ถ้าเป็นสิ่งที่ต้องทำอยู่แล้ว มันก็ไม่ได้ยากกว่า
      แทนที่จะเป็น “ผู้คนตอบสนองเกินเหตุกับงานเล็กน้อย” มันใกล้กับว่า async ทำให้ต้องแก้ปัญหาที่สักวันก็ต้องแก้อยู่แล้วให้เร็วขึ้น
      ให้ความรู้สึกคล้าย borrow checker
      บางครั้งมันจำกัดเกินไป แต่บางครั้งก็มี edge case จริง ๆ ที่ไม่ได้พิจารณาเมื่อสมมติว่าทุกอย่างเรียบร้อยดี
    • ส่วนตัวผมเองก็มีปัญหาจริง ๆ เป็นครั้งคราวในการเข้าใจว่าสถานะของผมเป็น Send หรือไม่
      เลยรู้สึกว่า câuที่ยกมานั้นโดนใจ
  • นี่เป็นบทความที่พลาดภาพใหญ่แล้วไปยึดติดกับรายละเอียด
    ไม่มีวิธีที่ถูกเสมอสำหรับการได้ประสิทธิภาพดีที่สุดในทุกโปรแกรม
    จะถกเถียงกันได้ไม่รู้จบ แต่ข้อดีข้อเสียของ thread-per-core เป็นเรื่องแบบ “ขึ้นอยู่กับสถานการณ์” ตามตำรา
    ปัญหาคือการใช้ async เองตั้งแต่แรกเป็น premature optimization
    โปรแกรม Rust 99% ไม่ใช่ redis หรือ linkerd
    แต่เป็น CLI tool หรือเว็บแอปที่เขียนด้วย Python หรือ Ruby ก็เร็วพอแล้ว
    แล้วทำไมชุมชนถึงทิ้ง blocking I/O Rust ไป และทุกอย่างกลายเป็น async จน developer แปะ #[tokio::main] ใส่ทุกอย่างเหมือนเป็นค่าเริ่มต้น

    • เหตุผลหนึ่งอาจเป็นเพราะ ถ้าประสิทธิภาพแบบ thread-per-core ก็เพียงพอแล้ว คุณก็อาจไม่อยากใช้ Rust ตั้งแต่แรก
      มีภาษาที่ให้ประสบการณ์การเขียนโปรแกรมดีกว่าแลกกับความเร็ว ซึ่ง Python ก็เป็นตัวอย่าง
      ถ้าอยากใช้ Rust ก็น่าจะเพราะต้องการประสิทธิภาพเพิ่มเติม และเมื่อยอมรับภาษาที่สะดวกน้อยกว่าไปแล้ว ก็อาจยอมรับสไตล์ที่สะดวกน้อยกว่าเพื่อประสิทธิภาพที่ดีกว่าได้
  • ผมไม่เคยใช้ Rust แต่เข้าใจข้อร้องเรียน
    คงน่าหงุดหงิดถ้าต้องเขียนโค้ดในรูปแบบพิเศษเพื่อให้สถานะถูกย้ายไปมาระหว่างเธรดได้ เพียงเพื่อการกระจายโหลดใหม่ที่ไม่จำเป็น และในสถานการณ์ที่ CPU ยังเหลือเยอะ อาจกลับทำให้ latency แบบ end-to-end ของคำขอเดียวช้าลงด้วยซ้ำ
    ถ้าเป็นแพลตฟอร์มที่สถานะที่ย้ายได้เป็นค่าเริ่มต้นและแทบไม่พัง แนวทางนี้ก็อาจสมเหตุสมผล แต่ใน Rust ดูเหมือนไม่ใช่แบบนั้น
    สิ่งที่อยากรู้คือประสบการณ์การใช้งาน
    อยากรู้ว่ามันใกล้เคียงกับ “ถ้าไม่เพิ่มคาถาวิเศษชื่อ Send โค้ดจะคอมไพล์ไม่ผ่าน” หรือใกล้เคียงกับ “ระหว่าง work stealing สถานะเสียหาย แล้วล้มเหลวแบบเป็นครั้งคราวและดีบักยาก” มากกว่ากัน

    • สมมติว่าเขียนโค้ดประมาณนี้
      มี Server และใน serve อ่านข้อความ จากนั้น spawn(async move { ... }) handler ของแต่ละข้อความเป็น task ใหม่
      ตอนแรกทุกอย่างทำงานได้ดี
      แล้ววันหนึ่งเปลี่ยน implementation ของ do_this จน type ของ this ไม่เป็น Send อีกต่อไป ก็จะเจอ compile error หน้าตาน่ากลัวที่บอกว่า spawn(...) ทำไม่ได้เพราะ type ที่ scope async move { } แบบไม่ระบุชื่อสร้างขึ้นไม่ใช่ Send
      เหตุผลก็ไม่จำเป็นต้องชัดเจน และ error message ก็ไม่ค่อยช่วย
      ถ้า this ไม่ใช่ Send ก็ไม่สามารถถือมันข้าม .await ของ do_that(arg).await ได้
      เพราะ .await แต่ละจุดคือจุดทำงานที่ future อาจ yield และถูก executor schedule ไปยังเธรดอื่นได้
      ถ้าทำให้ type เป็น Send ได้ก็ไม่เป็นไร
      แต่ก็มีหลายกรณีที่ทำไม่ได้ และถ้าเป็นแบบนั้นก็ต้องเปลี่ยนการ schedule future ไปใช้บางอย่างอย่าง spawn_local
      การเรียกสิ่งนี้อาจต้องเพิ่ม boilerplate ไม่น้อย
      นี่คือปัญหาของ Send
      ไม่ใช่แค่เรื่องการเพิ่ม type annotation แต่เพราะไม่ชัดเจนเสมอไปว่า type implement Send หรือไม่ มันจึงค่อย ๆ แทรกซึมอยู่ในโค้ดอย่างละเอียดอ่อน และอาจมาพังทีหลังในแบบที่ไม่ชัดเจน
    • โดยรวมแล้วคอมไพเลอร์จะตรวจจับว่าเป็น Send และ Sync หรือไม่
      ถ้าเขียนโปรแกรมให้ thread-safe ก็จะไม่มีปัญหา
      ประเด็นสำคัญคือแบบนี้
      คนบ่นว่า Tokio async ยากเพราะเรียกร้อง Send และ Sync ไปทั่ว แต่จริง ๆ แล้วการเขียนโค้ด concurrent ที่ปลอดภัย ไม่ว่าจะชนิดไหน ก็ยากอยู่แล้ว
      มันไม่เป็นไปตามสัญชาตญาณ และปัญหาคือ async ทำให้รู้สึกเหมือนมัน “จัดการให้” โดยอัตโนมัติ
      แต่จริง ๆ ไม่ใช่
      คุณต้องรู้ว่ากำลังทำอะไรอยู่ และคอมไพเลอร์ก็แค่ช่วยในจุดนี้
      ถ้าใช้ thread-per-core ในบางสถานการณ์อาจซ่อนความจำเป็นของ Send ได้ แต่ไม่ใช่ทั้งหมด
      และในระยะยาว มันอาจย้อนกลับมากัดคุณในแง่สถาปัตยกรรมได้
    • โค้ดจะคอมไพล์ไม่ผ่านหากทุกอย่างในจุดที่จำเป็นไม่ได้เป็น Send/Sync
      ผมอาจคิดผิด แต่โดยปกติวิธีแบบขี้เกียจเพื่อให้บรรลุสิ่งนี้คือห่อสิ่งที่อาจถูกแชร์ได้ด้วย Arc หรือ Mutex
    • ผมคิดว่าสามารถทำให้เหตุผลที่คัดค้านการใช้ thread-per-core เป็นค่าเริ่มต้นนั้นเรียบง่ายได้
      ถ้าเน้น CPU เป็นหลัก work stealing ก็น่าจะดีกว่าในกรณีส่วนใหญ่
      ถ้าเน้น I/O เป็นหลัก thread-per-core อาจทำงานได้ดีกว่า แต่ยังไงก็มี CPU เหลือพอ ดังนั้นประสิทธิภาพจึงไม่ได้สำคัญมากนัก
      ในความเห็นของผม work stealing เป็นค่าเริ่มต้นที่ดีกว่าสำหรับใส่ไว้ใน API ของภาษา