กลับไปที่บทความ
Rust Performance Backend Systems Programming

Rust เปลี่ยนแปลงมุมมองของฉันเกี่ยวกับประสิทธิภาพ Backend อย่างไร

พลากร วรมงคล
28 ตุลาคม 2567 12 นาที

“หลังจากสร้างบริการ 50k RPS ใน Rust ฉันกลับไปยัง Node.js พร้อมกับแบบจำลองความคิดเกี่ยวกับประสิทธิภาพที่แตกต่างไปเสียทั้งหมด นี่คือเรื่องราวเกี่ยวกับแบบจำลองหน่วยความจำ threading และเหตุผลที่ async runtime ของคุณดีขึ้นและแย่ลงกว่าที่คุณคิด”

ข้อสมมติฐาน 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 บังคับให้เกิดการสนทนา นั่นคือเหตุผลที่มันเปลี่ยนแปลงวิธีที่ฉันคิดเกี่ยวกับประสิทธิภาพ

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง