ข้อสมมติฐาน Node.js
มาหลายปีแล้ว ฉันทำงานภายใต้สมมติฐานที่สะดวก: JavaScript ช้าเกินไปสำหรับงานประสิทธิภาพที่แท้จริง คุณใช้มันสำหรับ APIs และเซิร์ฟเวอร์เว็บ ยอมรับว่าคุณจะต้องมีเลเยอร์แคชและ CDNs แล้วไปต่อ Python, Ruby ก็เหมือนกัน ประสิทธิภาพที่แท้จริงหมายถึง C++ บางครั้งก็ Go หากคุณรู้สึกใจดี
จากนั้นฉันก็ใช้เวลาหกเดือนสร้าง message broker ใน Rust และทุกอย่างก็เปลี่ยนไป
เรียนรู้แบบจำลองหน่วยความจำของ Rust
ความเสียดทางของการเรียน Rust ไม่ใช่ borrow checker เป็นการรีเซ็ตทางจิตสำนึก ใน JavaScript หน่วยความจำคือปัญหาของคนอื่น (GC) ใน Rust มันคือของคุณ ตั้งแต่เริ่มต้น นั่นบังคับให้คุณคิดเกี่ยวกับ:
- ข้อมูลนี้อาศัยอยู่ที่ไหน?
- มันต้องอยู่นานเท่าไหร่?
- ใครมีความรับผิดชอบในการเอาออกมา?
คำถามเหล่านี้ฟังดูวิชาการจนกว่าคุณจะมีความรับผิดชอบในการจัดการกับคำขอ 50,000 ต่อวินาที พร้อมด้วย latency ต่ำกว่าหนึ่งมิลลิวินาที ทันใดนั้นพวกมันก็กลายเป็นคำถามเกี่ยวกับการอยู่รอด
Zero-Copy Architecture
ความเข้าใจที่ยิ่งใหญ่ที่สุดจาก Rust: ส่วนใหญ่ของโอเวอร์เฮด allocation ในบริการ backend มาจากการคัดลอกข้อมูล
ใน Node.js เมื่อคุณอ่านข้อความจากซ็อกเก็ต แยก JSON สกัดฟิลด์ และส่งกลับ คุณอาจสร้าง allocation กลาง 5-10 ตัว GC จะทำความสะอาด แต่ “ในไม่ช้า” หมายถึง 50-200 มิลลิวินาทีในสถานการณ์ throughput สูง นั่นคือเมื่อคุณกระทบการหยุดชั่วคราว GC
ใน Rust คุณเขียนเพียงครั้งเดียว ข้อมูลไหลผ่านแอปพลิเคชันของคุณเป็น references การแยก JSON ไม่คัดลอก มันใส่คำอธิบายประกอบ เมื่อข้อความถึง handler คุณ ไม่มี allocations เกิดขึ้น (ยกเว้น socket buffer เริ่มต้น ซึ่งคุณนำมาใช้ใหม่)
ถึงเวลาเปรียบเทียบ: message broker นี้จัดการโหลดเดียวกันที่จะส่ง Node.js service ไป 90%+ CPU usage พร้อมพยายามอยู่ต่ำกว่า 15% CPU ใน Rust ตรรกะเดียวกัน Runtime ต่างกัน
Threading vs. Async Runtime
นี่คือที่ที่ฉันเปลี่ยนแปลงความคิดของฉันมากที่สุด ก่อนหน้านี้ฉันเชื่อว่า async/await เร็วกว่า threads โดยธรรมชาติ Threads context-switch spawn overhead ฯลฯ ทั้งหมดนี้เป็นจริง
แต่ฉันคิดผิดเกี่ยวกับข้อสรุป
Async runtime ของ Rust (Tokio) น่าทึ่ง แต่มันไม่ได้สร้าง parallelism ด้วยวิธีอวดมหัศจรรย์ ด้วย async คุณยังคงอยู่บน CPU core เดียว แค่สลับบริบทเร็วขึ้นเท่านั้น Parallelism ที่แท้จริงต้องการ threads ที่เป็นจริง
สำหรับ message broker ฉันใช้:
- Tokio async สำหรับ I/O: การยอมรับการเชื่อมต่อ อ่านจากเครือข่าย
- Native threads สำหรับงาน CPU-bound: แยก validation ตรรกะธุรกิจ
นี่ใกล้เคียงกับเหลวไหล (heresy) ในโลก Node.js ที่แอปพลิเคชันทั้งหมดคือ JavaScript context เดียว แต่มันได้ผล เครื่อง 16-core สามารถทำ 16 สิ่งในขนานจริง ไม่ใช่ 16 ภาพลวงตา context-switch
// Tokio for I/O, threads for CPU work
let listener = TcpListener::bind("127.0.0.1:8080").await?;
loop {
let (socket, _) = listener.accept().await?;
tokio::spawn(async move {
let mut buf = vec![0; 1024];
loop {
let n = socket.read(&mut buf).await?;
// Hand off to thread pool for processing
let result = tokio::task::spawn_blocking(move || {
process_message(&buf[..n])
}).await?;
// Return result
}
});
}
การเปิดเผยความลับของ Garbage Collection
ผู้ร้ายประสิทธิภาพที่แท้จริงไม่ใช่ allocation มันคือความไม่สามารถคาดเดาได้ บริการ Node.js อาจทำงานที่ latency 50ms เป็นเวลา 10 วินาที จากนั้นก็กระโดดขึ้นไป 2 วินาทีเมื่อ GC ทำงาน นั่นสำคัญสำหรับ API เว็บ มันยอมรับไม่ได้สำหรับ message broker ที่ให้บริการคำขอ 50k ต่อวินาที
Rust ขจัดสิ่งนี้ GC เป็นศูนย์ คุณรู้แน่ชัดว่าเมื่อ allocations เกิดขึ้น และคุณสามารถโครงสร้างโค้ดเพื่อ allocate ในระหว่างเฟส cold (startup การเตรียมการเชื่อมต่อ) และ stay allocation-free ในระหว่างเฟส hot (request loop)
ฉันกลับมา Node.js projects และ profiled พวกมันทันทีกับมุมมองใหม่นี้ สิ่งที่มี latency profiles ความไม่สามารถคาดเดาได้อย่างป่าเถื่อนมักจะมีสิ่งหนึ่งที่เหมือนกัน: พวกมันสร้าง garbage ในระดับ peak throughput
เมื่อ Rust มากเกินไป
นี่ไม่ใช่บทความ “เขียนทุกอย่างใหม่ใน Rust” สำหรับส่วนใหญ่ของบริการ Node.js ยังคงเป็นตัวเลือกที่ถูกต้อง:
- เวลา Startup สำคัญมากกว่าประสิทธิภาพ steady-state
- คุณ I/O-bound ไม่ใช่ CPU-bound
- เวลา time-to-market ชนะลักษณะ runtime ที่ดีที่สุด
- ทีมของคุณรู้ Node.js อยู่แล้ว
HTTP API ที่ไม่มีสถานะ? ใช้ Node.js Message broker ที่เสี้ยววินาทีของ latency คือเงิน? ใช้ Rust
การเปลี่ยนแปลงมানสิกภาพ
ความสำคัญที่แท้จริงไม่ใช่ “Rust เร็ว” มันคือความเข้าใจ memory allocation บังคับให้ฉันคิดถึงประสิทธิภาพใหม่ทั้งหมด ตอนนี้ฉันถามคำถามที่ต่างกันในโค้ด Node.js:
- Hot path นี้สร้าง allocations กี่อัน?
- ฉันสามารถนำ buffers กลับมาใช้ใหม่แทนการสร้างตัวใหม่ได้หรือไม่?
- GC pause time ของฉันสามารถคาดเดาได้หรือไม่?
คำถามเหล่านี้คือ backend engineer ทุกคนควรถามขอ โดยไม่คำนึงถึงภาษา Rust บังคับให้เกิดการสนทนา นั่นคือเหตุผลที่มันเปลี่ยนแปลงวิธีที่ฉันคิดเกี่ยวกับประสิทธิภาพ