การย้ายจาก 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 ก็อาจยังเป็นคำตอบที่เหมาะสม
- เป้าหมายของการย้ายระบบคือวางแต่ละปัญหาไว้กับภาษาที่แก้ปัญหานั้นได้ดีที่สุด
4 ความคิดเห็น
ดูเหมือนว่าการเติบโตของ Rust จะเริ่มชะงักหรือเปล่า ถึงได้เขียนกันราวกับว่าเหมือนลัทธิชินชอนจี คนที่ใช้ Go กำลังย้ายไป Rust กันเยอะมาก นี่เป็นสิ่งที่ผู้ใช้ Python เคยทำกับผู้ใช้ Perl เมื่อ 20 ปีก่อน และมันก็ประสบความสำเร็จด้วย
ก็เริ่มรู้สึกว่า
tokioน่าจะถึงเวลาที่ค่อย ๆ เข้าไปอยู่ใน standard library ได้แล้วเหมือนกันนะดูเหมือนว่าจะมีคนพูดกันด้วยว่า การไม่มี async runtime มาตรฐานเสียอีกที่เป็นส่วนหนึ่งของแนวทางการออกแบบของ Rust
ความเห็นจาก Hacker News
พอจะเข้าใจได้ว่าทำไมหลายคนถึงย้ายจาก C/C++ หรือ Python ไป Rust แต่ถ้าเป็น เว็บแบ็กเอนด์ Go ดูจะเป็นตัวเลือกที่เหมาะมากกว่า
ปกติผมใช้ Rust แทบทั้งหมด แต่ครั้งล่าสุดที่ทำงานฝั่งเว็บเซิร์ฟเวอร์ด้วย Rust ก็รู้สึกว่าน่าจะใช้ Go มากกว่า
ต้นฉบับชี้ว่าไวยากรณ์จัดการข้อผิดพลาดของ Go ค่อนข้างเยิ่นเย้อ ซึ่งก็จริง Rust เองก็เคยมีปัญหาแบบเดียวกัน ก่อนจะเพิ่มไวยากรณ์
?สำหรับคืนค่า error เมื่อเกิดข้อผิดพลาดขึ้นมา การจัดการ error ของ Go ส่วนใหญ่ก็เหมือนการเขียนสิ่งนี้แบบขยายออกใน Rust ไม่มี error type ที่เป็นมาตรฐานเดียวกัน แต่มีระบบ error หลัก ๆ อย่าง
io::Error,thiserror,anyhowซึ่งทำให้การส่งต่อขึ้นไปตาม call chain ค่อนข้างยุ่งยากมีบางอย่างที่ถ้าหลุดไปจากภาษาใหม่ในตอนแรกแล้ว จะเติมกลับเข้ามาทีหลังได้ยาก เช่น constant type, boolean type, error type, multidimensional array type, รวมถึง vector/matrix ขนาด 2/3/4 และโอเปอเรชันมาตรฐานของมัน ถ้าไม่ทำให้เป็นมาตรฐานตั้งแต่ต้น ก็จะเสียเวลามากกับการทำให้รูปแบบหลายแบบของแนวคิดเดียวกันเข้ากันได้
นอกจากการจัดการ error แล้ว เรื่องนี้กระทบเว็บดีเวลอปเมนต์น้อยกว่า แต่ในงานคำนวณเชิงตัวเลข กราฟิก และโมเดลลิง มันสร้างความเจ็บปวดมาก เพราะต้องใช้โอเปอเรชันมาตรฐานกับอาร์เรย์ของตัวเลข
ข้อดีของ Go สำหรับเว็บเซอร์วิสมีอยู่สองอย่าง อย่างแรกคือ goroutine ตามที่ต้นฉบับพูดถึง และอีกอย่างคือลงลึกไม่มากในต้นฉบับคือไลบรารี Go มีไลบรารีเกือบทั้งหมดที่จำเป็นต่อเว็บเซอร์วิส และหลายตัวก็ถูกใช้ภายใน Google ด้วย จึงผ่านสภาพแวดล้อมที่โหดมากมาแล้ว ในทางกลับกัน crate ของ Rust ยังโตไม่เต็มที่และหลายครั้งก็ไม่มีการรับประกันคุณภาพอย่างเป็นทางการ
อีกอย่าง Rust ยังพึ่งพาไลบรารี C/C++ จำนวนมากมากกว่า Go อยู่ดี ทำให้ cross-compilation, reproducible build และการสร้าง static binary มักเป็นปัญหาได้ง่าย
ข้อเสียของ Go คือ garbage collector เรียบง่ายเกินไป ถ้าเกิด latency spike ขึ้นมาก็มักไม่มีทางรับมือที่ดีนักนอกจากรีไรต์แบบเจ็บตัว
สิ่งที่ยกมาคือเพียงวิธีใช้งานที่พบบ่อยของมันเท่านั้น และต่อให้ใช้แค่
Boxก็ไม่มีปัญหาอะไรเลย ซึ่งโดยมากก็คล้ายกับที่anyhow::Errorทำแต่ถ้าพูดถึง standard library ผมว่า Go ทำได้ดีกว่า Rust มาก
ผมชอบภาษา Rust และใช้มันกับ embedded firmware และแอปพลิเคชันบนพีซี แต่เว็บแบ็กเอนด์ยังคงใช้ Python เพราะ Rust ยังไม่มีชุดเครื่องมือระดับ Django หรือ Rails
มันมีของที่คล้าย Flask อยู่ แต่ไม่มี ecosystem ที่แข็งแรงแบบ Flask แม้ผมจะมีประสบการณ์กับ Go ไม่มาก แต่ถ้าต้องเลือกสำหรับเว็บแบ็กเอนด์ ก็น่าจะเลือก Go มากกว่า Rust เพราะ ecosystem ของไลบรารีและเฟรมเวิร์ก
อีกทั้งด้วยเหตุผลทั่ว ๆ ไปที่มักพูดกัน ผมไม่ค่อยชอบ Async Rust เท่าไร และ ecosystem เว็บของ Rust ก็แทบทั้งหมดบังคับให้ใช้ async เกือบหมด
io::Errorเป็นเพียงหนึ่งในหลาย type ที่ implement สิ่งนี้เท่านั้น ไม่ได้พิเศษอะไร error ที่นิยามด้วยthiserrorก็ implement trait นี้เหมือนกันส่วน
anyhowก็แค่ช่วยให้เราพูดแบบสบาย ๆ ว่าเป็น “Error แบบใดก็ได้” เมื่อไม่อยากเขียนรายละเอียดของ error type ที่ฟังก์ชันสามารถปล่อยออกมาเป็นสัญญา APIRust ทำให้เขียนโค้ดแบบ กำหนดผลลัพธ์ได้แน่นอน ได้ง่ายกว่า Go มาก จึงมีประโยชน์มากเมื่อจำเป็นต้องทำ deterministic simulation test และ property-based test
ไม่นานมานี้ผมเขียนเครื่องมือทำ data mirroring จาก Postgres ไป Iceberg ด้วย Go ที่ https://github.com/polynya-dev/pg2iceberg แต่ย้ายมันไป Rust เพราะอยากทำ deterministic simulation test โดยไม่ต้องไปสู้กับ Go runtime
แต่ถ้าโดเมนนั้นไม่ได้สำคัญพอจะคุ้มกับการทดสอบระดับนั้น ผมก็จะเลือก Go แทน Rust ได้เสมอ
บทความที่เกี่ยวข้อง: https://www.polarsignals.com/blog/posts/2024/05/28/mostly-ds...
ฟังดูอาจจะซ้ำซากและเดาได้ง่าย แต่ข้อบ่นใหญ่ที่สุดเกี่ยวกับ Rust คือสถานการณ์เรื่อง การจัดการแพ็กเกจ และมองว่านี่เป็นผลจากแนวคิดของนักพัฒนาโดยตรง
ผมชอบการใช้งานฝั่ง Rust นะ แนวทางเชิงฟังก์ชันกับชนิดข้อมูลนั้นสวยงามมาก แต่ตอนนี้กำลังทำโปรเจ็กต์ Rust กับโปรเจ็กต์ Go ควบคู่กันอยู่ แล้วต้นไม้ dependency มันคนละเรื่องกันโดยสิ้นเชิง
โปรเจ็กต์ Go ส่วนใหญ่จัดการได้ด้วย standard library แต่โปรเจ็กต์ Rust แค่ขอ
rusqlite(sqlite),clap(CLI),ratatui(TUI),tauri(GUI) ก็ดูเหมือนจะมี dependency เกิน 400 ตัวแล้วโดยเฉพาะ
tauriนี่คือตัวการหลักแบบทิ้งห่าง และถึงจะเอามันออกก็ยังเกือบ 100 ตัวอยู่ดี เลยรู้สึกว่ามันบ้าพอสมควรถ้ามี crate ทางเลือกของ Rust ที่ดูแลดีและจัดการ dependency ได้สมเหตุสมผลกว่านี้ก็คงดีมาก แต่ยังหาไม่เจอ ผมแค่ไม่อยากเอา shai hulud เข้ามาไว้ในระบบเท่านั้นเอง แต่คนฝั่ง Rust web ดูเหมือนอยากทำให้
cargoกลายเป็นnpmในแง่นั้นเลยทำให้จำนวน dependency ดูเยอะกว่าความเป็นจริง แม้จะแยกเป็นคนละ crate แต่ก็มักมีผู้ดูแลคนเดียวกันและเป็นส่วนหนึ่งของ upstream Git repository เดียวกัน
ถึงอย่างนั้นก็เห็นด้วยกับความรู้สึกโดยรวม ใน Rust มี crate เวอร์ชัน 0.x ที่ถูกปล่อยกึ่งร้างอยู่เยอะ และบ่อยครั้งก็ไม่มีทางเลือกที่ดีกว่า
แล้วหลังจากนั้น
httplib3ก็ออกมา ก่อนจะตามด้วยhttplib4พูดอีกแบบคือผมชอบแนวทางของ Rust มากกว่า จะพึ่ง standard library หรือพึ่ง dependency อื่น สำหรับผมก็แทบไม่ต่างกัน ยังไงมันก็เป็น dependency อยู่ดี
การคิดว่าเพราะมันเป็น standard library แล้วคุณภาพต้องดีกว่าหรือดูแลรักษาดีกว่านั้นเป็นคนละเรื่องกัน
สุดท้ายทุกอย่างขึ้นกับทรัพยากร แน่นอนว่า standard library อาจได้รับทรัพยากรมากกว่า แต่ในทางกลับกันมันก็อาจพองตัวและดูแลรักษาไม่ได้เหมือนกัน
rusqlite,clap,ratatui,tauriอยู่ใน standard library หมดอีกอย่างต้องดูด้วยว่า Tauri เองประกอบด้วย 14 crate และแต่ละตัวก็จะโผล่ใน build tree
https://github.com/tauri-apps/tauri/blob/dev/Cargo.toml
Ratatui ก็มี 6 ตัว
https://github.com/ratatui/ratatui/blob/main/Cargo.toml
ยังไม่มีใคร “แก้” มันได้จริง และผมก็คิดว่าคงยากที่จะมีคำตอบเดียวในอนาคต
ใน Go คุณต้องเชื่อว่านักพัฒนาไลบรารีจะทำตาม semantic versioning อย่างถูกต้อง และคุณก็ล็อกเวอร์ชันไม่ได้ เรื่องนี้ส่วนตัวผมก็หงุดหงิดพอสมควร
มีวิธีเลี่ยงอยู่บ้าง เช่นใช้ SHA อย่าง Git commit hash เพื่อทำเวอร์ชันเทียม หรือใช้ vendoring ซึ่งเป็นแคช dependency ที่รู้จักกันดี แต่ vendoring ก็มาพร้อมปัญหาเรื่องการจัดการแคช
ช่วงสุดสัปดาห์ผมต้องใช้ Python virtual environment แล้วมันไม่จบสวย ทำให้นึกได้อีกครั้งว่าทำไมถึงหนีออกมาจาก Python
ไม่ว่าจะเป็น CPAN ของ Perl, Maven/Gradle ของ Java, gems ของ Ruby, dep/glide/vgo/modules ของ Go, Cargo ของ Rust, npm/yarn ของ Node ล้วนมีปัญหาคล้ายกันหมด
แม้แต่ระบบปฏิบัติการก็เหมือนกัน ไม่ว่าจะเป็น yum/rpm ของ Redhat, apt ของ Debian หรือ snap ของ Ubuntu โดยเฉพาะ snap นี่ไม่เข้าใจจริง ๆ ว่าทำไปทำไม
จาก use case นี้ มันอาจสมเหตุสมผลไหมที่จะคงฝั่ง frontend ไว้เป็น Go แล้วเปลี่ยนแค่ backend เป็น Rust
เอกสารนี้ให้ความรู้สึกแปลก ๆ เพราะพยายามเป็นทั้ง คู่มือการย้ายระบบ และเอกสารเชียร์ Rust ไปพร้อมกัน
สุดท้ายแล้วถ้ากำลังชั่งใจว่าจะใช้ Rust หรือ Go ประเด็นหลักแทบทั้งหมดจะสรุปลงที่คำถามว่า “คุณต้องการ managed runtime ไหม”
โปรแกรมเมอร์ Rust รุ่นหนึ่งพยายามโน้มน้าวตัวเองมาตลอดว่า managed runtime เป็นสิ่งไม่ดี และการไม่มีมันคือฟีเจอร์สำคัญ
แต่นั่นผิดอย่างชัดเจน มีงานเขียนโปรแกรมที่ต้องการ managed runtime มากกว่างานที่ไม่ต้องการ
ไม่ได้แปลว่าในกรณีแบบนั้น Go ต้องเป็นตัวเลือกตั้งต้นเสมอ ยังมีเหตุผลเชิงอัตวิสัยอีกมากที่ทำให้ชอบ Rust เวลาใช้ Go ผมคิดถึง
matchแต่ไม่ได้คิดถึงtokioกับ Async Rustทั้งสองอย่างล้วนเป็นตัวเลือกที่สมเหตุสมผลในแทบทุกกรณีที่คุณไม่ต้องบิดปัญหาให้เข้ากับเครื่องมือ เช่น การจะเขียน Linux kernel module ด้วย Go ก็ดูเป็นตัวเลือกประหลาดอยู่แล้ว
ศึก Rust ปะทะ Go ดูเป็นมุมชายขอบที่ทั้งแปลกและน่าเขินของวงการเรา ส่วนใหญ่ของอุตสาหกรรมกำลังสร้างระบบทั้งระบบด้วย Python หรือ Node ได้ดีอยู่แล้ว และคงหัวเราะพวกประหลาดที่เถียงกันว่าจะใช้ภาษา static type แบบคอมไพล์ตัวไหนดี คำถามจริงคือ Python ปะทะ Rust/Go ไม่ใช่ Rust ปะทะ Go
แต่โดยรวมแล้วฝั่ง Rust กับ Go ควรร่วมมือกันต่อสู้กับความชั่วร้ายของ dynamic typing
ถ้าตอนนี้ type hint ถูกมองว่าเป็นแนวปฏิบัติที่ดี นั่นก็แทบจะเป็นการยอมรับอยู่แล้วว่าเดิมมันมีข้อบกพร่อง
ต่อให้มี type hint ที่ดี มันก็ยังสู้ type inference ไม่ได้ เพราะ type inference ช่วยให้เปลี่ยนชนิดข้อมูลได้โดยไม่ต้องแก้โค้ดจำนวนมาก ขณะเดียวกันก็ป้องกันการเปลี่ยนชนิดข้อมูลโดยไม่ตั้งใจได้ด้วย
ผมแค่อยากให้ TS มี runtime มากกว่านี้ สิ่งเดียวที่ผมอิจฉา Python คือมันทำ JSON schema validation บน HTTP endpoint ได้อย่างเป็นธรรมชาติมาก
ขั้นตอนที่ต้องผ่าน Zod ยังเป็นแหล่งกำเนิดความรำคาญอยู่เสมอ และผมมองว่านี่เป็นปัญหาที่เกิดจากทีม TS มีความยึดมั่นแบบสุดโต่ง
ร่องรอยของการเขียนแบบ LLM กำลังยิ่งแนบเนียนขึ้นเรื่อย ๆ แต่ก็ยังสะดุดตาอยู่ดี โดยเฉพาะคำว่า genuine
ประมาณว่า “This is the area where Go genuinely shines, and it’s worth being precise about why”, “the lack of GC pauses is a genuine selling point”, “Humans are genuinely bad at reasoning about memory”, “There are cases where the borrow checker is genuinely too strict”
ผมไม่ได้คิดว่าทั้งบทความถูกสร้างโดย AI ทั้งหมด แต่ดูเหมือนจะมี AI ช่วยอยู่ ถ้าเป็นแบบนั้น ผู้เขียนก็ genuinely ทำออกมาได้ดีทีเดียว
ที่คนอื่น ๆ ไม่ค่อยพูดถึงเรื่องนี้ก็ดูเหมือนว่าเนื้อหาเองไม่ได้เสียหายมากนัก แต่การที่เรื่องแบบนี้เกิดขึ้นบ่อยขึ้นเรื่อย ๆ และจับได้ยากขึ้นเรื่อย ๆ ก็ให้ความรู้สึกแปลก ๆ
แค่อ่านถึงประมาณ “Go is clearly working for a lot of people,” ก็เริ่มสงสัยแล้วว่าอาจมี AI ช่วย แน่นอนว่าอาจจะไม่ใช่ก็ได้ และผมเองก็ไม่ได้เก่งเรื่องแยกแยะ
มันไม่ใช่เบาะแสที่ชัดเจน แต่แดกดันตรงที่มันใกล้เคียงกับ ความรู้สึก มากกว่า ถ้างานเขียนชิ้นไหน “ฟังดู” เหมือนมี AI ช่วย ต่อให้ตัวบทความโอเค ผมก็มักหมดความสนใจทันที
อยากให้คนรู้สึกสบายใจกับการเขียนความคิดของตัวเองออกมาตามวิธีที่มันผุดขึ้นมาในหัวมากกว่านี้
it's worth being precise about ...เป็นสำนวนที่ให้กลิ่น AI แรงกว่าการใช้ genuine มากตัวอย่างเช่นย่อหน้านี้: “Go got generics in 1.18, and they’re useful, but the implementation has constraints (no methods with type parameters, GC shape stenciling, occasional surprising performance characteristics). Rust generics monomorphize, each instantiation produces specialized code with zero runtime cost. Combined with traits, this gives you real zero-cost abstractions.”
ทุกประโยคกำลังบอกอะไรบางอย่าง ทุกประโยคสำคัญ และทุกประโยครับบทของตัวเองครบถ้วน งานเขียนแบบนี้ปกติจะคาดหวังจากหนังสือหรือบทความวิชาการที่เป็นมืออาชีพมาก ๆ มากกว่าจากบล็อกโพสต์
เลยยิ่งทำให้อ่านยากและน่าเบื่อขึ้นด้วยซ้ำ
ผมไม่ได้คาดหวังว่าข้อความที่ LLM สร้างจะไม่เต็มไปด้วยสำนวนซ้ำ ๆ อยู่แล้ว แต่อยากให้พวกเราทุกคนมีเซนส์ด้านการตัดต่อแก้ไขที่ดีขึ้น จะได้ไม่ต้องอ่านน้ำเสียงแบบเดียวกันซ้ำแล้วซ้ำอีก
ถ้าเป็นโปรเจกต์ใหม่ จะเขียนด้วย Rust ก็ได้เต็มที่
แต่ถ้าเป็นระบบที่มีโค้ดเดิมอยู่แล้ว ใช้งานได้จริง และทำเงินอยู่ ก็ควรแก้เฉพาะส่วนที่จำเป็นต้องเขียนใหม่ในภาษาเดิมแล้วเดินหน้าต่อ
ปรับปรุงระบบทีละเล็กทีละน้อยในแบบที่วัดผลได้ ด้วยภาษาที่คุณรู้จักและทีมที่คุณไว้ใจ เรื่องอื่นนอกจากนั้นก็เป็นแค่ สงครามศาสนา ที่สิ้นเปลือง
ผมชอบ Rust มาตั้งแต่ก่อนรันเบนช์มาร์กอยู่แล้ว แต่ความต่างด้านประสิทธิภาพที่ LLM ส่วนใหญ่เขียน Rust กับ Go ได้นั้นใหญ่กว่าที่คิดไว้มาก โดยเฉพาะใน agentic harness ที่สามารถแก้ปัญหาสภาพแวดล้อมตั้งต้นได้
หลังเห็นแบบนั้น ผมก็กลายเป็นคนเชียร์ Rust ค่อนข้างแรง ผมเคยทำ เครื่องมือประมวลผลแบบแบตช์ ที่เรียกใช้จากโค้ดเบสเดิมด้วย Rust แล้วได้ผลดีมาก แต่ยังไม่เคยลองย้าย production ทั้งระบบ
ปัญหาของ Go ที่บทความพูดถึง โดยเฉพาะปัญหาเกี่ยวกับการจัดการ
nilผมมองว่ากำลังถูกแก้ได้มากขึ้นเรื่อย ๆ หากรีวิวโค้ดอย่างเข้มงวดด้วย Codex แน่นอนว่าถ้าไม่มีปัญหาตั้งแต่แรกย่อมดีกว่า แต่สำหรับนักพัฒนาที่พร้อมทุ่มแรงให้กับการรีวิวและทำความเข้าใจพอ ๆ กับที่ทุ่มให้กับการออกแบบและการเขียน ระบบช่องโหว่ด้านความปลอดภัยแบบนี้กำลังกลายเป็นเรื่องที่เลือกได้ว่าจะยอมให้มีหรือไม่ข้อมูลภาษาอยู่ที่ https://gertlabs.com/rankings?mode=agentic_coding
Rust บังคับผู้ใช้ให้ขึ้นไปอยู่บนรางที่กำหนดไว้อย่างชัดเจน Codex ทำให้บางอย่างคอมไพล์ผ่านได้เสมอ
ข้อเสียคือ บางครั้งมันควรจะล้มเหลวเมื่อไม่สามารถใช้แนวทางที่เป็นสำนวนปกติได้ แต่กลับสร้าง implementation ทื่อ ๆ ที่คอมไพล์ได้และยังทำตามสิ่งที่ขอได้
LLM เขียนโค้ดได้เร็วกว่ามนุษย์ ดังนั้นสัดส่วนเวลาที่ต้องรอคอมไพล์จึงยิ่งมากขึ้น ในโปรเจกต์ที่มีขนาดพอสมควร เช่น 100k LOC ขึ้นไป การคอมไพล์ที่ช้ากว่าประมาณ 10 เท่าของ Rust จะเริ่มกลายเป็นคอขวด
ถ้ากำลังเขียนโครงสร้างพื้นฐานหลัก ค่าใช้จ่ายนั้นอาจคุ้ม แต่ถ้ากำลังทำบริการภายในที่ไม่ได้เปิดสู่สาธารณะบนอินเทอร์เน็ต ความเร็วในการพัฒนาอาจเป็นเรื่องที่สำคัญกว่า
ผมคิดว่าการคอมไพล์ช้ายังส่งผลต่อความเร็วในการพัฒนาของมนุษย์ด้วย แต่แปลกตรงที่นักพัฒนากลับไม่ค่อยพยายามวัดมันในเชิงปริมาณ
ถ้าความเยิ่นเย้อเป็นอุปสรรคหลัก สิ่งนี้ที่คาดว่าจะเข้ามาใน Go 1.28 อาจช่วยลดได้มาก
https://github.com/golang/go/issues/12854#issue-110104883
วลีที่ว่า “บริการที่องค์กรพึ่งพา ต้องการ uptime สูง และมีความสำคัญต่อธุรกิจอย่างยิ่งยวด” นี่ชวนขำดี
โดยเฉพาะตอนที่ บริการ Rust นั้นรันอยู่บน Kubernetes
ผมใช้ Rust อยู่แล้วและไม่มีประสบการณ์กับ Go ดังนั้นบทความนี้อาจไม่ได้ตรงกับผมนัก
แต่มีอยู่จุดหนึ่งที่ขัดใจ ใน Rust การพูดว่า data race “ถูกจับได้ตอนคอมไพล์” อย่างน้อยก็ฟังดูเกินจริงไปนิด
ถ้อยคำแบบนี้อาจทำให้คนเข้าใจว่า Rust จัดการเรื่องอย่าง lock starvation หรือปัญหาด้าน concurrency อื่น ๆ ได้ด้วย ซึ่งจริง ๆ แล้วไม่ใช่
ผมรู้ว่า data race เป็นคำทางการที่มีความหมายแคบ แต่ถึงอย่างนั้นก็คิดว่าน่าจะเขียนให้ชัดเจนกว่านี้ได้