Rust cho lập trình viên Go: hướng dẫn thực hành
Nếu bạn viết Go, bạn sẽ thấy Rust quen thuộc hơn bạn tưởng. Cả hai ngôn ngữ đều biên dịch thành một file binary duy nhất chạy nhanh. Cả hai đều có struct kèm phương thức thay cho class, không có kế thừa, trả lỗi về dưới dạng giá trị thay vì exception, có một công cụ định dạng chuẩn mà ai cũng dùng, và có một công cụ dòng lệnh duy nhất để build, test và tải gói. Một struct Go với vài phương thức chuyển sang Rust gần như từng dòng một.
Cái mới chủ yếu liên quan đến bộ nhớ và việc phải viết rõ ràng. Rust không có bộ thu gom rác (garbage collector): mỗi giá trị có đúng một chủ sở hữu, và trình biên dịch kiểm tra quyền sở hữu (ownership) và mượn (borrowing) trước khi chương trình chạy. Rust không có nil (bạn dùng Option), và if err != nil trở thành Result cùng toán tử ?. Interface vẫn tồn tại (chúng được gọi là trait), nhưng một kiểu phải nói rõ ra rằng nó triển khai trait đó. Hãy chuẩn bị dành phần lớn thời gian học cho quyền sở hữu. Phần còn lại sẽ giống một phiên bản Go khắt khe và chi tiết hơn.
Tại sao lập trình viên Go học Rust? Thường là vì một trong những lý do sau: một service mà những lần GC làm khựng hoặc lượng bộ nhớ dùng bắt đầu trở thành vấn đề, công việc nặng về CPU, code chạy không cần hệ điều hành (Rust nhúng) hoặc chạy trong trình duyệt (WebAssembly), hoặc đơn giản là muốn trình biên dịch bắt data race và những cú sập kiểu nil trước khi lên production. Nếu bạn muốn một bài so sánh rộng hơn giữa Rust với Go, C++ và Python, hãy xem Rust so với C++, Go và Python. Trang này đi sâu hơn và được viết riêng cho bạn, một lập trình viên Go.
Khái niệm ngôn ngữ: từ Go sang Rust
Phần tiêu đề “Khái niệm ngôn ngữ: từ Go sang Rust”Hãy dùng bảng này như một cuốn sổ tay hội thoại. Cột cuối dẫn đến bài học trên trang này giải thích phía Rust.
| Go | Rust | Tìm hiểu thêm |
|---|---|---|
| Bộ thu gom rác | Quyền sở hữu và mượn, được kiểm tra lúc biên dịch; bộ nhớ được giải phóng khi chủ sở hữu ra khỏi phạm vi | Quyền sở hữu, Mượn |
struct + phương thức |
struct cho dữ liệu + một khối impl cho các phương thức |
Struct |
Value receiver func (a Account) |
&self (đọc, không sao chép) hoặc self (lấy quyền sở hữu) |
Struct |
Pointer receiver func (a *Account) |
&mut self (có thể thay đổi giá trị) |
Struct |
Hàm constructor NewAccount(...) |
Một associated function, theo quy ước là Account::new(...) |
Struct |
| Viết hoa chữ cái đầu = exported | Từ khóa pub |
Module |
Giá trị zero (var c Config) |
Không có giá trị zero ngầm định; mọi field phải được gán. #[derive(Default)] + Config::default() |
Struct, Các trait thông dụng |
| Struct embedding | Không có embedding. Đặt struct vào một field và viết vài phương thức chuyển tiếp nhỏ, hoặc chia sẻ hành vi bằng trait | Trait, Mẫu thiết kế |
interface (được thỏa mãn ngầm định) |
trait (triển khai tường minh bằng impl Trait for Type) |
Trait |
[]Shape chứa nhiều kiểu khác nhau |
Vec<Box<dyn Shape>> (một trait object) |
Trait object |
any / interface{} + type switch |
Một enum với mỗi biến thể (variant) cho một trường hợp, rồi match |
Enum, match |
Generic func Max[T cmp.Ordered](a, b T) T |
Generic với ràng buộc trait (trait bound): fn max<T: PartialOrd>(a: T, b: T) -> T |
Generic, Generic nâng cao |
Con trỏ nil, map nil, interface nil |
Option<T>: hoặc Some(value) hoặc None |
Option |
v, ok := m[k] |
m.get(&k) trả về một Option |
HashMap |
(T, error) + if err != nil { return err } |
Result<T, E> + toán tử ? |
Result |
errors.New, sentinel error, errors.Is / errors.As |
Một enum lỗi mà bạn match trên đó (crate thiserror hỗ trợ) |
Lỗi tự định nghĩa, anyhow và thiserror |
fmt.Errorf("...: %w", err) |
anyhow với .context("..."), hoặc map_err |
anyhow và thiserror |
panic / recover |
panic! cho bug thật sự; hiếm khi cần khôi phục (std::panic::catch_unwind) |
Panic |
defer |
Drop, chạy khi một giá trị ra khỏi phạm vi (RAII) |
Phạm vi, Con trỏ thông minh |
go f() |
std::thread::spawn(...), hoặc tokio::spawn(...) cho task async |
Lập trình đồng thời, Async/Await |
chan T, make(chan T, n) |
std::sync::mpsc::channel() (không giới hạn) hoặc sync_channel(n); tokio::sync::mpsc trong code async |
Channel |
select |
tokio::select! trong code async, hoặc select! từ crossbeam-channel |
Async chuyên sâu |
sync.Mutex nằm cạnh dữ liệu |
Mutex<T> bọc chính dữ liệu; nó tự mở khóa khi guard bị drop |
Trạng thái dùng chung |
sync.WaitGroup |
JoinHandle::join(), hoặc std::thread::scope |
Lập trình đồng thời |
sync.Once |
std::sync::OnceLock / LazyLock |
Thành ngữ của std |
context.Context |
Không có thứ tương đương duy nhất: drop một future để hủy nó, tokio::time::timeout, hoặc CancellationToken từ tokio-util |
Async chuyên sâu |
Giá trị func và closure |
Closure, được mô tả bằng các trait Fn, FnMut, FnOnce |
Closure, Fn, FnMut và FnOnce |
for i, v := range s |
for (i, v) in s.iter().enumerate() |
Vòng lặp for, Iterator |
Vòng lặp for duy nhất |
loop, while và for |
Vòng lặp |
Iterator dạng range-over-func (iter.Seq) |
Trait Iterator, với các adapter như filter, map, sum, collect |
Iterator, Iterator tự định nghĩa |
switch |
match (trình biên dịch kiểm tra bạn đã xử lý mọi trường hợp) |
match, So khớp mẫu |
const + iota |
Một enum (có thể kèm số tường minh) |
Enum |
:= |
let (mặc định bất biến; let mut để cho phép thay đổi) |
Biến |
string (dãy byte, thường là UTF-8) |
String (sở hữu) và &str (mượn), luôn là UTF-8 hợp lệ |
Chuỗi |
rune, for _, r := range s |
char, for c in s.chars() |
Chuỗi |
[]byte |
Vec<u8> hoặc &[u8] |
Slice |
fmt.Println, %v, %+v |
println!, {} (Display), {:?} và {:#?} (Debug, với #[derive(Debug)]) |
In ra màn hình |
String() string (interface Stringer) |
Triển khai Display (rồi .to_string() tự động dùng được) |
Các trait thông dụng |
Struct tag `json:"user_name"` |
Attribute: #[serde(rename = "user_name")] |
JSON với Serde |
Chuyển đổi int(x), float64(n) |
as cho số, From/Into, và TryFrom khi việc chuyển đổi có thể thất bại |
Ép kiểu, Chuyển đổi |
| Tràn số nguyên quay vòng lặng lẽ | Panic trong bản build debug, quay vòng trong bản release; checked_add, wrapping_add, saturating_add nói rõ bạn muốn gì |
Tràn số nguyên |
*T và &x |
Tham chiếu &T và &mut T (không bao giờ null, không thể sống lâu hơn giá trị); Box<T> cho một giá trị sở hữu trên heap |
Mượn, Con trỏ thông minh |
| Package | Module (mod) bên trong một crate; use thay cho import |
Module |
go generate |
Macro và build script build.rs |
Macro, Procedural macro |
Package unsafe |
Khối unsafe và con trỏ thô (raw pointer) |
Unsafe |
| cgo | extern "C" và FFI (bindgen sinh binding từ header C) |
FFI (gọi C) |
Build tag //go:build linux |
#[cfg(target_os = "linux")] và feature của Cargo |
Feature của Cargo và cfg |
Collection
Phần tiêu đề “Collection”Go có slice và map được xây sẵn trong ngôn ngữ. Rust có chúng trong thư viện chuẩn, cùng với vài thứ khác.
| Go | Rust | Tìm hiểu thêm |
|---|---|---|
[]T mà bạn sở hữu và append vào |
Vec<T> |
Vector |
[]T truyền vào một hàm chỉ đọc nó |
&[T] (một slice mượn từ một Vec hoặc một mảng) |
Slice |
[N]T |
[T; N] |
Mảng |
append(s, x) / append(s, other...) |
v.push(x) / v.extend(other) |
Vector |
s[a:b] |
&v[a..b] |
Slice |
len(s), cap(s), make([]T, 0, n) |
v.len(), v.capacity(), Vec::with_capacity(n) |
Vector |
map[K]V |
HashMap<K, V> |
HashMap |
map[K]struct{} hoặc map[K]bool dùng làm set |
HashSet<T> |
HashSet và BTreeMap |
| Gom và sắp xếp khóa của map trước khi in | BTreeMap<K, V> (khóa luôn được sắp xếp) |
HashMap |
container/list |
VecDeque<T> (push_back / pop_front) |
VecDeque và BinaryHeap |
container/heap (phải triển khai 5 phương thức) |
BinaryHeap<T>: chỉ cần push và pop; lớn nhất ra trước, bọc trong Reverse để nhỏ nhất ra trước |
VecDeque và BinaryHeap |
slices.Sort, sort.Slice |
v.sort(), v.sort_by(...), v.sort_by_key(...) |
Sắp xếp và tìm kiếm |
slices.Contains, slices.Index |
v.contains(&x), v.iter().position(...) |
Vector |
strings.Builder |
Một String với push_str, hoặc write! |
Chuỗi |
sync.Map |
Mutex<HashMap<K, V>>, RwLock, hoặc crate dashmap |
Trạng thái dùng chung |
Có hai điều cần biết. Thứ nhất, giống map của Go, HashMap của Rust không có thứ tự cố định, nên hãy sắp xếp trước khi in hoặc dùng BTreeMap. Thứ hai, cái bẫy slice nổi tiếng của Go (hai slice dùng chung một mảng nền, và một lần append làm thay đổi slice kia) không thể xảy ra trong Rust: khi một phần code của bạn đang giữ một borrow &mut của một Vec, không ai khác được nhìn vào nó.
Công cụ: từ go sang cargo
Phần tiêu đề “Công cụ: từ go sang cargo”Tin tốt: bạn đã quen với một công cụ làm mọi việc, và Rust cũng có một công cụ như thế. Cargo bao trọn go build, go run, go test, go mod và go install.
| Go | Rust | Ghi chú |
|---|---|---|
Lệnh go |
cargo |
Build, chạy, test, thêm gói, publish. Xem Cargo và crate |
go mod init |
cargo new my_app (cargo new --lib cho thư viện) |
Tạo thư mục, Cargo.toml, src/main.rs và một git repo |
go.mod |
Cargo.toml |
|
go.sum |
Cargo.lock |
Được tạo và cập nhật tự động cho bạn |
go get github.com/gin-gonic/gin |
cargo add axum |
|
go get -u |
cargo update |
Cập nhật trong phạm vi phiên bản ghi trong Cargo.toml |
| pkg.go.dev | crates.io cho các gói, docs.rs (tiếng Anh) cho tài liệu API của chúng | |
go build / go run / go test |
cargo build / cargo run / cargo test |
Thêm --release để có bản build tối ưu; bản build debug chạy chậm hơn nhiều |
go vet, staticcheck, golangci-lint |
cargo clippy |
Chính thức, với hàng trăm lint hữu ích |
gofmt / goimports |
cargo fmt (rustfmt) |
Một phong cách chuẩn duy nhất, giống gofmt |
go doc |
cargo doc --open |
Ví dụ code trong doc comment được chạy như test. Xem Tài liệu |
go test -bench |
criterion (chạy bằng cargo bench) |
Xem Hiệu năng |
go test -cover |
cargo llvm-cov hoặc cargo tarpaulin |
|
go test -race |
Không cần với code an toàn: data race là lỗi biên dịch. Miri kiểm tra code unsafe |
|
go test -fuzz |
cargo fuzz |
Cài bằng cargo install cargo-fuzz |
| testify | assert! và assert_eq! có sẵn; pretty_assertions cho diff đẹp hơn |
Xem Kiểm thử và Kiểm thử nâng cao |
| gomock / mockery | mockall, hoặc tự viết bản giả (fake) triển khai trait của bạn |
|
go install pkg@latest |
cargo install name |
|
go work (workspace) |
Một Cargo workspace | Một Cargo.toml ở thư mục gốc liệt kê các crate thành viên |
Dòng toolchain trong go.mod |
rustup và một file rust-toolchain.toml |
Xem Công cụ và cài đặt |
GOOS=linux GOARCH=arm64 go build |
rustup target add aarch64-unknown-linux-gnu + cargo build --target ... |
Thường cần một linker cho target đó; cross hoặc cargo-zigbuild giúp việc này dễ hơn |
| govulncheck | cargo audit (cảnh báo RustSec), cargo deny |
|
| gopls | rust-analyzer | Chạy trong VS Code, Neovim, Helix, Zed và nhiều editor khác |
| GoLand | JetBrains RustRover | |
| Delve | rust-gdb / rust-lldb, hoặc CodeLLDB trong VS Code |
|
| pprof | cargo flamegraph, perf, hoặc bất kỳ profiler native nào |
|
| air (tự tải lại) | bacon hoặc cargo watch |
Một Cargo.toml nhỏ đặt cạnh file go.mod mà bạn đã biết:
module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0[package]name = "orders-api"version = "0.1.0"edition = "2021"
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }Có một khác biệt bạn sẽ nhận ra ngay: thư viện chuẩn của Go có sẵn HTTP server, JSON, template và mã hóa. Thư viện chuẩn của Rust cố ý nhỏ hơn, nên những thứ như JSON, HTTP, async và số ngẫu nhiên đến từ các crate. Thêm một crate chỉ cần một lệnh cargo add, và có một nhóm nhỏ crate (serde, tokio, clap, regex, rand) mà gần như ai cũng dùng.
Thư viện và framework: “Vậy thay vì … thì dùng gì?”
Phần tiêu đề “Thư viện và framework: “Vậy thay vì … thì dùng gì?””Đây là câu hỏi thực tế đầu tiên. “Bọn mình dùng Gin và GORM. Trong Rust có gì?” Dưới đây là những thứ tương đương gần nhất. Giống Go, Rust thường có hai ba lựa chọn cộng đồng đều tốt thay vì một câu trả lời chính thức.
Web và mạng
Phần tiêu đề “Web và mạng”| Go | Rust | Ghi chú |
|---|---|---|
| Gin, Echo, Chi, Fiber | axum (chúng ta xây một cái trong Dự án: Web API), actix-web, Rocket | axum và actix-web được dùng rộng rãi trong production |
Server net/http |
hyper (cấp thấp), thường được dùng qua axum |
Thư viện chuẩn không có HTTP server |
Middleware http.Handler |
Các layer của tower và tower-http (CORS, nén, tracing, timeout) |
axum dùng trực tiếp tower |
Client net/http |
reqwest |
Mặc định bất đồng bộ, có client blocking tùy chọn. Xem Lập trình mạng |
| grpc-go | tonic (kèm prost cho protobuf) |
|
| gorilla/websocket | tokio-tungstenite, hoặc feature ws của axum |
|
html/template, text/template |
askama (kiểm tra lúc biên dịch), tera, minijinja |
|
net (TCP/UDP) |
std::net, hoặc tokio::net cho code async |
Xem Lập trình mạng |
| errgroup | tokio::task::JoinSet |
Dữ liệu
Phần tiêu đề “Dữ liệu”| Go | Rust | Ghi chú |
|---|---|---|
encoding/json + struct tag |
serde + serde_json |
Còn làm được TOML (crate toml) và các định dạng khác. Xem JSON với Serde |
database/sql + pgx |
sqlx (bất đồng bộ, SQL thuần được kiểm tra với cơ sở dữ liệu của bạn lúc biên dịch), tokio-postgres |
Không liên quan gì tới jmoiron/sqlx của Go, dù trùng tên |
| sqlc | Các macro query! của sqlx |
Cả hai đều bắt đầu từ SQL do bạn viết và cho bạn kết quả có kiểu |
| GORM | SeaORM (bất đồng bộ, kiểu entity), Diesel (query builder được kiểm tra kiểu) | Xem Cơ sở dữ liệu |
| go-sqlite3 | rusqlite |
|
| go-redis | redis |
CLI, cấu hình, ghi log và công cụ hằng ngày
Phần tiêu đề “CLI, cấu hình, ghi log và công cụ hằng ngày”| Go | Rust | Ghi chú |
|---|---|---|
cobra, urfave/cli, flag |
clap (derive đối số từ một struct) |
Xem Đối số dòng lệnh và Dự án: công cụ CLI với clap |
| viper | config hoặc figment |
Xếp chồng file và biến môi trường, rồi deserialize bằng serde |
log/slog, zap, zerolog |
tracing + tracing-subscriber, hoặc log + env_logger |
Log có cấu trúc. Xem Ghi log |
regexp |
regex |
Cùng ý tưởng với RE2 của Go: so khớp thời gian tuyến tính, không có backreference. Xem Biểu thức chính quy |
time |
std::time cho khoảng thời gian; chrono hoặc jiff cho ngày tháng và múi giờ |
Xem Ngày và giờ |
embed |
include_str! / include_bytes!, hoặc rust-embed cho cả thư mục |
|
| bubbletea | ratatui |
Giao diện terminal |
| Prometheus client_golang | prometheus hoặc metrics |
|
sync/atomic |
std::sync::atomic |
Desktop, game và nhúng
Phần tiêu đề “Desktop, game và nhúng”| Go | Rust | Ghi chú |
|---|---|---|
| Wails | Tauri (giao diện web + backend Rust) | Ý tưởng rất giống nhau. Xem Ứng dụng giao diện (GUI) |
| Fyne | egui, Iced, Slint |
Không có bộ công cụ GUI chính thức |
| Ebitengine | macroquad, Bevy |
Bevy chưa tới 1.0 và thay đổi API giữa các phiên bản. Xem Phát triển game |
| TinyGo | embedded-hal, Embassy |
Rust chạy được mà không cần hệ điều hành và không cần heap. Xem Rust nhúng (embedded) |
GOOS=js GOARCH=wasm |
wasm-bindgen, Leptos, Yew, Dioxus |
Binary nhỏ, không phải mang theo GC. Xem Rust và WebAssembly |
Đặt cạnh nhau: struct và phương thức (gần như giống hệt)
Phần tiêu đề “Đặt cạnh nhau: struct và phương thức (gần như giống hệt)”Đây là chỗ lập trình viên Go thấy như ở nhà. Đây là một kiểu tài khoản nhỏ viết bằng Go:
type Account struct { Owner string // exported: viết hoa chữ cái đầu balance int // không exported}
func NewAccount(owner string, balance int) *Account { return &Account{Owner: owner, balance: balance}}
func (a *Account) Deposit(amount int) { a.balance += amount }func (a *Account) Balance() int { return a.balance }func (a *Account) String() string { return fmt.Sprintf("%s: %d", a.Owner, a.balance) }
func main() { account := NewAccount("Ada", 100) account.Deposit(50) fmt.Println(account) fmt.Println("số dư là", account.Balance())}Và bằng Rust:
use std::fmt;
// `pub` làm việc của chữ cái viết hoa trong Go.pub struct Account { pub owner: String, balance: i64, // private, giống một field viết thường trong Go}
impl Account { // Giống NewAccount của Go: một hàm bình thường. `new` chỉ là một quy ước. pub fn new(owner: &str, balance: i64) -> Self { Account { owner: owner.to_string(), balance } }
// Giống một pointer receiver: phương thức này thay đổi tài khoản. pub fn deposit(&mut self, amount: i64) { self.balance += amount; }
// Chỉ đọc. Không có bản sao nào được tạo, khác với value receiver của Go. pub fn balance(&self) -> i64 { self.balance }}
// Phương thức String() của Go (interface Stringer) trở thành Display.impl fmt::Display for Account { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.owner, self.balance) }}
fn main() { let mut account = Account::new("Ada", 100); account.deposit(50); println!("{account}"); println!("số dư là {}", account.balance());}Kết quả:
Ada: 150số dư là 150Hình dạng thì giống nhau: dữ liệu nằm trong một struct, phương thức gắn vào nó, constructor chỉ là một hàm. Những khác biệt tuy nhỏ nhưng quan trọng. Các phương thức nằm chung trong một khối impl. Receiver nói chính xác phương thức làm gì: &self thì đọc, &mut self thì thay đổi, còn self thuần thì lấy giá trị và dùng hết nó. Và vì deposit thay đổi tài khoản, biến phải được khai báo let mut. Trong Go, ai giữ một *Account cũng có thể thay đổi nó; trong Rust, quyền được thay đổi một thứ hiện rõ ngay trong code.
Đặt cạnh nhau: interface ngầm định trở thành trait tường minh
Phần tiêu đề “Đặt cạnh nhau: interface ngầm định trở thành trait tường minh”Trong Go, một kiểu thỏa mãn một interface chỉ cần có đúng các phương thức:
type Shape interface { Area() float64 Name() string}
type Circle struct{ R float64 }type Rect struct{ W, H float64 }
// Không có "implements" ở đâu cả: có đủ phương thức là được.func (c Circle) Area() float64 { return math.Pi * c.R * c.R }func (c Circle) Name() string { return "hình tròn" }func (r Rect) Area() float64 { return r.W * r.H }func (r Rect) Name() string { return "hình chữ nhật" }
func describe[T Shape](s T) { fmt.Printf("%s: %.2f\n", s.Name(), s.Area())}
func main() { describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}} total := 0.0 for _, s := range shapes { total += s.Area() } fmt.Printf("tổng diện tích: %.2f\n", total)}Trong Rust, bạn viết rõ mối liên hệ đó bằng impl Shape for Circle:
use std::f64::consts::PI;
trait Shape { fn area(&self) -> f64; fn name(&self) -> &str;}
struct Circle { r: f64,}
struct Rect { w: f64, h: f64,}
// Dòng này là phần mà Go bỏ qua.impl Shape for Circle { fn area(&self) -> f64 { PI * self.r * self.r } fn name(&self) -> &str { "hình tròn" }}
impl Shape for Rect { fn area(&self) -> f64 { self.w * self.h } fn name(&self) -> &str { "hình chữ nhật" }}
// Generic, giống describe[T Shape](s T) của Go.fn describe<T: Shape>(s: &T) { println!("{}: {:.2}", s.name(), s.area());}
fn main() { describe(&Circle { r: 1.0 });
// Giống []Shape của Go: nhiều kiểu khác nhau đứng sau một trait. let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { r: 1.0 }), Box::new(Rect { w: 2.0, h: 3.0 }), ]; let total: f64 = shapes.iter().map(|s| s.area()).sum(); println!("tổng diện tích: {total:.2}");}Kết quả:
hình tròn: 3.14tổng diện tích: 9.14Viết tường minh tốn thêm một dòng cho mỗi kiểu, và đổi lại bạn có vài thứ mà Go không làm được. Một trait có thể có phương thức mặc định, nên các kiểu có hành vi dùng chung mà không phải viết gì. Bạn có thể triển khai trait của riêng mình cho những kiểu không do bạn viết, kể cả i32 hay String (Go chỉ cho bạn thêm phương thức vào các kiểu trong package của chính bạn). Và hàm generic describe được biên dịch riêng cho từng kiểu cụ thể, nên lời gọi không phải tra cứu gì lúc chạy. Box<dyn Shape> là phong cách interface của Go: một giá trị trỏ tới “một kiểu nào đó triển khai Shape”, với phương thức được tra cứu lúc chạy. Xem Trait và Trait object.
Đặt cạnh nhau: nil trở thành Option
Phần tiêu đề “Đặt cạnh nhau: nil trở thành Option”Trong Go, một con trỏ có thể bị thiếu là nil, và bạn phải nhớ kiểm tra:
type User struct { Name string Manager *User // nil nghĩa là "không có quản lý"}
func findUser(users []*User, name string) *User { for _, u := range users { if u.Name == name { return u } } return nil}
func main() { ada := &User{Name: "Ada"} users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} { u := findUser(users, name) if u == nil { fmt.Println(name + ": không có người dùng này") continue } boss := "không ai" if u.Manager != nil { boss = u.Manager.Name } fmt.Println(name, "báo cáo cho", boss) }}Trong Rust, “có thể bị thiếu” là một phần của kiểu. Option<&User> hoặc là Some(user) hoặc là None, và trình biên dịch không cho bạn dùng user cho tới khi bạn đã xử lý None:
struct User { name: String, manager: Option<String>, // Go: Manager *User, có thể là nil}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> { users.iter().find(|u| u.name == name)}
fn main() { let users = vec![ User { name: "Ada".to_string(), manager: None }, User { name: "Bob".to_string(), manager: Some("Ada".to_string()) }, ];
for name in ["Bob", "Ada", "Eve"] { match find_user(&users, name) { Some(user) => { let boss = user.manager.as_deref().unwrap_or("không ai"); println!("{name} báo cáo cho {boss}"); } None => println!("{name}: không có người dùng này"), } }}Kết quả:
Bob báo cáo cho AdaAda báo cáo cho không aiEve: không có người dùng nàyCùng ý tưởng đó thay thế cách tra cứu comma-ok của Go. if email, ok := emails[name]; ok { ... } trở thành if let Some(email) = emails.get(name) { ... }. Có một điều cần để ý: trong Go, emails["missing"] lặng lẽ cho bạn giá trị zero "". Trong Rust, emails["missing"] gây panic, nên hãy dùng .get() mỗi khi khóa có thể không tồn tại. Chữ 'a trong find_user là một lifetime (vòng đời): nó cho trình biên dịch biết rằng &User trả về trỏ vào bên trong users. Bạn sẽ sớm gặp chúng.
Đặt cạnh nhau: if err != nil trở thành Result và ?
Phần tiêu đề “Đặt cạnh nhau: if err != nil trở thành Result và ?”Cách xử lý lỗi của Go nổi tiếng với mẫu ba dòng của nó:
func parsePort(s string) (int, error) { n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("cổng không hợp lệ %q: %w", s, err) } if n < 1 || n > 65535 { return 0, fmt.Errorf("cổng %d nằm ngoài phạm vi cho phép", n) } return n, nil}
func address(host, port string) (string, error) { p, err := parsePort(port) if err != nil { return "", err } return fmt.Sprintf("%s:%d", host, p), nil}
func main() { for _, port := range []string{"8080", "abc", "70000", "0"} { addr, err := address("localhost", port) if err != nil { fmt.Println("lỗi:", err) continue } fmt.Println("đang lắng nghe trên", addr) }}Rust giữ lại ý tưởng (lỗi là giá trị bạn trả về) nhưng gói cả mẫu đó vào một ký tự, ?:
fn parse_port(s: &str) -> Result<u16, String> { // Một u16 không chứa nổi 70000, nên parse() bắt lỗi “ngoài phạm vi” giúp chúng ta. let port: u16 = s.parse().map_err(|e| format!("cổng không hợp lệ {s:?}: {e}"))?; if port == 0 { return Err("cổng 0 được dành riêng".to_string()); } Ok(port)}
fn address(host: &str, port: &str) -> Result<String, String> { // `?` nghĩa là: nếu đây là lỗi, trả nó về ngay lập tức. let port = parse_port(port)?; Ok(format!("{host}:{port}"))}
fn main() { for port in ["8080", "abc", "70000", "0"] { match address("localhost", port) { Ok(addr) => println!("đang lắng nghe trên {addr}"), Err(e) => println!("lỗi: {e}"), } }}Kết quả:
đang lắng nghe trên localhost:8080lỗi: cổng không hợp lệ "abc": invalid digit found in stringlỗi: cổng không hợp lệ "70000": number too large to fit in target typelỗi: cổng 0 được dành riêngHai phần “invalid digit found in string” (gặp chữ số không hợp lệ trong chuỗi) và “number too large to fit in target type” (số quá lớn so với kiểu đích) là thông báo lỗi có sẵn của Rust, nên chúng vẫn là tiếng Anh.
Có một khác biệt sâu xa hơn việc tiết kiệm vài dòng. Trong Go, n, err := strconv.Atoi(s) cho bạn cả hai giá trị, và chẳng gì ngăn bạn dùng n khi err không phải nil. Một Result thì hoặc là giá trị hoặc là lỗi, nên bạn không thể chạm tới giá trị mà không xử lý lỗi trước. Và nếu bạn bỏ qua hoàn toàn một Result, trình biên dịch sẽ cảnh báo.
Trong dự án thật, lỗi kiểu String được thay bằng thứ phong phú hơn. Một enum lỗi (thường viết bằng crate thiserror) đóng vai trò của sentinel error và errors.Is: nơi gọi match trên biến thể. Crate anyhow cho bạn .context("loading config")?, chính là fmt.Errorf("loading config: %w", err) của Rust. Xem Lỗi tự định nghĩa và anyhow và thiserror.
Đặt cạnh nhau: goroutine và channel trở thành thread, mpsc và task
Phần tiêu đề “Đặt cạnh nhau: goroutine và channel trở thành thread, mpsc và task”Đây là mẫu fan-in kinh điển của Go: khởi động vài goroutine, gửi kết quả xuống một channel, đóng channel khi chúng xong việc:
func main() { results := make(chan int) var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} { wg.Add(1) go func(n int) { defer wg.Done() results <- n * n }(n) }
go func() { wg.Wait() close(results) }()
total := 0 for r := range results { total += r } fmt.Println("tổng:", total)}Phiên bản Rust dùng luồng (thread) và channel của thư viện chuẩn còn ngắn hơn một chút:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] { let tx = tx.clone(); // mỗi luồng có sender riêng thread::spawn(move || { tx.send(n * n).unwrap(); }); }
// Drop sender của chính chúng ta. Khi mọi sender đều đã mất, channel // đóng lại và vòng lặp bên dưới kết thúc: không cần WaitGroup. drop(tx);
let total: i32 = rx.iter().sum(); println!("tổng: {total}");}Kết quả:
tổng: 14Vài thứ ánh xạ khác đi. mpsc nghĩa là “multi-producer, single-consumer” (nhiều bên gửi, một bên nhận): bạn có thể clone sender, nhưng không clone được receiver (với channel nhiều bên đọc kiểu Go, hãy dùng crossbeam-channel). mpsc::channel() là không giới hạn, thứ mà Go không có; mpsc::sync_channel(n) là make(chan T, n) của Go, và sync_channel(0) là một channel không có bộ đệm. Không ai gọi close: channel tự đóng khi sender cuối cùng bị drop. move trao cho mỗi luồng bản sao n và tx của riêng nó, đó là cách Rust bảo đảm các luồng không bao giờ dùng chung dữ liệu một cách không an toàn. Xem Channel và Lập trình đồng thời.
thread::spawn khởi động một luồng thật của hệ điều hành, nặng hơn một goroutine. Với vài worker thì không sao. Với hàng nghìn kết nối đồng thời, Rust dùng task async trên một runtime như tokio, thứ gần với goroutine hơn nhiều:
#[tokio::main]async fn main() { let mut handles = Vec::new();
for n in [1, 2, 3] { // Giống `go func() { ... }()`, nhưng bạn nhận lại một handle. handles.push(tokio::spawn(async move { n * n })); }
let mut total = 0; for handle in handles { total += handle.await.unwrap(); } println!("tổng: {total}");}Kết quả:
tổng: 14Nói thật: đây là một chỗ Go đơn giản hơn. Trong Go, hàm nào cũng có thể block và runtime lo việc đó. Trong Rust, bạn đánh dấu hàm là async, gọi chúng bằng .await, và chọn một runtime. Bù lại, trình biên dịch từ chối build code mà hai luồng có thể cùng thay đổi một dữ liệu một lúc, nên go test -race chẳng còn gì để tìm. Xem Async/Await, Async chuyên sâu và Trạng thái dùng chung để biết về Arc<Mutex<T>>, phiên bản Rust của một struct có sync.Mutex nằm cạnh dữ liệu.
Đặt cạnh nhau: defer trở thành Drop
Phần tiêu đề “Đặt cạnh nhau: defer trở thành Drop”Trong Go, bạn lên lịch dọn dẹp bằng defer, và các lời gọi defer chạy theo thứ tự ngược lại khi hàm trả về:
func process() { fmt.Println("mở file") defer fmt.Println("đóng file")
fmt.Println("khóa mutex") defer fmt.Println("mở khóa mutex")
fmt.Println("đang làm việc")}
func main() { process() fmt.Println("xong")}Trong Rust, việc dọn dẹp thuộc về chính giá trị. Khi một giá trị ra khỏi phạm vi (scope), phần triển khai Drop của nó chạy, và các giá trị được drop theo thứ tự ngược lại, y như các lệnh defer của bạn:
struct Guard(&'static str);
impl Drop for Guard { fn drop(&mut self) { println!("{}", self.0); }}
fn process() { println!("mở file"); let _file = Guard("đóng file");
println!("khóa mutex"); let _lock = Guard("mở khóa mutex");
println!("đang làm việc");} // _lock bị drop ở đây, rồi tới _file
fn main() { process(); println!("xong");}Kết quả:
mở filekhóa mutexđang làm việcmở khóa mutexđóng filexongTrong code thật, bạn hiếm khi tự viết Drop: File tự đóng, MutexGuard tự mở khóa, TcpStream tự ngắt kết nối. Chẳng có gì để quên. Có hai khác biệt so với defer đáng để biết. Drop chạy ở cuối khối lệnh, không phải cuối hàm, nên cái bẫy defer chồng chất bên trong vòng lặp của Go không tồn tại: mỗi lượt lặp tự dọn dẹp sau khi xong. Và hãy cẩn thận với let _ = Guard(...): dấu _ đứng một mình không giữ giá trị lại chút nào, nên nó bị drop ngay lập tức. Hãy dùng một cái tên như _lock.
Những chỗ dễ vấp với lập trình viên Go
Phần tiêu đề “Những chỗ dễ vấp với lập trình viên Go”Ai đến từ Go cũng vấp phải cùng những ổ gà này. Biết trước sẽ giúp bạn nhiều.
-
Truyền một giá trị có thể move nó. Trong Go, truyền một slice vào hàm sẽ sao chép một header nhỏ và cả hai bên vẫn dùng tiếp được. Trong Rust, truyền một
Vecsẽ chuyển (move) nó vào hàm, và bạn không dùng được nó sau đó nữa:fn total(prices: Vec<u32>) -> u32 {prices.iter().sum()}fn main() {let prices = vec![3, 4, 5];let sum = total(prices);println!("{sum} from {} items", prices.len());}error[E0382]: borrow of moved value: `prices`--> src/main.rs:8:37|6 | let prices = vec![3, 4, 5];| ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait7 | let sum = total(prices);| ------ value moved here8 | println!("{sum} from {} items", prices.len());| ^^^^^^ value borrowed here after moveTrình biên dịch báo rằng
pricesđã bị move vàototalở dòng 7, nên dòng 8 không được dùng nó nữa. Cách sửa chính là thói quen của Go trong hình hài Rust: cho hàm mượn một slice,fn total(prices: &[u32]), và gọi nó bằngtotal(&prices). Hãy đọc Quyền sở hữu trước; bài đó giải thích quy tắc này. Trang E0382 nói chi tiết về lỗi này. -
Borrow checker. Tại một thời điểm, bạn có thể có nhiều người đọc (
&T) hoặc một người ghi (&mut T), không bao giờ cả hai. Code Go mà nhiều struct cùng giữ con trỏ tới một struct dùng chung rồi thay đổi nó cần một thiết kế khác trong Rust, hoặc con trỏ thông minh nhưRc<RefCell<T>>hayArc<Mutex<T>>. Hãy chuẩn bị cho vài tuần bực bội. Rồi mọi thứ sẽ dễ hơn nhiều. -
Không có giá trị zero.
let x: i32;rồiprintln!("{x}")là lỗi biên dịch, và mọi field của struct phải được gán khi bạn tạo struct. Nếu bạn muốn giá trị mặc định kiểu Go, hãy deriveDefaultvà viếtConfig { port: 8080, ..Default::default() }. -
Triển khai trait là việc tường minh. Có một phương thức
areakhông biến kiểu của bạn thành mộtShape. Bạn cầnimpl Shape for Circle. Mặt tốt: bạn có thể thấy mọi phần triển khai chỉ bằng cách tìm kiếm. -
Stringvà&str. Go có một kiểustring. Rust có hai:Stringsở hữu văn bản của nó,&strthì mượn. Hàm thường nhận&str, struct thường lưuString. Và bạn không thể đánh chỉ số vào chuỗi bằngs[0]như trong Go; hãy dùngs.chars()hoặcs.as_bytes()[0]. Xem Chuỗi. -
Thứ không dùng đến chỉ là cảnh báo, không phải lỗi. Go từ chối biên dịch khi có import hay biến không dùng. Rust chỉ cảnh báo, và bạn có thể thêm tiền tố
_vào tên để nói “cố ý không dùng”. -
Tràn số nguyên. Go quay vòng lặng lẽ. Rust gây panic trong bản build debug và quay vòng trong bản build release, nên hãy chọn
checked_add,wrapping_addhoặcsaturating_addkhi có thể bị tràn. Số nguyên viết trực tiếp mặc định lài32, và chỉ số có kiểuusize. Xem Tràn số nguyên. -
Thư viện chuẩn nhỏ hơn. Không có HTTP server, không có JSON, không có runtime async trong
std. Bạn sẽcargo addchúng. Lúc đầu thấy hơi lạ, nhưng các crate phổ biến đều ổn định và có tài liệu tốt. -
Async cần một runtime và lan rộng ra. Một
async fnkhông làm gì cả cho tới khi được await bên trong một runtime như tokio, và chỉ code async mới.awaitđược. Go không có sự chia tách này. Hãy bắt đầu với luồng bình thường; chỉ dùng async khi bạn cần nhiều kết nối đồng thời. -
Thời gian biên dịch. Go được tạo ra để biên dịch rất nhanh. Rust biên dịch chậm hơn, nhất là build sạch có nhiều crate. Hãy dùng
cargo checktrong lúc sửa code; nó bỏ qua bước sinh mã và nhanh hơn nhiều. -
Biên dịch chéo cần thiết lập một chút.
GOOS=linux go buildlà chạy luôn. Trong Rust, bạn thêm target bằng rustup và thường cần cả một linker cho target đó. Những công cụ nhưcrosslo việc đó giúp bạn.
Go hay Rust? Khi nào chọn cái nào
Phần tiêu đề “Go hay Rust? Khi nào chọn cái nào”Cả hai đều là lựa chọn tốt, và nhiều nhóm dùng cả hai. Đây là một quy tắc kinh nghiệm công bằng.
Hãy chọn Go khi bạn muốn nhanh chóng đưa ra các network service và công cụ nội bộ, nhóm của bạn coi trọng code mà ai cũng đọc hiểu được sau một tuần, tốc độ build rất quan trọng, hoặc bạn làm việc trong thế giới cloud-native (Kubernetes, Docker, Terraform) nơi Go là ngôn ngữ chung. Thư viện chuẩn của Go và mô hình đồng thời đơn giản của nó khiến nhiều chương trình server rất dễ viết.
Hãy chọn Rust khi bạn cần độ trễ có thể dự đoán mà không có những lần khựng vì GC, cần dùng ít bộ nhớ, hoặc cần từng chút sức mạnh CPU (parser, cơ sở dữ liệu, game engine, video, nén dữ liệu). Rust cũng phù hợp hơn cho code không cần hệ điều hành, cho WebAssembly, và cho thư viện mà bạn muốn gọi từ các ngôn ngữ khác. Và nó đáng giá khi một cú sập vì data race hay vì quên kiểm tra nil gây tốn kém. Discord từng viết một bài nổi tiếng về việc chuyển một service từ Go sang Rust (tiếng Anh) vì những đợt tăng độ trễ do bộ thu gom rác gây ra.
Cái giá của Rust là thời gian học và build chậm hơn. Cái giá của Go là một bộ thu gom rác và những bug lộ ra lúc chạy thay vì lúc biên dịch. Cái giá nào đau hơn thì tùy vào dự án của bạn.
Lộ trình học cho lập trình viên Go
Phần tiêu đề “Lộ trình học cho lập trình viên Go”Bạn không cần đọc trang này theo thứ tự. Là lập trình viên Go, lộ trình dưới đây bỏ qua những gì bạn đã biết và dành thời gian cho chỗ Rust khác biệt:
- Bắt đầu với Rust và Công cụ và cài đặt: cài Rust, thiết lập rust-analyzer, và chạy
cargo new. - Biến và Kiểu dữ liệu: đọc lướt, chủ yếu để biết
let mutvà các kiểu số. - Quyền sở hữu, rồi Mượn, rồi Slice: những trang quan trọng nhất với bạn. Cứ từ từ.
- Chuỗi:
Stringvà&str. - Struct và Enum, rồi match và So khớp mẫu. Enum có dữ liệu của Rust thay thế được nhiều type switch của Go.
- Option và Result, rồi Lỗi tự định nghĩa và anyhow và thiserror.
- Trait, Generic, Các trait thông dụng và Trait object.
- Closure và Iterator: Rust dựa vào chuỗi iterator nhiều hơn Go rất nhiều.
- Vector, HashMap và HashSet và BTreeMap.
- Module, Cargo và crate và Kiểm thử.
- Lifetime và Con trỏ thông minh.
- Lập trình đồng thời, Channel, Trạng thái dùng chung, rồi Async/Await.
- JSON với Serde và Ghi log, rồi xây dự án Web API.
Luyện tập
Phần tiêu đề “Luyện tập”Cả hai bài tập đều cho bạn một đoạn Go. Hãy chuyển nó sang Rust.
1. Chuyển một chương trình đếm từ
Phần tiêu đề “1. Chuyển một chương trình đếm từ”Chương trình Go này đếm số từ và in chúng theo thứ tự bảng chữ cái. Hãy chuyển nó sang Rust. Gợi ý: dùng một HashMap<&str, u32> và *counts.entry(word).or_insert(0) += 1 thay cho counts[w]++ của Go. text.split_whitespace() chính là strings.Fields.
func main() { text := "the cat and the dog and the bird"
counts := map[string]int{} for _, w := range strings.Fields(text) { counts[w]++ }
keys := make([]string, 0, len(counts)) for k := range counts { keys = append(keys, k) } sort.Strings(keys)
for _, k := range keys { fmt.Printf("%s: %d\n", k, counts[k]) }}Câu mẫu được giữ nguyên tiếng Anh vì kết quả được sắp xếp theo bảng chữ cái.
Xem lời giải
use std::collections::HashMap;
fn main() { let text = "the cat and the dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split_whitespace() { *counts.entry(word).or_insert(0) += 1; }
let mut keys: Vec<&str> = counts.keys().copied().collect(); keys.sort();
for key in keys { println!("{key}: {}", counts[key]); }}Kết quả:
and: 2bird: 1cat: 1dog: 1the: 3Ở đây không có giá trị zero nào để dựa vào như trong Go, nên entry(word).or_insert(0) nói rằng “bắt đầu từ 0 nếu từ này mới”. Đánh chỉ số bằng counts[key] là an toàn vì mọi khóa đều lấy từ chính map đó. Còn ngắn hơn nữa: dùng BTreeMap thay cho HashMap, các khóa sẽ ra theo thứ tự đã sắp xếp, và bạn bỏ được vector keys.
2. Chuyển một sentinel error
Phần tiêu đề “2. Chuyển một sentinel error”Đoạn Go này trả về một sentinel error và kiểm tra nó bằng errors.Is. Hãy chuyển nó sang Rust sao cho withdraw trả về Result<(), WithdrawError>, trong đó WithdrawError là một enum có hai biến thể. Gợi ý: triển khai fmt::Display cho enum để có thể in nó, và match trên biến thể ở chỗ Go dùng errors.Is.
var ErrInsufficientFunds = errors.New("không đủ tiền")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error { if n <= 0 { return fmt.Errorf("số tiền không hợp lệ %d", n) } if n > a.Balance { return ErrInsufficientFunds } a.Balance -= n return nil}
func main() { acc := &Account{Balance: 100} for _, n := range []int{30, 0, 500} { if err := acc.Withdraw(n); err != nil { if errors.Is(err, ErrInsufficientFunds) { fmt.Println("bị từ chối:", err) } else { fmt.Println("lỗi:", err) } continue } fmt.Println("ok, số dư", acc.Balance) }}Xem lời giải
use std::fmt;
enum WithdrawError { InvalidAmount(i64), InsufficientFunds,}
impl fmt::Display for WithdrawError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { WithdrawError::InvalidAmount(n) => write!(f, "số tiền không hợp lệ {n}"), WithdrawError::InsufficientFunds => write!(f, "không đủ tiền"), } }}
struct Account { balance: i64,}
impl Account { fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> { if n <= 0 { return Err(WithdrawError::InvalidAmount(n)); } if n > self.balance { return Err(WithdrawError::InsufficientFunds); } self.balance -= n; Ok(()) }}
fn main() { let mut acc = Account { balance: 100 }; for n in [30, 0, 500] { match acc.withdraw(n) { Ok(()) => println!("ok, số dư {}", acc.balance), Err(e @ WithdrawError::InsufficientFunds) => println!("bị từ chối: {e}"), Err(e) => println!("lỗi: {e}"), } }}Kết quả:
ok, số dư 70lỗi: số tiền không hợp lệ 0bị từ chối: không đủ tiềnSentinel error và lỗi tạo bằng fmt.Errorf trở thành hai biến thể của cùng một enum, còn errors.Is trở thành một nhánh match. e @ WithdrawError::InsufficientFunds vừa khớp với biến thể vừa cho bạn cả giá trị dưới tên e để in ra. Một điểm cộng nữa: nếu sau này bạn thêm biến thể thứ ba, trình biên dịch sẽ chỉ cho bạn mọi match cần cập nhật. Crate thiserror có thể viết phần triển khai Display giúp bạn.
Tóm tắt
Phần tiêu đề “Tóm tắt”- Phần lớn Go mang sang được: struct có phương thức, không có kế thừa, lỗi là giá trị, một công cụ định dạng chuẩn, và một công cụ để build và test.
- Những thay đổi lớn là quyền sở hữu thay cho bộ thu gom rác,
Optionthay chonil, vàResult+?thay choif err != nil. - Interface trở thành trait, và việc triển khai trait là tường minh (
impl Trait for Type).defertrở thànhDroptự động ở cuối một phạm vi. - Goroutine và channel trở thành
thread::spawn+mpsckhi có vài worker, hoặc task của tokio khi có rất nhiều; trình biên dịch loại bỏ data race. - Cargo thay thế
go build,go testvàgo mod; clippy và rustfmt thay thếgo vetvàgofmt. Thay Gin hãy xem axum hoặc actix-web, thay GORM hãy xem SeaORM hoặc Diesel, thay pgx và sqlc hãy xem sqlx, và thay cobra hãy xem clap.
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Trong Go, một kiểu thỏa mãn một interface chỉ cần có đúng các phương thức. Một kiểu trong Rust triển khai một trait như thế nào?
2. ? làm gì khi đặt sau một lời gọi trả về Result?
3. Với std::sync::mpsc, khi nào for msg in rx (hoặc rx.iter()) dừng lại?
4. Nhóm Go của bạn xây HTTP API bằng Gin. Crate Rust nào gần nhất?
Điểm: 0 / 4
Đọc thêm
Phần tiêu đề “Đọc thêm”- The Rust Programming Language (tiếng Anh): cuốn sách chính thức. Chương 4 (ownership), 9 (lỗi), 10 (generic và trait) và 16 (lập trình đồng thời) quan trọng nhất với lập trình viên Go.
- Tokio tutorial (tiếng Anh): runtime async mà phần lớn server Rust dùng, thứ gần nhất với bộ lập lịch goroutine của Go.
- Tài liệu axum (tiếng Anh): web framework nên xem đầu tiên nếu bạn đến từ Gin, Echo hay Chi.
- Why Discord is switching from Go to Rust (tiếng Anh): câu chuyện thật của một nhóm khi chuyển một service nhạy cảm với độ trễ từ Go sang Rust.
- Are We Web Yet? (tiếng Anh): tổng quan của cộng đồng về các thư viện và framework web của Rust.