การย้ายจาก Go ไปสู่ Rust
(corrode.dev)- การเปลี่ยนจาก 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 เรียบขึ้น และปัญหา
nildereference กับ 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.sum→Cargo.toml/Cargo.lock: การตั้งค่าโปรเจกต์และ manifest ของ dependencygo get/go mod tidy→cargo add/cargo update: เพิ่มและ resolve dependencygo build→cargo build: คอมไพล์go run .→cargo run: รันหลัง buildgo test ./...→cargo test: ทดสอบgo vet ./...→cargo clippy: linter โดยClippyมีแนวทางที่ชัดเจนกว่าvetมากgofmt/goimports→cargo fmt: auto formatter แบบไม่ต้องตั้งค่าgolangci-lint run→cargo clippy -- -D warnings: โหมด lint แบบเข้มงวดgo doc→cargo doc --open: สร้างและเปิดดูเอกสาร APIpprof→cargo flamegraph/samply: โปรไฟล์ CPUgovulncheck→cargo 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{} -
nilpanic ในโปรดักชัน- บริการ 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จึงกำจัดปัญหากลุ่มนี้ไปได้
- บริการ Go อาจทำงานปกติเป็นเวลาหลายเดือน แต่เมื่อถึงเส้นทางโค้ดบางกรณีก็อาจพลาดการตรวจสอบพอยน์เตอร์
-
ดาต้าเรซที่
-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 != nilvsResult<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รองรับการแปลงนี้แบบที่เป็นสำนวนใช้งานทั่วไป
- ใน Go จะใช้
-
null:
nilvsOption<T>GetUser(id string) *Userของ Go จะคืนnilหากไม่พบผู้ใช้ และถ้าฝั่งเรียกใช้fmt.Println(u.Name)ก็จะ panic เมื่อเป็นnilget_user(id: &str) -> Option<User>ของ Rust จะคืนSome(User)หรือNonelet 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
- โมเดล concurrency ของ Go เรียบง่าย เช่น
-
context.ContextvsCancellationToken- ใน Go จะส่ง
context.Contextให้กับทุก blocking call - Rust ไม่มี
context.Contextในตัว และสิ่งที่ใกล้เคียงที่สุดสำหรับการยกเลิกคือtokio_util::sync::CancellationToken - timeout ทำได้โดยห่อ future ด้วย
tokio::time::timeout(dur, fut) - deadline และ value มักถูกส่งผ่าน argument แบบชัดเจนหรือผ่าน
tracingspan มากกว่าจะรวมไว้ใน 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แต่คอมไพเลอร์สามารถบอกได้หากคุณลืมส่งต่อ
- ใน Go จะส่ง
-
สตริง:
stringvsStringและ&strstringของ 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.Orderedconstraintsync.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 กระจายข้อมูลชนิดไปทั่วทั้ง expression รวมถึง closure, iterator chain และตัวดำเนินการ
-
ไม่สามารถอุดช่องโหว่ของระบบชนิดได้
- ใน 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 generics และ trait ช่วยกำจัดสถานการณ์ส่วนใหญ่ที่ไม่เช่นนั้นจะต้องพึ่ง
ระบบนิเวศแบ็กเอนด์ของ Rust
- ระบบนิเวศของ Rust ก็มีการค่อย ๆ ลงตัวพอสมควรแล้วว่าทางเลือก “ค่าเริ่มต้น” สำหรับบริการแบ็กเอนด์ทั่วไปคืออะไร
- ความสอดคล้องโดยทั่วไปที่พบได้:
- HTTP server: Go
net/http,chi,gin,echo,fiber→ Rustaxumบนhyper - HTTP client: Go
net/http,resty→ Rustreqwest - gRPC: Go
google.golang.org/grpc+protoc-gen-go→ Rusttonic+prost - SQL: Go
database/sql,sqlc,sqlx,gorm→ Rustsqlx,sea-orm,diesel - Migrations: Go
golang-migrate,goose→ Rustsqlx migrate,refinery - JSON: Go
encoding/json,sonic,goccy/go-json→ Rustserde+serde_json - Logging: Go
log/slog,zerolog,zap→ Rusttracing+tracing-subscriber - Metrics: Go
prometheus/client_golang→ Rustmetrics+metrics-exporter-prometheus - Config: Go
viper,koanf→ Rustconfig/ config-rs,figment - CLI: Go
cobra,urfave/cli→ Rustclapderive - Errors: Go
errors,pkg/errors→ Rustthiserrorสำหรับไลบรารี,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→ Rusttokio::spawn+JoinSet
- HTTP server: Go
- สำหรับบริการแบ็กเอนด์ทั่วไป มีการเสนอว่าชุด
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
- การอ้างอิงที่มีอายุยาว: ใน 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 มักจะน่าเบื่อขึ้นมากโดยรวม
- บั๊กประเภท data race, nil dereference และเส้นทางจัดการ error ที่ตกหล่น ซึ่งอาจผ่าน
- การใช้ CPU: ลดลง 20~60%
- 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↔ RustVec<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 ก็อาจยังเป็นคำตอบที่เหมาะสม
- เป้าหมายของการย้ายระบบคือวางแต่ละปัญหาไว้กับภาษาที่แก้ปัญหานั้นได้ดีที่สุด
ยังไม่มีความคิดเห็น