• การเปลี่ยนจาก Go ไปสู่ Rust ใกล้เคียงกับการเลือกย้ายปัญหาอย่าง nil, การจัดการข้อผิดพลาด, data race และอายุการใช้งานของทรัพยากร ไปอยู่ภายใต้การรับประกันตั้งแต่คอมไพล์ไทม์ มากกว่าจะเป็นเรื่องเพิ่มความเร็วเพียงอย่างเดียว
  • Go มีจุดเด่นเรื่องคอมไพล์เร็ว, goroutine ที่เรียบง่าย และ ecosystem ฝั่งแบ็กเอนด์ที่แข็งแกร่ง แต่ Rust ใช้ Option, Result, Send/Sync เพื่อป้องกันความผิดพลาดได้มากขึ้นในระดับ type system
  • ตัวตรวจสอบการยืม ของ Rust และ async/await สร้างต้นทุนด้านเส้นโค้งการเรียนรู้และการใช้งาน และเวลาในการคอมไพล์ก็ต้องยอมรับว่าแย่ลงจาก Go อย่างชัดเจน
  • การเปลี่ยนผ่านเหมาะกับการเริ่มจากคอมโพเนนต์ที่มีขอบเขตชัดเจน เช่น บริการ hot path, worker หรือบาง endpoint ที่อยู่หลัง gateway มากกว่าการเขียนใหม่ทั้งหมด
  • ผลลัพธ์ที่คาดหวังสรุปได้ว่า CPU ลดลง 20~60%, หน่วยความจำลดลง 30~50%, ค่า P99 latency เรียบขึ้น และปัญหา nil dereference กับ race-related failure ลดลง

จุดเน้นของการเปลี่ยนผ่าน

  • การเปลี่ยนจาก Go ไปสู่ Rust เป็นเรื่องของการชั่งน้ำหนัก การรับประกันความถูกต้อง, trade-off ของ runtime และความแตกต่างด้านประสบการณ์นักพัฒนา มากกว่าคำถามว่า “Rust เร็วกว่าไหม”
  • จุดศูนย์กลางของการเปรียบเทียบคือ บริการแบ็กเอนด์ โดยอิงจากจุดแข็งของ Go ในด้าน static binary ขนาดเล็ก, standard library ที่เน้นงานเครือข่าย, และ ecosystem ของ HTTP server, gRPC และฐานข้อมูล
  • เนื้อหาบางส่วนอาจใช้ได้กับเครื่องมือ CLI, embedded firmware และ game engine แต่ไม่ใช่กลุ่มเป้าหมายที่ถูกปรับให้เหมาะที่สุด
  • เอกสารพื้นหลังที่เกี่ยวข้องมี “Go vs Rust? Choose Go.” จากปี 2017 และ “Rust vs Go: A Hands-On Comparison” จากทีม Shuttle
  • Go เป็นภาษาที่ประสบความสำเร็จ แต่การใช้ nil อย่างกว้างขวาง, การจัดการข้อผิดพลาดที่พึ่งพาวินัยมากกว่าระบบชนิดข้อมูล และตัวเลือกด้านการออกแบบอย่างการไม่มี generics อยู่เป็นเวลานาน ล้วนกลายเป็นประเด็นสำคัญเมื่อเทียบกับ Rust
  • ใน JetBrains Developer Ecosystem Survey นั้น Go ถูกจัดเป็นภาษาที่มีสัดส่วนนักพัฒนาที่ใช้งานจริงอยู่ที่ราว 17~19% ขณะที่ Rust เติบโตอย่างต่อเนื่องแต่ยังมีสัดส่วนเล็กกว่า

ชุดเครื่องมือ

  • ทั้ง Go และ Rust ต่างมี ชุดเครื่องมือแบบแบตเตอรี่มาครบ ที่ให้ build, test, format, lint และจัดการ dependency ผ่านอินเทอร์เฟซที่สม่ำเสมอ
  • cargo ให้ความสามารถที่สอดคล้องกับเครื่องมือของ Go ได้กว้างกว่าในฐานะเครื่องมือหลัก
    • go.mod / go.sumCargo.toml / Cargo.lock: การตั้งค่าโปรเจกต์และ manifest ของ dependency
    • go get / go mod tidycargo add / cargo update: เพิ่มและ resolve dependency
    • go buildcargo build: คอมไพล์
    • go run .cargo run: รันหลัง build
    • go test ./...cargo test: ทดสอบ
    • go vet ./...cargo clippy: linter โดย Clippy มีแนวทางที่ชัดเจนกว่า vet มาก
    • gofmt / goimportscargo fmt: auto formatter แบบไม่ต้องตั้งค่า
    • golangci-lint runcargo clippy -- -D warnings: โหมด lint แบบเข้มงวด
    • go doccargo doc --open: สร้างและเปิดดูเอกสาร API
    • pprofcargo flamegraph / samply: โปรไฟล์ CPU
    • govulncheckcargo audit: ตรวจช่องโหว่จากฐานข้อมูล advisory
  • ในฝั่ง Go มักต้องใช้เครื่องมือจาก third party อย่าง golangci-lint, mockgen, air, goreleaser เพื่ออุดช่องว่าง แต่ Rust มี ecosystem หลักที่ครอบคลุมความสามารถได้มากกว่าเป็นค่าเริ่มต้น
  • แม้ต้องใช้ external crate ก็ยังติดตั้งได้ด้วยคำสั่งเดียวอย่าง cargo install cargo-nextest สำหรับ cargo watch, cargo nextest และใช้งานได้เหมือนเป็นเครื่องมือเนทีฟ เช่น cargo nextest
  • gofmt และ rustfmt มีข้อดีสำคัญกว่าความชอบด้านสไตล์ย่อย ๆ คือช่วยลบข้อถกเถียงเรื่องสไตล์ออกจาก code review
    • คำกล่าวจาก Go Proverbs ของ Rob Pike: “Gofmt’s style is no one’s favorite, yet gofmt is everyone’s favorite.”

ความแตกต่างหลักระหว่าง Go และ Rust

  • ทั้งสองภาษาเป็นภาษาแบบคอมไพล์, static type, deploy เป็นไบนารีเดี่ยวได้ และมีโมเดล concurrency ที่แข็งแรง แต่ความต่างอยู่ที่ ขอบเขตที่คอมไพเลอร์รับประกันได้ และระดับการควบคุมพฤติกรรมของ runtime
  • หัวข้อเปรียบเทียบหลักมีดังนี้
    • รุ่นเสถียร: Go ปี 2012, Rust ปี 2015
    • type system: Go เป็น static/structural type และรองรับ generics ตั้งแต่ 1.18, ส่วน Rust เป็น static/nominal type และรองรับ generics, trait และ lifetime
    • การจัดการหน่วยความจำ: Go ใช้ garbage collection แบบ concurrent/low-latency, Rust ใช้ ownership และ borrowing โดยไม่มี GC
    • ความปลอดภัยจาก null: Go มี nil อย่างแพร่หลาย, Rust ไม่มี null และใช้ Option<T> แทนในระดับ type
    • การจัดการข้อผิดพลาด: Go ใช้ interface error และ if err != nil { ... }, Rust ใช้ Result<T, E>, ตัวดำเนินการ ? และ pattern matching แบบสมบูรณ์
    • concurrency: Go ใช้ goroutine และ channel ตามแนว CSP, Rust ใช้ async/await, channel และ thread บน tokio
    • การยกเลิก: Go ใช้ context.Context ตามธรรมเนียมปฏิบัติ, Rust ใช้การส่งผ่านแบบชัดเจนและตรวจสอบชนิดได้ เช่น CancellationToken
    • data race: Go ตรวจพบเชิงความน่าจะเป็นตอนรันไทม์ด้วย -race, Rust ตรวจพบตั้งแต่คอมไพล์ไทม์ด้วย Send/Sync
    • เวลาในการคอมไพล์: Go เร็วมาก, Rust ช้าโดยเฉพาะการ build แบบ clean
    • runtime: Go มี runtime ราว 2MB และมี GC, Rust ไม่มี runtime นอกจาก libc หรือจะ build แบบ static ทั้งหมดด้วย MUSL ก็ได้
    • ขนาด ecosystem: Go มีราว 750,000+ โมดูล, Rust มี 250,000+ crate
  • สิ่งที่ใน Go ต้องพึ่งพาธรรมเนียมปฏิบัติ, เครื่องมือ และการตรวจจับตอนรันไทม์ เช่น การจัดการ nil, การส่งต่อข้อผิดพลาด, data race, อายุการใช้งานทรัพยากร, การยกเลิก และ generics นั้น ใน Rust จะถูกดึงเข้าไปอยู่ใน type system
  • Mutex<T> ของ Rust บังคับให้เข้าถึงค่าภายในได้ผ่าน guard ที่ได้จาก .lock() เท่านั้น ทำให้เส้นทางที่ “ลืมล็อก” ถูกลบออกไปตั้งแต่ระดับ type
  • รูปแบบเดียวกันนี้เกิดซ้ำใน Option, Result, &mut T, Send/Sync และ RAII guard โดยเมื่อคุ้นเคยแล้ว คอมไพเลอร์จะกลายเป็นตัวช่วยตรวจเช็กแทนสิ่งที่เคยต้องจำเองในหัว

ข้อจำกัดของ Go ที่ทำให้ต้องพิจารณา Rust

  • เนื่องจาก Go เร็วเพียงพอสำหรับเวิร์กโหลดฝั่งแบ็กเอนด์ส่วนใหญ่ เหตุผลหลักในการพิจารณา Rust จึงใกล้เคียงกับเรื่อง ความเยิ่นเย้อของการจัดการข้อผิดพลาด, ความเสี่ยงจากพอยน์เตอร์ nil, และการขาดความสามารถของระบบชนิดข้อมูลที่ประณีตอย่าง enum·trait มากกว่าเรื่องความเร็ว
  • อินเทอร์เฟซของ Go ไม่ได้ทดแทน trait ของ Rust ได้อย่างเพียงพอ และไลบรารีมาตรฐานก็ไม่มีชนิด Set ทำให้ต้องใช้ทางอ้อมเชิงสำนวนอย่าง map[T]struct{}
  • nil panic ในโปรดักชัน

    • บริการ Go อาจทำงานปกติเป็นเวลาหลายเดือน แต่เมื่อถึงเส้นทางโค้ดบางกรณีก็อาจพลาดการตรวจสอบพอยน์เตอร์ nil จนทำให้เกิด goroutine panic ได้
    • ในตัวอย่าง Find คืนค่า (*User, error) และในกรณี “not found” ค่า error เป็น nil แต่ยังปล่อยให้ผู้เรียกต้องตรวจสอบ user เอง
    • user.Account.Notify() อาจทำให้แครชได้เมื่อ user หรือ Account เป็น nil
    • ลินเตอร์และการตรวจสอบจาก IDE อย่าง nilaway, staticcheck จับได้บางส่วน แต่เป็นแบบ opt-in, มีลักษณะเชิงความน่าจะเป็น และข้ามขอบเขตแพ็กเกจได้ไม่เสถียร
    • Option<T> ของ Rust ทำให้ไม่สามารถ dereference ได้หากยังไม่จัดการกรณี None จึงกำจัดปัญหากลุ่มนี้ไปได้
  • ดาต้าเรซที่ -race จับไม่ได้

    • go test -race เป็นเครื่องมือที่ยอดเยี่ยม แต่เพราะเป็นตัวตรวจจับตอนรันไทม์ จึงค้นพบได้เฉพาะเรซที่เกิดขึ้นจริงระหว่างการทดสอบ
    • ใน Go โค้ดที่มี goroutine สองตัวแก้ไข map โดยไม่มีล็อกก็ยังคอมไพล์ผ่าน และอาจระเบิดในโปรดักชันเมื่อมีโหลดสูง
    • ใน Rust การแชร์สถานะที่แก้ไขได้ข้ามเธรดต้องใช้ชนิดที่ implement Send และ Sync และหากพยายามแชร์ HashMap ปกติข้ามเธรดก็จะคอมไพล์ไม่ผ่าน
    • จึงถูกบังคับให้ใช้ Arc<Mutex<...>>, Arc<RwLock<...>> หรือช่องทางอย่าง channel อย่างใดอย่างหนึ่ง และทำให้ race condition กลายเป็นข้อผิดพลาดระดับชนิดข้อมูล
    • Paul Dix กล่าวถึงการกำจัดดาต้าเรซโดยตรงว่าเป็นแรงจูงใจในการเขียน InfluxDB 3.0 ใหม่
      • “[The main benefit is] fearless concurrency — eliminating data races essentially, which we had before. Really gnarly bugs in version 1 of Influx due to that.”
      • ที่มา: Paul Dix, Founder & CTO, InfluxData, Rust in Production
  • การจัดการข้อผิดพลาดที่ประกอบต่อกันได้

    • if err != nil { return err } ของ Go อาจทำให้ตรรกะหลักของฟังก์ชันพร่ามัวลง และการห่อบริบทด้วย fmt.Errorf("doing X: %w", err) ก็อาศัยวินัยมากกว่ากฎที่คอมไพเลอร์บังคับ
    • ใน เธรด Lobste.rs นักพัฒนา Go ที่ชำนาญโต้แย้งว่า errcheck และ golangci-lint จับการพลาดจัดการข้อผิดพลาดส่วนใหญ่ได้ และ if err != nil ที่ชัดเจนก็อ่านง่ายกว่าการต่อ ? แบบหนาแน่น
    • Peter Bourgon เสนอว่าการจัดการข้อผิดพลาดแบบชัดเจนของ Go เป็นคุณค่าทางวัฒนธรรมที่ตั้งใจไว้
      • “I think that error handling should be explicit, this should be a core value of the language.”
      • ที่มา: Peter Bourgon, GoTime #91, อ้างถึงใน Zen of Go ของ Dave Cheney
    • Result<T, E> ของ Rust เป็นส่วนหนึ่งของ type signature เอง จึงไม่มีทางลืมได้ และผ่าน enum ที่นิยามด้วย thiserror::Error กับ #[from] ก็จะได้ทั้งการแปลงข้อผิดพลาดและการตรวจสอบความครบถ้วน
    • เมื่อเพิ่ม error variant ใหม่ คอมไพเลอร์จะบอกตำแหน่ง match ที่ต้องอัปเดต
  • เจเนอริกที่ไม่ต้อง boxing

    • เจเนอริกของ Go 1.18 มีประโยชน์ แต่ก็มีข้อจำกัดอย่างการไม่มีเมธอดที่มี type parameter, GC shape stenciling และคุณลักษณะด้านประสิทธิภาพที่บางครั้งชวนแปลกใจ
    • เจเนอริกของ Rust ใช้การ monomorphization ทำให้แต่ละการอินสแตนซ์สร้างโค้ดที่ปรับให้เหมาะเฉพาะและไม่มีต้นทุนตอนรันไทม์
    • เมื่อใช้ร่วมกับ trait ก็ทำให้เกิด zero-cost abstraction ได้
    • เรื่องนี้สำคัญกับโครงสร้างพื้นฐานที่ใช้ร่วมกันอย่าง middleware, generic repository, decoder, parser มากกว่ากับโค้ด handler และในพื้นที่เหล่านี้ Go มักย้อนกลับไปใช้ interface{}/any และ type assertion
  • เวลาแฝงที่คาดการณ์ได้

    • GC ของ Go ยอดเยี่ยม เป็นแบบ concurrent, low-latency และปรับแต่งมาดีสำหรับเวิร์กโหลดบริการทั่วไป แต่ “low-pause” ไม่ได้แปลว่า “no-pause”
    • ในสถานการณ์ที่มีการจัดสรรหน่วยความจำมาก tail latency ระดับ P99 อาจแย่กว่าการ implement ด้วย Rust ที่ไม่จัดสรรใน hot path
    • ในระบบที่ไวต่อเวลาแฝงอย่างการเทรด, real-time bidding, network proxy, งานเก็บข้อมูลปริมาณสูง การไม่มี GC pause เป็นข้อได้เปรียบจริง
    • Stephen Blum กล่าวว่าเพื่อให้ได้ขีดความสามารถด้านประสิทธิภาพต่อราคาที่จำเป็นในสเกลระดับ PubNub จำเป็นต้องใช้ Rust
      • “Go is great at our scale, but we really need something that is going to give us the price-per-dollar performance capacity that we need, and Rust is going to get us there. That’s why basically everything is heading towards Rust these days.”
      • ที่มา: Stephen Blum, CTO, PubNub, Rust in Production

รูปแบบ Go ที่เทียบเคียงได้ใน Rust

  • วิธีที่ทำให้คุ้นเคยกับ Rust ได้เร็วที่สุดคือจับคู่รูปแบบ Go ที่คุณรู้อยู่แล้วกับรูปแบบคู่เทียบใน Rust
  • มีตัวอย่างที่ยาวกว่านี้ซึ่งอิมพลีเมนต์บริการแบ็กเอนด์เดียวกันด้วยทั้งสองภาษาใน Shuttle comparison
  • การจัดการข้อผิดพลาด: if err != nil vs Result<T, E>

    • ใน Go จะใช้ os.ReadFile(path) และ json.Unmarshal แล้วคืนข้อผิดพลาดที่ห่อบริบทด้วย if err != nil
    • ใน Rust จะประกอบด้วย fs::read_to_string(path)?, serde_json::from_str(&data)?, Ok(cfg)
    • ตัวดำเนินการ ? ใช้แทนแพตเทิร์น if err != nil { return err } และยังจัดการแปลงชนิดให้ด้วยหากมีการอิมพลีเมนต์ From<E1> for E2
    • #[from] ของ thiserror รองรับการแปลงนี้แบบที่เป็นสำนวนใช้งานทั่วไป
  • null: nil vs Option<T>

    • GetUser(id string) *User ของ Go จะคืน nil หากไม่พบผู้ใช้ และถ้าฝั่งเรียกใช้ fmt.Println(u.Name) ก็จะ panic เมื่อเป็น nil
    • get_user(id: &str) -> Option<User> ของ Rust จะคืน Some(User) หรือ None
    • let user = get_user("123"); println!("{}", user.name); จะเกิดข้อผิดพลาดตอนคอมไพล์ เพราะ user ไม่ใช่ User แต่เป็น Option<User>
    • ต้องจัดการทั้ง Some(u) และ None ด้วย match get_user("123")
    • Rust แบบปลอดภัยไม่มี nil และ reference ก็ไม่สามารถเป็น null ได้
  • interface vs trait

    • interface ของ Go เป็นแบบ structural และ type จะสอดคล้องกับ interface โดยปริยาย
    • trait ของ Rust เป็นแบบ nominal และต้องอิมพลีเมนต์อย่างชัดเจน
    • วิธีของ Go เหมาะกับ duck typing แบบเฉพาะหน้า ส่วนวิธีของ Rust เหมาะกับการรีแฟกเตอร์และ discoverability และสามารถ grep หาอิมพลีเมนต์ของ trait ที่ต้องการได้
    • generic function ที่มี trait bound อย่าง fn handle<R: Reader>(r: R) ครอบคลุมกรณีส่วนใหญ่ได้ และไม่มี runtime dispatch เพราะมีการ monomorphization
    • หากต้องเก็บอิมพลีเมนต์ที่ต่างชนิดกันและต้องใช้ runtime dispatch ให้ใช้ Box<dyn Trait> หรือ Arc<dyn Trait>
  • Goroutine vs async task

    • โมเดล concurrency ของ Go เรียบง่าย เช่น go doWork(ctx, input) โดย goroutine มีต้นทุนต่ำ และรันไทม์จะจัดตารางมันบน OS thread
    • จุดเด่นสำคัญของ Go คือ ไม่มีความแตกต่างทางไวยากรณ์ระหว่างโค้ดแบบลำดับกับโค้ดแบบขนาน
    • Rust แทบจะใช้ async/await บน executor ของ tokio เกือบตลอดในบริการแบ็กเอนด์
    • ฟังก์ชัน async จะคืน Future และจะไม่เริ่มทำงานจนกว่าจะถูก await หรือ spawn
    • คอมไพเลอร์จะติดตาม Send/Sync ก่อนและหลังจุด .await และจะรายงานข้อผิดพลาดตอนคอมไพล์หากมีการเก็บค่าที่เป็น non-Send ข้ามจุด await
    • เนื่องจากไม่มีการ preempt แบบฝังในตัวสไตล์ goroutine หากรันงานแบบ CPU-bound นานเกินไปภายใน async task ตัว executor อาจอดงานได้ จึงควรโยนไปที่ tokio::task::spawn_blocking หรือ rayon
  • context.Context vs CancellationToken

    • ใน Go จะส่ง context.Context ให้กับทุก blocking call
    • Rust ไม่มี context.Context ในตัว และสิ่งที่ใกล้เคียงที่สุดสำหรับการยกเลิกคือ tokio_util::sync::CancellationToken
    • timeout ทำได้โดยห่อ future ด้วย tokio::time::timeout(dur, fut)
    • deadline และ value มักถูกส่งผ่าน argument แบบชัดเจนหรือผ่าน tracing span มากกว่าจะรวมไว้ใน context object เดียว
    • คำกล่าวของ Dave Cheney ใน The Zen of Go:
      • “Go doesn’t have a way to tell a goroutine to exit. There is no stop or kill function, for good reason. If we cannot command a goroutine to stop, we must instead ask it, politely.”
    • ใน Go การ “ขออย่างสุภาพ” นี้มักส่งต่อผ่าน context.Context ตามธรรมเนียม ส่วนใน Rust คือ CancellationToken หรือช่อง watch แต่คอมไพเลอร์สามารถบอกได้หากคุณลืมส่งต่อ
  • สตริง: string vs String และ &str

    • string ของ Go เป็น UTF-8 byte slice โดยเมื่อมีการกำหนดค่า header จะถูกคัดลอกและ bytes ใต้ชั้นจะถูกแชร์กัน เป็นโครงสร้าง immutable
    • Rust แยกสิ่งนี้ออกเป็นสองชนิด
      • String: เป็นเจ้าของข้อมูล, จัดสรรบน heap และขยายขนาดได้
      • &str: เป็น borrowed view ไปยังข้อมูลสตริงอื่น ซึ่งในกรณีส่วนใหญ่เทียบได้กับพารามิเตอร์ string ของ Go
    • กฎคร่าว ๆ คือรับ &str เป็นอาร์กิวเมนต์ และคืน String เมื่อต้องสร้างข้อมูลใหม่
    • การแยกระหว่าง &str กับ String เป็นภาพย่อของโมเดล “borrow vs own” ของ Rust

การประเมิน Go Generics

  • Go เปิดตัว generics ในเวอร์ชัน 1.18 เมื่อเดือนมีนาคม 2022 ซึ่งเป็นเวลา 13 ปีหลัง จากการเปิดตัวภาษา
  • ประเมินว่าแม้ generics จะมีประโยชน์ แต่ก็ยังมอบข้อดีได้ไม่เต็มที่อย่างที่คาดหวังจาก Rust, Haskell, หรือ C++ สมัยใหม่ และยังมีข้อเสียของระบบ generic type อยู่ไม่น้อย
  • แทบไม่ถูกใช้ใน standard library

    • แม้จะผ่านไป 3 ปีหลังการเพิ่ม generics แต่ standard library ของ Go ก็ยังหลีกเลี่ยงการใช้ generics เป็นส่วนใหญ่
    • sort.Slice ยังคงรับ closure แบบ func(i, j int) bool แทน cmp.Ordered constraint
    • sync.Map ยังคงถูกพิมพ์ชนิดเป็น any/any
    • helper แบบ generic ที่มีอยู่ก็มีเพียงไม่กี่แพ็กเกจ เช่นบางรายการภายใต้ slices, maps, cmp, sync
    • แม้คำสัญญาเรื่องความเข้ากันได้ของ Go 1 จะพออธิบายได้บางส่วนว่าทำไมจึงปรับ API แบบ non-generic เดิมได้ยาก แต่ก็ยังไม่ได้ใช้ generics เป็นเครื่องมือหลักแบบ Rust
    • Rust มี generics ฝังอยู่ตั้งแต่แรกเริ่มใน Option<T>, Result<T, E>, Vec<T>, HashMap<K, V>, Iterator, From/Into รวมถึง collection และ smart pointer ทั้งหมด
  • ไม่มีระบบ trait และมีเพียง constraint แบบโครงสร้าง

    • generics ของ Rust ผูกอยู่กับ trait ที่รองรับ ad-hoc polymorphism, supertrait, associated type, blanket impl และ coherence
    • constraint ของ Go ใกล้เคียงกับ interface ที่เพิ่มตัวดำเนินการ ~ สำหรับ type-set membership
    • Go ไม่มี supertrait hierarchy แบบ trait Ord: Eq + PartialOrd ของ Rust, ไม่มี associated type แบบ type Item; ของ Iterator, และไม่มี blanket impl แบบ impl<T: Display> ToString for T
    • ใน Go ไม่สามารถใช้ เมธอดที่มี type parameter ได้ จึงไม่สามารถเขียนรูปแบบอย่าง func (s Set[T]) Map[U](<https://corrode.dev/learn/migration-guides/go-to-rust/f func(T>) U) Set[U]
    • ทันทีที่ abstraction เลยไปจากระดับ “ฟังก์ชันที่ทำงานกับ T ใดก็ได้ซึ่งมีโอเปอเรชันบางอย่าง” Go ก็มักต้องย้อนกลับไปใช้ any, type assertion, code generation หรือ runtime reflection
  • ความต่างของ type inference และกลยุทธ์การ implement

    • Rust กระจายข้อมูลชนิดไปทั่วทั้ง expression รวมถึง closure, iterator chain และตัวดำเนินการ ?
    • การอนุมานชนิดของ Go ตื้นกว่า โดยปกติจะอนุมาน type parameter จากอาร์กิวเมนต์ของฟังก์ชัน แต่ ไม่สามารถอนุมานจากบริบทของตำแหน่ง return ได้ และมักต้องระบุ type argument แบบ explicit ที่จุดเรียกใช้
    • Go เลือกทางสายกลางด้วย GCShape stenciling and dictionaries เพื่อรักษาเวลา compile ให้เร็ว แต่การเรียกเมธอดของ type parameter แต่ละครั้งอาจมี indirection แทรกเข้ามา
    • มีการอ้างถึง บทความของ PlanetScale เป็นข้อมูลประกอบ
    • Rust สร้าง machine code ที่ specialize แยกสำหรับ Vec<i32> และ Vec<String> และไม่มี runtime dispatch
    • ต้นทุนของ monomorphization คือเวลา compile และทั้งสองภาษาก็ optimize ไปคนละเป้าหมาย
  • ไม่สามารถอุดช่องโหว่ของระบบชนิดได้

    • ใน Rust generics และ trait ช่วยกำจัดสถานการณ์ส่วนใหญ่ที่ไม่เช่นนั้นจะต้องพึ่ง Box<dyn Any> หรือ runtime reflection
    • generics ของ Go ยังไม่สามารถกำจัด any, reflect หรือแพตเทิร์นการสร้างโค้ดที่ยังครองอยู่ใน ORM, decoder และ mock ได้
    • encoding/json ยังใช้ reflection, database/sql ยังใช้ any, และ mockgen ก็ยังสร้างโค้ดอยู่
    • generics ของ Go ให้ความรู้สึกเหมือนเป็นเครื่องมือใหม่ที่มีประโยชน์ในกรณีแคบ ๆ ส่วน generics ของ Rust ทำงานเสมือนเป็นรากฐานที่ถ้าถอดออกไป ภาษาแทบจะพังทั้งระบบ

ระบบนิเวศแบ็กเอนด์ของ Rust

  • ระบบนิเวศของ Rust ก็มีการค่อย ๆ ลงตัวพอสมควรแล้วว่าทางเลือก “ค่าเริ่มต้น” สำหรับบริการแบ็กเอนด์ทั่วไปคืออะไร
  • ความสอดคล้องโดยทั่วไปที่พบได้:
    • HTTP server: Go net/http, chi, gin, echo, fiber → Rust axum บน hyper
    • HTTP client: Go net/http, resty → Rust reqwest
    • gRPC: Go google.golang.org/grpc + protoc-gen-go → Rust tonic + prost
    • SQL: Go database/sql, sqlc, sqlx, gorm → Rust sqlx, sea-orm, diesel
    • Migrations: Go golang-migrate, goose → Rust sqlx migrate, refinery
    • JSON: Go encoding/json, sonic, goccy/go-json → Rust serde + serde_json
    • Logging: Go log/slog, zerolog, zap → Rust tracing + tracing-subscriber
    • Metrics: Go prometheus/client_golang → Rust metrics + metrics-exporter-prometheus
    • Config: Go viper, koanf → Rust config / config-rs, figment
    • CLI: Go cobra, urfave/cli → Rust clap derive
    • Errors: Go errors, pkg/errors → Rust thiserror สำหรับไลบรารี, anyhow สำหรับไบนารี
    • Testing: Go testing, testify, gomega → Rust built-in #[test], rstest, assert_matches
    • Mocking: Go mockgen, moq → ใน Rust การเขียน fake ด้วยมือเป็นแนวปฏิบัติที่พบได้บ่อย และมีการใช้ mockall ด้วย
    • Background tasks: Go goroutines + errgroup → Rust tokio::spawn + JoinSet
  • สำหรับบริการแบ็กเอนด์ทั่วไป มีการเสนอว่าชุด axum + sqlx + tokio + tracing + serde + clap ครอบคลุมความต้องการได้ถึง 90%

ตัวตรวจสอบการยืมและเส้นโค้งการเรียนรู้

  • ต้องตั้งต้นจากข้อเท็จจริงว่าเมื่อย้ายจาก Go มา Rust คุณจะ ชนกำแพงอย่างหลีกเลี่ยงไม่ได้
  • รันไทม์ของ Go จัดการหน่วยความจำและ aliasing ให้แทน แต่ Rust ย้ายการตัดสินใจนั้นไปอยู่ใน type system จึงอาจทำให้ในช่วงสองสามสัปดาห์แรก โค้ดที่ “ควรจะทำงานได้แน่นอน” ถูกคอมไพเลอร์ปฏิเสธ
  • แพตเทิร์นที่นักพัฒนา Go มักเจอ:
    • การอ้างอิงที่มีอายุยาว: ใน Go การถือ *User ที่ดึงออกมาจาก map ไว้นาน ๆ เป็นเรื่องธรรมชาติ แต่ใน Rust จะไม่สามารถแก้ไข map ได้ตราบใดที่การยืมนั้นยังมีชีวิตอยู่
    • self-referential struct: ใน Go คุณสามารถเก็บข้อมูลและ iterator ที่ทำงานบนข้อมูลนั้นไว้ใน struct เดียวกันได้ แต่ใน Rust ต้องใช้ Pin, ouroboros หรือไม่ก็ออกแบบใหม่
    • การแชร์สถานะที่เปลี่ยนแปลงได้ระหว่าง goroutine: แพตเทิร์น mu sync.Mutex; data map[K]V ของ Go จะกลายเป็น Arc<Mutex<HashMap<K, V>>> ใน Rust
    • การคืนค่า reference จากฟังก์ชัน: lifetime annotation จะเข้ามาเกี่ยวข้อง ซึ่งเป็นแนวคิดใหม่สำหรับนักพัฒนา Go
  • ควรมอง ตัวตรวจสอบการยืม ว่าไม่ใช่ “ยามเฝ้าประตู” ที่คอยขัดขวาง แต่เป็นกลไกที่ช่วยเปิดเผยบั๊กที่มีอยู่จริง
  • มันช่วยกรองตั้งแต่ตอนคอมไพล์ กรณีที่มีการใช้ค่าซ้ำหลังถูกย้ายไปแล้ว, หลายเธรดแตะข้อมูลเดียวกันพร้อมกัน, dereference null หรือ dangling pointer, หรือกรณีที่ reference มีอายุนานกว่าค่าจริง
  • เมื่อซึมซับแนวคิดเรื่องการยืมได้แล้ว มันจะเปลี่ยนจากสิ่งที่ต้องต่อสู้ด้วยเป็นผู้ช่วย และนักพัฒนา Rust ที่มีประสบการณ์มักบอกว่าใช้เวลาราว 4–12 สัปดาห์กว่าตัวตรวจสอบการยืมจะกลายเป็นผู้ช่วย
  • Stephen Blum, CTO ของ PubNub กล่าวใน Rustacean Station ว่าเดือนแรกนั้น “เหมือนตอนหัดเขียนโปรแกรมครั้งแรก” เพราะต้องรับมือกับตัวตรวจสอบการยืมและ lifetime แบบเลี่ยงไม่ได้
  • Ed Page ผู้ดูแล clap กล่าวใน Rustacean Station: clap with Ed Page ว่าตัวตรวจสอบการยืมช่วยให้เขาโฟกัสกับปัญหาระดับสูงได้ และยังจับจุดที่เขาวิเคราะห์เองพลาดได้ด้วย

อุปสรรคหลักในการเปลี่ยนมาใช้ Rust

  • เวลาในการคอมไพล์

    • เวลาในการคอมไพล์ของ Rust ต้องยอมรับว่าแย่ลงจาก Go อย่างชัดเจน โดย clean release build ของบริการขนาดกลางอาจใช้เวลาหลายนาที ต่างจากการคอมไพล์ของ Go ที่แทบจะทันที
    • incremental build และ cargo check ยังถือว่าใช้งานได้ดี และเวลาในการคอมไพล์ก็ดีขึ้นทุกปี แต่ความต่างเมื่อเทียบกับ Go ยังรู้สึกได้
    • ในลูปการแก้ไขโค้ดให้ใช้ cargo check, แยกเป็น workspace เมื่อเริ่มเห็นประโยชน์, และคง crate ที่มี procedural macro จำนวนมากไว้เป็น crate แยก เพื่อให้คอมไพล์ใหม่เฉพาะตอนที่มันเปลี่ยน
    • อ่านเพิ่มเติมได้ที่ เคล็ดลับลดเวลาในการคอมไพล์ของ Rust
  • ปัญหา async coloring

    • การแยก async fn / fn ของ Rust เป็นหนึ่งในจุดถดถอยด้านการใช้งานที่ใหญ่ที่สุดเมื่อย้ายมาจาก Go
    • async trait แม้จะ stable แล้วตั้งแต่ Rust 1.75 แต่ก็ยังมีจุดขรุขระอยู่เมื่อใช้ร่วมกับ dynamic dispatch
    • ในบางกรณีจึงต้องใช้ crate async-trait เพื่อกลบปัญหาเหล่านี้
  • ecosystem ที่เล็กกว่า

    • ecosystem ของ crate ใน Rust กำลังเติบโตและคุณภาพของไลบรารีโดยรวมก็สูง แต่ Go ยังนำหน้าในบางด้านที่ใกล้กับงานแบ็กเอนด์
    • ด้านที่ Go ยังนำ ได้แก่ Kubernetes operator, SDK ของผู้ให้บริการคลาวด์, และ database driver สำหรับสตอเรจเฉพาะทางบางชนิด
    • ก่อนตัดสินใจย้าย ควรใช้เวลาสักวันตรวจสอบว่าไลบรารีที่คุณพึ่งพามีทางเลือกใน Rust ที่ใช้งานได้หรือไม่
    • บางทีมอาจต้องอัปเดต crate สำหรับตรวจสอบ XML schema ที่ถูกปล่อยทิ้งไว้ หรือเขียน client ของโปรโตคอลที่ไม่ค่อยมีคนใช้ขึ้นมาเอง

กลยุทธ์การผสานรวม

  • การย้ายจาก Go ไป Rust ที่ประสบความสำเร็จมักเป็นการเลือกเชิงยุทธวิธี มากกว่าจะเขียนทุกอย่างใหม่ในครั้งเดียว
  • Victor Ciura, Principal Engineer ของ Microsoft กล่าวใน Rust in Production ว่า “ไม่ใช่การเขียนทุกอย่างใหม่เป็น Rust เพราะสนุก แต่เป็นการเลือกเชิงยุทธวิธีว่า component ใหม่เหมาะกับ Rust มากกว่า ก็ให้เขียนเป็น Rust”
  • 1. แยก hot path ออกมาเป็นบริการ

    • หากมีบริการบางตัวที่ก่อปัญหาอยู่เรื่อย ๆ การเขียนใหม่เฉพาะบริการนั้นเป็น Rust โดยคงไว้หลัง API contract เดิม เป็นแนวทางย้ายระบบที่เสี่ยงต่ำที่สุด
    • เป้าหมายอาจเป็นบริการที่ใช้ CPU สูง, ไวต่อ latency, หรือมีปัญหาด้านเสถียรภาพซ้ำ ๆ
    • บริการ Go ตัวอื่นยังสื่อสารผ่าน HTTP/gRPC ต่อไปได้ จึงไม่จำเป็นต้องรู้ว่าภายในใช้ภาษาอะไร
    • Jeff Kao, CTO ของ Radar กล่าวใน Rust in Production ว่าบทความ Discord ย้ายจาก Go ไป Rust ทำให้ Radar คิดจะลองแนวทางเดียวกัน
  • 2. เปลี่ยน sidecar หรือ worker process

    • background worker, queue consumer, pipeline สำหรับเก็บข้อมูล, และงาน batch แบบ CPU-bound เป็นเป้าหมายแรกที่ดี
    • โดยมากองค์ประกอบเหล่านี้มีขอบเขต input/output ชัดเจน เช่น queue หรือ topic และไม่มีการแชร์สถานะแบบ in-process กับส่วนอื่นของระบบ
  • 3. cgo ทำได้ แต่เจ็บปวด

    • คุณสามารถเรียก Rust จาก Go ผ่าน cgo ได้ และก็มี คู่มือที่ดีสำหรับเรื่องนี้ ด้วย
    • แต่โดยทั่วไปไม่ค่อยแนะนำสำหรับบริการแบ็กเอนด์
    • ความซับซ้อนของการ build และ overhead ของ FFI มักลบล้างข้อดี เมื่อเทียบกับการ “ตั้งบริการ Rust ขึ้นมาแล้ววางไว้หลัง network call”
    • สำหรับไลบรารีและเครื่องมือ CLI อาจใช้งานได้จริงมากกว่า
  • 4. ใช้ Strangler Pattern หลังเกตเวย์

    • หากมี API gateway หรือ reverse proxy คุณสามารถ route เฉพาะบาง endpoint ไปยังบริการ Rust ใหม่ และปล่อยให้ส่วนที่เหลืออยู่บน Go ต่อได้
    • วิธีนี้เหมาะมากเมื่อ bounded context เดียว เช่น authentication, search, หรือ payment เหมาะจะเป็นหน่วยของการย้ายระบบ
    • แพตเทิร์นนี้ถูกเรียกว่า “strangler fig” เพราะบริการใหม่จะค่อย ๆ เติบโตล้อมรอบบริการเดิม จนสุดท้ายแทนที่ได้ทั้งหมด

เคล็ดลับการย้ายระบบในภาคปฏิบัติ

  • ควรเริ่มจาก บริการที่มีขอบเขตชัดเจน และไม่ควรเลือกบริการที่เป็นศูนย์กลางที่สุดหรือถูก deploy บ่อยที่สุด
  • ควรเลือกบริการที่มีสัญญากับระบบส่วนอื่นชัดเจน และมีรัศมีผลกระทบเล็ก
  • รักษา API contract เดิมไว้

    • หากบริการ Go เปิด REST API บริการ Rust ก็ควรรักษา path เดิม, รูปแบบ JSON เดิม, และ error wrapper เดิมไว้
    • การย้ายระบบจะมองไม่เห็นจากฝั่ง client และสามารถค่อย ๆ ย้ายทราฟฟิกผ่าน gateway ได้
  • อย่าแปล idiom แบบตรงตัว

    • if err != nil { return err } จะกลายเป็น ?
    • แพตเทิร์น goroutine ต่อหนึ่ง request ควรถูกย้ายเป็น tokio::spawn เฉพาะเมื่อจำเป็นจริง ๆ เท่านั้น
    • axum จัดการคำขอพร้อมกันอยู่แล้ว
    • interface ที่มีเมธอดเดียว โดยมากจะกลายเป็น trait bound ของ generic มากกว่า Box<dyn Trait>
  • ใช้คอมไพเลอร์เหมือนเป็น pair programmer

    • ข้อความ error จากคอมไพเลอร์ของ Rust โดยทั่วไปมีคุณภาพสูง และถ้าอ่านช้า ๆ ก็มักจะบอกคำตอบที่ถูกต้องเกือบทุกครั้ง
    • คนในทีมที่ลำบากอยู่นานที่สุด มักเป็นคนที่ไม่มองคอมไพเลอร์เป็นผู้ร่วมงาน แต่ไปสู้กับมัน
  • ลงทุนกับการฝึกฝนตั้งแต่ต้น

    • การย้ายมา Rust มักไปไม่สวย หากพยายามเรียนรู้ควบคู่ไปแบบ “ทำไปข้าง ๆ”
    • ควรจัดเวลาเรียนรู้อย่างจริงจังผ่านเวิร์กช็อป, คอร์สออนไลน์, หรือ pair session บน codebase จริง
    • เมื่อทีมเริ่มเชี่ยวชาญ การลงทุนล่วงหน้าจะคืนผลหลายเท่า

พื้นที่ที่ Go ยังเหมาะสมอยู่

  • ไม่จำเป็นต้องย้ายทุกอย่างไปเป็น Rust และยังมีงานบางด้านที่ Go เหมาะอย่างยิ่ง
  • เครื่องมือแบบ Kubernetes-native

    • งานด้าน operator, controller และ CRD นั้น ecosystem ยังเทไปทาง Go อย่างท่วมท้น
  • CLI utility และเครื่องมือสำหรับนักพัฒนา

    • จุดแข็งคือคอมไพล์ได้เร็ว cross-compile ได้ง่าย และ deploy ได้ไม่ซับซ้อน
  • บริการแบบ glue

    • ใน API layer ที่บาง, proxy และตัวแปลงรูปแบบ Rust อาจมี boilerplate มากเกินกว่าความคุ้มค่า
  • พื้นที่ที่ความเร็วของทีมสำคัญกว่าการรับประกันความถูกต้องแบบเด็ดขาด

    • ในงานที่ต้องขยับตัวให้เร็ว Go ก็อาจยังเหมาะสมต่อไป
    • Jon Seager รองประธานฝ่ายวิศวกรรมของ Canonical กล่าวใน Rust in Production ว่า Go เป็นตัวเลือกที่ดีมากสำหรับบริการด้านเครือข่าย และที่ Canonical ก็มี Go อยู่มาก รวมถึง Juju ก็เป็น codebase ภาษา Go ขนาดใหญ่
    • กลยุทธ์แบบไฮบริดเป็นเรื่องปกติ และหลายทีมก็ลงเอยด้วย backend หลายภาษา โดยใช้ Go กับบริการที่ “น่าเบื่อ” และใช้ Rust กับบริการที่ความเสถียรและประสิทธิภาพคุ้มกับความพยายามที่เพิ่มขึ้น

การปรับปรุงที่คาดหวังได้

  • ตัวเลขอาจต่างกันมากตามลักษณะ workload จึงควรมองเป็นแนวทางคร่าว ๆ ไม่ใช่คำรับประกัน
  • ช่วงการปรับปรุงโดยประมาณที่พบจากการย้ายจาก Go ไป Rust:
    • การใช้ CPU: ลดลง 20~60%
      • ไม่ได้หวือหวาเท่าการย้ายจาก Python ไป Rust เพราะ Go ก็มีประสิทธิภาพอยู่แล้ว
      • ได้ประโยชน์จากการไม่มี GC และลูปที่กระชับกว่า
    • หน่วยความจำ: ลดลง 30~50%
      • หลัก ๆ มาจากการไม่มี GC overhead และมี runtime ที่เล็กกว่า
    • P99 latency: สม่ำเสมอมากขึ้นอย่างชัดเจน
      • บริการ Rust มักนิ่งขึ้นและมี GC-induced jitter แบบที่เห็นในบริการ Go น้อยลง
      • ฝั่ง Go ก็ดีขึ้นมากหลังมี low-latency GC แต่เมื่อโหลดสูงก็ยังมีความต่างอยู่
    • เหตุขัดข้องใน production: เป็นด้านที่ทีมต่าง ๆ รายงานการปรับปรุงกันมากที่สุด
      • บั๊กประเภท data race, nil dereference และเส้นทางจัดการ error ที่ตกหล่น ซึ่งอาจผ่าน go test -race และไปถึง production ได้ จะคอมไพล์ไม่ผ่านใน Rust
      • หลังย้ายมา Rust แล้ว การเวร on-call มักจะน่าเบื่อขึ้นมากโดยรวม
  • Andrew Lamb, Staff Engineer ของ InfluxData กล่าวใน Rustacean Station: Rebuilding InfluxDB with Rust ว่าหลังจากเขียน InfluxDB ใหม่แล้ว ก็ไม่ต้องตามไล่ปัญหาอย่าง crash, race condition แบบ multithread แปลก ๆ และปัญหาที่เคยกินเวลาไปมากอีกต่อไป
  • การย้ายจาก Go ไป Rust มีโอกาสน้อยที่จะทำให้ throughput ดีขึ้น 10 เท่าแบบที่อาจเห็นเมื่อย้ายจาก Python ไป Rust
  • ประโยชน์ที่แท้จริงคือการลด “ข้อผิดพลาดชวนปวดหัว”, ทำให้ latency tail เรียบขึ้น และมีความสามารถในการขยายไปสู่งานด้านอื่น เช่น embedded development หรือ system programming ด้วยภาษาเดียวกัน

ข้อควรระวังเพิ่มเติม

  • type system ของ Rust ไม่ได้ลบล้างบั๊กของตรรกะการ synchronization ทั้งหมด แต่ type ที่ไม่สามารถแชร์ข้าม thread โดยไม่มี synchronization จะคอมไพล์ไม่ผ่าน
  • ปัญหาประเภท “ลืมล็อก” จนกลายเป็นข้อมูลเสียหายแบบเงียบ ๆ เป็นสิ่งที่ type system ของ Rust ช่วยป้องกันได้
  • string ใน Go เป็นลำดับไบต์ที่ไม่เปลี่ยนแปลงค่าได้ และโดยธรรมเนียมถือเป็น UTF-8 แต่ไม่ได้มีการรับประกันในระดับ type
  • สิ่งที่ใกล้เคียงที่สุดคือ หากมองเป็น read-only view จะเทียบ Go string ↔ Rust &str และหากมองเป็น mutable buffer จะเทียบ Go []byte ↔ Rust Vec<u8>
  • String ใน Rust คือเวอร์ชันแบบมี ownership และขยายได้ของ &str พร้อมการรับประกันเพิ่มเติมว่าเนื้อหาเป็น UTF-8 ที่ถูกต้อง
  • ดูรายละเอียดเพิ่มเติมได้ที่ Strings, bytes, runes and characters in Go
  • ตั้งแต่ Go 1.18 เป็นต้นมา สามารถใช้ generic function และ generic type ได้ แต่ยังไม่มี type parameter สำหรับ method โดยตรง
  • iterator chain แบบ Rust เช่น (0..100).filter(|n| ...).collect() อาจดูไม่คุ้นสำหรับนักพัฒนา Go แต่ใน Rust ก็ยังใช้ลูป for ได้ และสำหรับโค้ดใช้ครั้งเดียวก็มักเป็นตัวเลือกที่เหมาะสม

สรุป

  • การย้ายจาก Go ไป Rust แตกต่างจากการย้ายจาก Python หรือ TypeScript ไป Rust
  • นักพัฒนาที่มาจาก Go รู้ข้อดีของ static type และภาษาแบบคอมไพล์อยู่แล้ว ดังนั้นนี่ไม่ใช่การแลกกับการทิ้ง dynamic type หรือ runtime ที่ช้า
  • สิ่งที่แลกกันหลัก ๆ คือทิ้ง nil แล้วได้ codebase ที่แข็งแรงขึ้น, มีหลุมพรางน้อยลง และมีคอมไพเลอร์ที่เข้มงวดกว่าซึ่งจับความผิดพลาดได้มากขึ้นตั้งแต่ตอนคอมไพล์
  • แต่เส้นโค้งการเรียนรู้ก็ชันกว่า
  • สำหรับบริการสำคัญต่อธุรกิจที่องค์กรพึ่งพา ต้องการ uptime สูง เช่น บริการพื้นฐาน การแลกนี้มีความคุ้มค่าอย่างชัดเจน
  • สำหรับบริการอื่น ๆ Go ก็อาจยังเป็นคำตอบที่เหมาะสม
  • เป้าหมายของการย้ายระบบคือวางแต่ละปัญหาไว้กับภาษาที่แก้ปัญหานั้นได้ดีที่สุด

ยังไม่มีความคิดเห็น

ยังไม่มีความคิดเห็น