9 năm Python, 1 tháng vật lộn với Rust Ownership

Python GC vs Rust Ownership - đâu là cái giá của performance? Story của một Python dev học Rust.

9 năm Python, 1 tháng vật lộn với Rust Ownership

Blog 01: Foundations - Khi biến không chỉ là cái tên

Chào các bạn, tôi đây.

Sau 9 năm gắn bó với Python, nơi mà mọi thứ nhanh gọn, code phát chạy ngay và..."ma thuật" (magic), việc bước qua thế giới của Rust là một trải nghiệm hoàn toàn khác biệt ... và tới hôm nay thì nó khá phê, trust me bro.

Ở Python, chúng ta được phép lười biếng. Chúng ta viết x = 10, rồi x = "Hello", và trình thông dịch: "OK, chạy đi". Chúng ta không quan tâm x nằm ở đâu trong RAM, khi nào nó bị xóa, hay nó chiếm bao nhiêu byte. Garbage Collector sẽ cân hết việc đó cho chúng ta, chúng ta cứ quăng biến tùm lum mà không phải nghĩ.

Câu chuyện bắt đầu thay đổi khi tôi quyết định viết lại con "Network Stresser Tool" tool bằng RUST 🦀. Lúc đó mới thấy tốc độ nó điên thật.

Network Stresser Tool benchmark - Rust đạt 10 triệu requests/s với 2 core CPU và 8GB RAM


Với cấu hình khiêm tốn: CPU 2 core + 8 GB Ram. Cỡ này launch 10-15 con container trên mấy cái cloud provider thì đẩy 1 phát lên 10 triệu request/1s. Và bản execution chỉ nặng có 3 MB. (quá phù hợp để phán tán qua BotNet ;) )
Chính cái hiệu năng đó đã truyền cảm hứng cho tôi tiến sâu hơn vào Rust, để hiểu xem thực sự nó hoạt động thế nào, ... cơ bản là tôi học code Rust.

Nên hôm nay, tôi muốn nói về thứ khiến tôi vấp ngã nhiều nhất lúc mới bắt đầu: Bộ nhớ (Memory). Không phải kiểu "ơ thì GC lo hết rồi" đâu. Mà là kiểu bare-metal, cái-byte-này-nằm-ở-đâu-trong-RAM thật sự.

1. Vậy Biến (Variable) thực sự là gì?

OK để tôi bắt đầu bằng thứ đã làm tôi "vỡ não" một chút. Trong Python, bạn viết thế này và chẳng nghĩ ngợi gì:

a = [1, 2, 3]
b = a

Chúng ta nói "gán a cho b" rồi bước đi tiếp cuộc đời. Nhưng thực tế thì ab chỉ là hai cái nhãn (labels) được dán lên cùng một cái list đang lơ lửng đâu đó trong Heap memory. a chẳng sở hữu gì cả. Nó chỉ là... một tờ giấy nhớ trỏ vào data thật.

Rust nhìn vào cái này và nói "không đời nào." Trong Rust, bạn phải nghĩ về ba thứ cùng lúc: Value (Giá trị), Variable (Biến), và Pointer (Con trỏ). Ừ, hơi nhiều đấy.

Rust Memory Model: The Value-Variable-Pointer Triangle


(Figure 1: Minh họa mối quan hệ giữa Value, Variable và Pointer trong Rust)

High-Level Model: Biến là Dòng chảy (Flow)

Khi tôi đọc cuốn Rust for Rustaceans, Jon Gjengset đưa ra một mô hình tư duy rất hay:

Hãy coi biến không phải là một cái hộp chứa dữ liệu, mà là một điểm khởi đầu của một Luồng dữ liệu (Data Flow).

Trong Python, biến cứ ung dung tồn tại miễn scope còn sống. Không hỏi han gì. Rust thì khó tính hơn nhiều. Một biến chỉ "tồn tại" khi nó đang thực sự giữ một giá trị hợp lệ. Cái khoảnh khắc nó đưa giá trị đó đi, với compiler thì nó coi như đã chết.

let x; // x được khai báo nhưng... chưa có ai ở nhà
x = 42; // GIỜ thì x mới tồn tại, flow bắt đầu
let y = x; // x trao giá trị cho y. x xong rồi. Hết. RIP.

Tôi nhớ lần đầu tiên cố dùng x sau dòng cuối cùng. Compiler gào lên và tôi ngồi nhìn màn hình kiểu "nhưng... x nó ở ngay đây mà??" Mất một lúc tôi mới ngấm được - trong thế giới của Rust, cái "dây nối" từ x tới data đã bị cắt đứt ngay khoảnh khắc y tiếp quản. Borrow Checker đi dò từng sợi dây một để đảm bảo không sợi nào bị rối vào nhau thành data race.

Low-Level Model: Biến là Slot (Ngăn chứa)

Giờ zoom sâu hơn chút. Ở mức hardware, biến trong Rust thực chất là một Slot - một khối bộ nhớ được khoét ra trên Stack (hoặc đôi khi là Heap). Viết let x: usize là bạn đang bảo compiler: "giữ cho tôi 8 bytes ở đây, đó là nhà của x."

Đây là chỗ sẽ rất lạ lẫm nếu bạn từ Python qua. Trong Python, x = 10x trỏ tới một integer object. Rồi x = "hi" - không vấn đề gì, x chỉ đơn giản trỏ sang chỗ khác thôi. Biến trong Python như con tắc kè hoa, nó không quan tâm mình đang giữ cái gì.

Rust không hoạt động kiểu đó đâu. x CHÍNH LÀ cái hộp. Bạn đổ số 10 vào, và cái hộp đó được đúc khuôn riêng cho integer. Thử nhét string vào xem? Compiler sẽ cười vào mặt bạn. À không, không phải cười - nó phang cho bạn một bức tường chữ đỏ lè error text, cảm giác còn tệ hơn.

Chúng ta sẽ mổ xẻ Memory Layout kỹ hơn ở bài sau. Giờ chỉ cần nhớ: Python giấu bộ nhớ khỏi bạn. Rust đẩy nó thẳng vào mặt bạn.

2. Stack, Heap và cái giá của sự tiện lợi

Là một Pythonista, có bao giờ bạn tự hỏi tại sao Python lại "chậm"? Một phần lớn lý do nằm ở đây: Gần như MỌI THỨ trong Python đều nằm trên Heap.

Đây là một fun fact khiến tôi choáng khi lần đầu biết: một con số int trong Python - chỉ là số 42 thôi - không phải 4 bytes. Không phải 8 bytes. Nó là cả một PyObject struct to đùng nằm trên Heap, bao gồm reference count, type pointer, rồi cuối cùng mới tới con số thật chôn bên trong. Khi bạn viết 1 + 2, Python không đơn giản cộng hai số đâu. Nó phải đuổi theo một con trỏ để tìm object đầu tiên, check "đây có phải int thật không?", đuổi theo con trỏ thứ hai, check lại, rồi MỚI cộng. Bảo sao chậm.

Rust thì khác. Rust phân biệt rạch ròi:

So sánh Memory Layout giữa Python và Rust


(Figure 2: So sánh Memory Layout giữa Python (All Heap) và Rust (Stack intensive))

The Stack (Ngăn xếp)

Hãy nghĩ Stack như cái bàn làm việc của CPU. Mọi thứ trong tầm tay, lấy cực nhanh. Biến local, function call - tất cả ngồi đây hết. Và Rust vắt kiệt từng giọt performance từ nó. Các primitive types như i32, bool, char? Tất cả trên Stack. Khi hàm return, toàn bộ stack frame chỉ đơn giản... biến mất. Không cần GC dọn dẹp gì cả, CPU chỉ dời con trỏ đi chỗ khác. Thế thôi. Tôi nhớ lần đầu profile chương trình Rust và bối rối vì allocation overhead gần như bằng 0 - thì ra là vì lý do này.

The Heap (Đống)

Heap giống như cái kho thuê ở bên kia thành phố. Chỗ thì rộng, nhưng mỗi chuyến đi qua đó tốn thời gian. Cái tôi thích ở Rust là - nó không cho bạn vô tình lạc vào Heap. Bạn phải chủ động nói "ừ, tôi muốn heap allocation" bằng cách dùng Box<T>, Vec<T>, String, v.v.

let x = Box::new(10); // Bạn đang bảo Rust: đặt số 10 này lên Heap giùm
// bản thân x (con trỏ) vẫn nằm trên Stack

Từ Python qua - nơi mọi thứ đều lặng lẽ chui lên Heap - cảm giác như đang chuyển từ buffet ăn-bao-nhiêu-cũng-được sang nhà hàng phải gọi từng món một. Tốn công hơn? Đúng rồi. Nhưng bạn sẽ không bao giờ bị bill bất ngờ cuối bữa.

Static Memory

Rồi còn một vùng nữa khá đặc biệt - Static memory. Bạn từng viết "Hello world" trong code chưa? Cái string đó không được tạo ra lúc runtime đâu. Nó được nướng thẳng vào binary lúc compile, nằm đó từ lúc chương trình start tới khi OS thu hồi. Sinh cùng chương trình, chết cùng chương trình.

Nhân tiện, 'static ở Rust chính là từ đây mà ra. Nếu bạn từng thấy 'static trong function signature và nghĩ "cái quái gì đây" - giờ bạn biết rồi. Nó nghĩa là "thứ này sống mãi." Hoặc ít nhất, sống lâu bằng chương trình.

3. Ownership: Phần khiến não tôi đứng hình

Đây là chỗ mọi thứ trở nên thực sự khó chịu với tôi.

Ở Python, quản lý bộ nhớ là bài tập nhóm. Bạn viết a = [1, 2] rồi b = a, giờ cả hai biến đều bám vào cùng một cái list. Python giữ một reference count - hai reference, count bằng 2. Khi tất cả buông tay, count về 0, GC quét dọn. Dễ ợt.

Nhưng đây là câu hỏi chẳng ai thèm hỏi trong Python: ai thực sự chịu trách nhiệm cho data này? Nếu a sửa cái list, b cũng thấy thay đổi. Chạy multi-thread? Mọi người xông vào giành cùng một object và Python phải quăng cả GIL lên để giữ mọi thứ không vỡ toang. Nó chạy được, nhưng kiểu băng keo dán tạm.

Câu trả lời của Rust tàn nhẫn trong sự đơn giản - Ownership (Quyền sở hữu):

Một giá trị. Một chủ. Không ngoại lệ.

Move Semantics (Di chuyển)

Đây là cái khiến tôi "wait, CÁI GÌ?" lần đầu tiên:

let s1 = String::from("hello");
let s2 = s1;
// println!("{}", s1); // KHÔNG. Compiler bảo s1 chết rồi.

Ở thế giới Python, s1s2 vui vẻ trỏ cùng một cái string và chẳng ai thắc mắc. Rust? Cái khoảnh khắc bạn viết let s2 = s1, ownership di chuyển. s1 đã trao chìa khóa và bỏ đi. Nó không phải "không dùng nữa" - nó hoàn toàn invalid. Thử chạm vào xem, compiler cắn đứt tay bạn luôn.

Tại sao phải phiền phức vậy? Vì Rust sợ bug Double Free - và thật lòng, nó nên sợ thật. Tưởng tượng s1s2 đều nghĩ mình là chủ cái string kia. Khi ra khỏi scope, cả hai cùng cố free cùng một vùng nhớ. Boom, segfault, chương trình thành tro. Tôi từng debug double-free bug trong C. Cách Rust ngăn chặn nó ngay lúc compile, nói thẳng ra, là thiên tài.

Drop Order (Thứ tự giải phóng)

Rust có dọn dẹp tự động cho bạn - nhưng đừng nhầm nó với dịch vụ giúp việc. Có một thứ tự nghiêm ngặt ở đây.

Biến được drop theo thứ ngược lại. Khai báo sau, chết trước. Giống như chồng đĩa ở quán buffet - bạn lấy từ trên xuống, không phải từ dưới lên. Struct fields thì ngược lại, drop theo thứ tự xuôi như trong source code. Trên xuống dưới, dễ đoán.

Nghe có vẻ là chi tiết nhàm chán phải không? Không phải đâu. Tôi học bài này theo kiểu xương máu khi có một struct chứa database connection pool, và một field khác phụ thuộc vào cái pool đó. Đặt sai thứ tự field là bạn drop connection trước cái thứ đang cần nó. Chương trình compile ngon, chạy ngon, rồi panic lúc runtime với một cái error message bí hiểm khiến bạn tự hỏi mình chọn nghề có đúng không.

4. Borrowing & Lifetimes: Kẻ gác cổng khó tính

OK vậy nếu chỉ một người được sở hữu một giá trị, thì làm sao để... chia sẻ data? Không lẽ clone hết mọi thứ - vừa tốn vừa đôi khi không thể.

Borrowing (Vay mượn) ra đời. Nghĩ nó như cho ai đó mượn xe. Bạn vẫn là chủ, họ chỉ dùng một lúc thôi. Trong Rust, bạn làm việc này qua references: &T cho "nhìn thôi đừng chạm" và &mut T cho "ừ, sửa đi."

Nhưng đây là cái bẫy - và là chỗ Rust bắt đầu "có ý kiến". Compiler áp dụng cái gọi là Reader-Writer Lock, nhưng thực hiện nó trước khi code chạy:

Bạn muốn bao nhiêu read-only borrow (&T) cũng được. HOẶC bạn có đúng một mutable borrow (&mut T). Chọn một. Không thể có cả hai cùng lúc, chấm.

Tôi biết bạn đang nghĩ gì - "nghe restrictive quá." Và đúng, nó restrictive thật. Nhưng nghĩ thử xem: trong Python, bạn có thể vui vẻ sửa một list trong khi đang loop qua nó. Tôi từng làm. Kết quả... không đẹp lắm. Khi thì output sai, khi thì crash, lúc nào cũng là đau đầu lúc 2 giờ sáng. Rust thẳng thừng từ chối cho bạn làm điều đó. Và thành thật? Sau nhiều năm truy lùng mutation bugs trong production Python, tôi biết ơn nó. Compiler biết chắc rằng nếu ai đó đang đọc data qua &T, không ai khác đang thay đổi nó sau lưng - và cái guarantee đó cho phép Rust optimize code mạnh hơn nhiều so với Python.

Lifetimes: Nó không phải là Scope!

Cái này làm tôi rối mất mấy tuần. Tôi cứ nghĩ "lifetime = scope" rồi bối rối khi mọi thứ không hoạt động như kỳ vọng. Chúng liên quan nhau, nhưng không phải cùng một thứ.

Lifetime thực chất chỉ là một cái tên cho đoạn code mà reference vẫn còn hợp lệ. Và đây là phần hack não - lifetime có thể có khoảng trống.

let mut x = 10;
let r = &x; // mượn x ở đây
println!("{}", r); // dùng xong... rồi không đụng tới r nữa
// r về mặt kỹ thuật vẫn "trong scope" nhưng chúng ta đã xong với nó
x = 20; // nên Rust cho phép mutate x ở đây. Không vấn đề gì.

Các version cũ của Rust sẽ reject code này. Compiler hồi đó khá "ngây thơ" - nếu r nằm trong cùng block {}, nó cứ cho là borrow vẫn sống. Nhưng Borrow Checker hiện đại (gọi là NLL, Non-Lexical Lifetimes) thực sự trace xem bạn dùng reference lần cuối ở đâu. Giống như compiler lớn lên và học được sự tinh tế vậy. Đã đến lúc.

Tổng kết

Nói thật - tháng đầu tiên với Rust của tôi khá khổ. Tôi dành thời gian cãi nhau với compiler nhiều hơn là ship code. Có những lúc tôi nghiêm túc cân nhắc quay lại Python và... kệ mẹ bộ nhớ.

Nhưng rồi có gì đó click. Tôi ngừng chống lại compiler và bắt đầu lắng nghe nó. Mỗi error message về cơ bản là Rust đang bảo tôi "ê, cái này sẽ là bug trên production đó." Và nó đúng. Mọi. Lần.

Thỏa thuận rất đơn giản: Python cho bạn muốn làm gì thì làm và hy vọng runtime lo được. Rust bắt bạn nghĩ kỹ từ đầu - data này sống ở đâu? Ai sở hữu nó? Khi nào nó chết? - và đổi lại, cho bạn một binary mà nó cứ... chạy. Không crash bí ẩn lúc 3 giờ sáng. Không "máy tôi chạy được mà." Tôi có những chương trình Rust chạy hàng tháng không một segfault. Mấy con Python service của tôi thì phải restart vài ngày một lần vì memory leak mà tôi tìm hoài không ra.

Learning curve có dốc không? Chắc chắn rồi. Đáng không? Tôi nghĩ là đáng. Nhưng đừng tin lời tôi - hãy ở lại cho bài sau khi chúng ta bổ đôi một Struct ra xem nó thật sự trông thế nào trong RAM. Nếu bạn từng thắc mắc tại sao Python Class ngốn nhiều memory vậy, bạn sẽ có một phen choáng.


Follow series này tại blog của tôi để cùng tôi tái cấu trúc tư duy từ Python sang Rust.