- เปรียบเทียบการใช้หน่วยความจำระหว่าง asynchronous และ multithread ในภาษา Rust, Go, Java, C#, Python, Node.js, Elixir
- เขียนโปรแกรมในแต่ละภาษาเพื่อรัน task จำนวน N งานที่รอ 10 วินาที (โดยอาศัยความช่วยเหลือจาก ChatGPT)
- ทดสอบบน Xeon E3 + Ubuntu 22.04
ผลลัพธ์
- footprint ขั้นต่ำ (ทดสอบด้วย task เดียว): Go และ Rust ต้องการไม่ถึง 3MB, Python 17MB, Java/Node.js ราว 40MB, C# 131MB
- 10,000 tasks: Rust Tokio 4.6MB, Rust async-std 8MB, Go 28.6MB, Python 40MB, Rust Threads 48MB, Node.js 48MB, Java Virtual Thread 78MB, Elixir 99MB, C# 131MB, Java Threads 244MB
- 100,000 tasks (ไม่รวม threads): Rust tokio 23MB, Rust Async-std 54MB, Node.js 112MB, C# 130MB, Java virtual threads 223 MB, Python 240MB, Go 269MB, Elixir 445MB
- 1,000,000 tasks: Rust Tokio 213MB, C# 461MB, Node.js 494MB, Rust async-std 527MB, Java virtual thread 1154MB, Python 2232MB, Go 2658MB, Elixir 4009MB
บทสรุป
- Rust tokio โดดเด่นเหนือคู่แข่งอย่างชัดเจน
- C# แม้จะมี footprint ใหญ่ แต่ก็แข่งขันได้มาก (บางครั้งถึงขั้นชนะ Rust)
- เมื่อขยับไปถึง 1 ล้านงาน Go ทิ้งระยะห่างจาก Java virtual threads มากขึ้น (ล้มความเชื่อทั่วไปที่ว่า Go เบากว่า JVM)
- การทดสอบนี้ดูเฉพาะการใช้หน่วยความจำเท่านั้น ยังไม่ได้พิจารณาปัจจัยอื่น
- ที่ระดับ 1 ล้าน tasks ค่า overhead ในการเริ่มงานจะสูงขึ้น และโค้ดส่วนใหญ่ใช้เวลามากกว่า 12 วินาทีจึงจะเสร็จ
- มีแผนจะรัน benchmark อื่นเพิ่มเติม
9 ความคิดเห็น
นี่เป็นเบนช์มาร์กที่มีความหมายไม่น้อยเลยสำหรับคนที่ใช้ Go แล้วก็ยังคอยเหลือบมอง Rust อยู่เรื่อย ๆ พร้อมกับคิดว่า จำเป็นไหมที่จะต้องปรับตัวเข้ากับไวยากรณ์อันเข้มงวดนี้ หากในสถานการณ์ที่ Go จะล้มเพราะ OOM แต่ถ้าเป็น Rust กลับยังรับมือได้ดี.... ก็นับว่าคุ้มค่าพอที่จะลงทุนแล้วนะครับ
แน่นอนว่า ปัญหาที่ยังคงมีอยู่ก็คือ การหานักพัฒนา Rust นั้นยากกว่ามาก...
เป็นความจริงที่ว่า Go มีโครงสร้างที่มีการจัดสรรสแตก (2KB) ให้กับแต่ละ goroutine แยกกัน ทำให้การใช้หน่วยความจำเพิ่มขึ้นแบบ O(n) ทีละตัว ดังนั้นยิ่งจำนวนเธรดมากขึ้นก็ยิ่งเสียเปรียบ....
สิ่งที่ทำให้สงสัยเล็กน้อยคือ สถานการณ์ที่มีเธรดเกิน 10,000 ตัวจะเกิดขึ้นบ่อยแค่ไหนกันนะ ดูเหมือนว่าการสลับคอนเท็กซ์น่าจะเกิดบ่อยกว่าการรันโค้ดจริงเสียอีก....
สงสัยว่าถ้าเป็น Kotlin coroutines จะเป็นอย่างไรนะ
ผลลัพธ์ของ Elixir น่าประหลาดใจที่สุดเลยนะครับ ผมเคยเข้าใจว่า Erlang ใช้หน่วยความจำเบามากในระดับเพียงไม่กี่ร้อยเวิร์ด จนเบากว่า Go เสียอีก...
จากที่ไปดู เอกสารทางการของ Erlang พบว่าการสปอว์น Erlang process หนึ่งตัวต้องใช้ 338 เวิร์ด และในระบบ 64 บิต 1 เวิร์ดมีขนาด 8 ไบต์ ดังนั้น Erlang process หนึ่งตัวก็น่าจะกินหน่วยความจำประมาณ 2.7KB (338 × 8 = 2,704) ส่วนในภาษา Go นั้น ขนาดสแตกของ goroutine หนึ่งตัว อยู่ที่ประมาณ 2.0KB จึงดูเหมือนว่าฝั่ง Erlang จะใช้หน่วยความจำมากกว่า
ถ้าอย่างนั้นคำนวณแบบตรงไปตรงมา Erlang process จำนวน 1 ล้านตัวก็ควรใช้หน่วยความจำ 2.7GB แต่ใน Elixir benchmark ที่แนะนำไว้ข้างต้นกลับสังเกตเห็นการใช้หน่วยความจำสูงสุดประมาณ 4.0GB เท่ากับว่ามีการใช้หน่วยความจำเพิ่มอีก 1.3GB ถ้าคิดแบบง่าย ๆ ก็หมายความว่าในสถานการณ์นี้ Erlang process หนึ่งตัวใช้หน่วยความจำเพิ่มอีกราว 1.3KB ผมเองก็ไม่แน่ใจนัก แต่ก็อดคิดไม่ได้ว่าเมื่อจำนวน Erlang process เพิ่มขึ้นเกินระดับหนึ่งแล้ว runtime อาจจำเป็นต้องใช้พื้นที่หน่วยความจำเพิ่มเติมบางอย่างหรือไม่
ผมเดาว่าอาจเป็นเพราะการกันความจุไว้ให้กับ supervision tree map หรือ message queue ก็ได้ครับ
ผมคิดว่าภาษา Rust เป็นภาษาที่ยอดเยี่ยมจริง ๆ ตั้งแต่แนวคิดการออกแบบไปจนถึงประสิทธิภาพ
การเปรียบเทียบระหว่างแนวทางแบบอะซิงโครนัสกับแบบเธรด รวมถึงเบนช์มาร์กที่พ่วงเอาภาษาและรันไทม์ต่าง ๆ เข้ามาด้วย อาจตีความได้ต่างกันไปตามมุมมอง ดังนั้นโปรดใช้เป็นข้อมูลอ้างอิงประกอบนะครับ
ลองอ่านคอมเมนต์ใน HN ไปด้วยได้ครับ https://news.ycombinator.com/item?id=36024209