Tại sao tôi chọn Rust thay vì C và C++ để xây dựng Drengr
Tác giả chia sẻ hành trình lựa chọn ngôn ngữ Rust thay vì C hay C++ cho dự án Drengr, công cụ tự động hóa di động dành cho tác nhân AI. Bài viết đi sâu vào các vấn đề về hệ thống build, an toàn bộ nhớ, quản lý dependency và sự đánh đổi thực tế trong lập trình hệ thống.

Tôi là người tạo ra Drengr, một MCP server giúp các tác nhân AI có "mắt" và "tay" trên thiết bị di động. Tôi bắt đầu blog này để chia sẻ về kỹ thuật đằng sau nó. Không giả vờ là một quan sát viên trung lập viết một bài suy ngẫm — tôi đã xây dựng nó, và tôi ở đây để nói về nó.
Khi tôi nói với mọi người rằng tôi xây dựng một công cụ tự động hóa di động bằng Rust, câu hỏi đầu tiên luôn là "tại sao không phải Python?". Tôi đã viết về điều đó trong một bài đăng riêng. Nhưng câu hỏi thực sự khiến tôi mất ngủ trong giai đoạn kiến trúc ban đầu lại khác: tại sao không C hay C++?
Drengr là một công cụ CLI (dòng lệnh) giao tiếp với thiết bị Android qua ADB, trình giả lập iOS qua simctl và các thiết bị đám mây qua Appium WebDriver. Nó phân tích cây UI, chụp ảnh màn hình, quản lý các phiên thiết bị đồng thời và đóng vai trò là MCP server qua stdio. Đây là lãnh thổ của lập trình hệ thống. C và C++ đã thống lĩnh không gian này trong nhiều thập kỷ. Vậy tại sao lại là Rust?
Đây không phải là một bài viết "thánh chiến" Rust so với C++. Tôi đã làm việc với C và C++ trong nhiều ngữ cảnh khác nhau qua các năm — các cầu nối JNI và mô-đun NDK khi Java không đủ nhanh để xử lý âm thanh thời gian thực hoặc các đường ống camera tùy chỉnh, một trình dò tia (raytracer) bằng C++ thời đại học dạy tôi nhiều hơn về lỗi segfault hơn là về ánh sáng, và các thử nghiệm nhúng Arduino/embedded mà mọi sinh viên CS đều làm. Đủ để biết những ngôn ngữ này giỏi ở đâu và gây đau đớn ở đâu. Đây là một bản chân thực về một quyết định cụ thể cho một dự án cụ thể, với những sự đánh đổi mà tôi thực sự phải đối mặt.
Tại sao chọn C?
C rất hấp dẫn. Bản thân ADB được viết bằng C++. Giao thức Android debug bridge được tài liệu hóa tốt ở mức C. Tôi có thể gọi trực tiếp vào thư viện của ADB, bỏ qua hoàn toàn chi phí của tiến trình con. Một tệp nhị phân C sẽ rất nhỏ — có thể dưới 1MB với liên kết tĩnh và loại bỏ mã thừa (stripping) tích cực.
Tôi đã nghiêm túc cân nhắc nó. Trong khoảng hai ngày.
Vấn đề trở nên rõ ràng khi tôi bắt đầu phác thảo MCP server. MCP là JSON-RPC 2.0 qua stdio. Điều đó có nghĩa là phân tích cú pháp JSON, định tuyến các lệnh gọi phương thức, quản lý tương quan yêu cầu/phản hồi và xử lý các l gọi công cụ đồng thời. Trong C, tôi sẽ cần một trình phân tích cú pháp JSON (jansson? cJSON? tự viết?), xử lý chuỗi không gây segfault và quản lý bộ nhớ thủ công cho mọi vòng đời yêu cầu/phản hồi.
Tôi đã thấy đủ cơ sở mã C để biết nó trông như thế nào. Nó trông giống như 60% mã của bạn là mã mẫu quản lý bộ nhớ, và 40% còn lại là logic thực tế bạn quan tâm. Đối với một dự án nghiên cứu nơi tôi cần lặp lại nhanh chóng và thử các phương pháp tiếp cận thử nghiệm để phân tích màn hình và các vòng lặp tác nhân AI, tỷ lệ này là chết người.
Tại sao chọn C++
C++ là một đối thủ mạnh mẽ hơn. C++ hiện đại (17/20) có smart pointers, string_view, std::optional, std::variant — nhiều tính năng nhân tiện làm cho việc viết Rust trở nên thú vị. Hệ sinh thái ADB là C++ bản địa. Tôi có thể sử dụng nlohmann/json để phân tích cú pháp. Thư viện chuẩn có các luồng, mutexes, biến kiện condition variables.
Ba điều đã giết chết nó đối với tôi:
1. Vấn đề hệ thống Build
Tôi muốn một tệp nhị phân tĩnh duy nhất mà bất kỳ ai cũng có thể curl và chạy. Không có phụ thuộc thư viện dùng chung, không có yêu cầu thời gian chạy, không có "cài đặt libfoo-dev trước". Trong Rust, đây là lệnh cargo build --release --target x86_64-unknown-linux-musl. Xong.
Trong C++, liên kết tĩnh là một hành trình dài. CMake hay Meson? Thư viện chuẩn nào — libstdc++ hay libc++? Liên kết tĩnh glibc về mặt kỹ thuật là khả thi nhưng bị ngăn cản và tạo ra các tệp nhị phân lớn hơn với các vấn đề tiềm ẩn về tính tương thích. Musl hoạt động nhưng bạn cần một chuỗi công cụ (toolchain) riêng. Biên dịch chéo (cross-compilation) cho Apple Silicon từ Linux? Tôi sẽ cần một chuỗi công cụ biên dịch chéo cho mỗi bộ ba đích (target triple).
Cargo xử lý tất cả những điều này. Tôi thêm một đích, chạy bản build, nhận được một tệp nhị phân. Ma trận CI trong quy trình làm việc GitHub Actions của tôi dài 20 dòng. Thiết lập CMake + biên dịch chéo tương đương sẽ dài hơn 200 dòng.
2. Đồng thời hóa (Concurrency) mà không sợ hãi
Drengr quản lý nhiều hoạt động đồng thời: MCP server xử lý yêu cầu trong khi SDK server lắng nghe các sự kiện mạng trong ứng dụng, vòng lặp OODA chạy các phiên tác nhân tự chủ, và chế độ khám phá thực hiện duyệt BFS với các chụp ảnh màn hình đồng thời. Tất cả những thứ này chia sẻ trạng thái — thiết bị vận chuyển hiện tại, bộ nhớ đệm chú thích màn hình, động cơ tình huống.
Trong C++, trạng thái có thể thay đổi chia sẻ trên các luồng có nghĩa là lựa chọn giữa:
- Mutex thô với kỷ luật khóa/mở khóa thủ công (và hy vọng bạn không bao giờ quên)
- Hoạt động nguyên tử cho các kiểu nguyên thủy (và hy vọng thuật toán không khóa của bạn thực sự đúng)
- Các trừu tượng cấp cao hơn như folly::Synchronized (và thêm folly của Facebook làm phụ thuộc)
Cuộc đua dữ liệu (data races) trong C++ là hành vi không xác định. Không phải "chương trình của bạn bị sập". Hành vi không xác định. Trình biên dịch được phép làm bất cứ điều gì. Du hành thời gian. Nasal demons (quỷ mũi - một thuật ngữprogramming vui). Trong thực tế, điều này có nghĩa là sự hỏng hóc tinh tế xuất hiện ba giờ vào một phiên kiểm tra dưới dạng một ảnh chụp màn hình bị lỗi hoặc số lượng phần tử sai lặng lẽ.
Trong Rust, hệ thống kiểu ngăn chặn cuộc đua dữ liệu tại thời gian biên dịch. Nếu tôi cố gắng chia sẻ một tham chiếu có thể thay đổi (mutable reference) trên các luồng mà không có đồng bộ hóa phù hợp, nó sẽ không biên dịch. Kết thúc. Trình biên dịch buộc tôi phải sử dụng Arc<Mutex<T>> hoặc các kênh (channels) hoặc nguyên tử một cách rõ ràng. Tôi không thể vô tình chia sẻ một con trỏ thô đến bộ đệm màn hình trên hai tác vụ async.
Đối với một công cụ quản lý các phiên thiết bị thực — nơi một lỗi có thể có nghĩa là gửi thao tác chạm sai đến thiết bị sai — đây không phải là thứ "có thì tốt". Đó là một yêu cầu bắt buộc.
3. Câu chuyện Dependency
Drengr phụ thuộc vào reqwest (máy khách HTTP), tokio (thời gian chạy async), serde (serialization), image (xử lý ảnh chụp màn hình) và khoảng 30 crates khác. Thêm một dependency trong Rust là một dòng trong Cargo.toml. Cargo tải xuống, biên dịch và liên kết tĩnh nó. Độ phân giải phiên bản là tự động. Các cảnh báo bảo mật được theo dõi bởi cargo audit.
Trong C++, mọi dependency là một dự án. Chúng có dùng CMake không? Meson? Autotools? Hệ thống build riêng của chúng? Chúng có hỗ trợ liên kết tĩnh không? Các phụ thuộc chuyển tiếp của chúng có tương thích với của tôi không? Các trình quản lý gói như Conan và vcpkg đã cải thiện điều này, nhưng chúng vẫn còn xa khỏi trải nghiệm "nó chỉ hoạt động" của Cargo.
Tôi ước tính rằng việc quản lý các dependency C++ một mình sẽ tốn của tôi 2-3 tuần trong lịch trình phát triển ban đầu. Trong một dự án cá nhân nơi mỗi tuần đều quan trọng, điều đó là không thể chấp nhận được.
Những gì tôi thiếu từ C/C++
Sự trung thực đòi hỏi việc thừa nhận Rust tốn của tôi cái gì.
Thời gian biên dịch
Một bản build sạch của Drengr mất khoảng 90 giây. Một bản build gia tăng sau khi chạm vào một tệp mất 8-12 giây. Dự án C tương đương sẽ biên dịch trong dưới 5 giây sạch, dưới 1 giây gia tăng. Khi tôi đang lặp lại logic phân tích màn hình và muốn kiểm tra trên một thiết bị thực, những giây đó cộng lại.
Tôi đã giảm thiểu điều này với cargo watch và bằng cách cấu trúc crate để giảm thiểu biên dịch lại, nhưng đó là một chi phí thực.
Đường cong học tập
Tôi biết C và C++ trước khi biết Rust. Mô hình tinh thần của borrow checker — sở hữu (ownership), mượn (borrowing), vòng đời (lifetimes) — mất nhiều tuần để nội hóa. Có những ngày đầu trong dự án nơi tôi dành nhiều thời gian chiến đấu với trình biên dịch hơn là viết tính năng. Async Rust làm cho nó tồi tệ hơn: ghim (pining), giới hạn Send/Sync, vấn đề hàm màu.
Nếu tôi đã viết Drengr bằng C++, nguyên mẫu đầu tiên sẽ hoàn thành một tuần trước. Không nghi ngờ gì. Nhưng tôi tin rằng phiên bản Rust có ít lỗi hơn, và tôi dành gần như không có thời gian nào để gỡ lỗi các vấn đề bộ nhớ. Sự đánh đổi đó đã cộng lại có lợi cho tôi trong những tháng kể từ đó.
Ma sát FFI
ADB là một công cụ C++. Một số tương tác sẽ tự nhiên hơn trong C++ — ví dụ: FFI trực tiếp vào thư viện của ADB. Thay vào đó, tôi gọi shell đến tệp nhị phân adb như một tiến trình con. Nó hoạt động, nhưng nó thêm độ trễ (tạo ra một tiến trình cho mỗi lệnh) và độ phức tạp (phân tích cú pháp stdout). Một triển khai C++ có thể liên kết trực tiếp với libadb.
Trong thực tế, cách tiếp cận tiến trình con đã ổn. Các lệnh ADB hoàn thành trong 10-50ms thường xuyên, và phân tích cú pháp là đơn giản. Nhưng đó là một sự thỏa hiệp kiến trúc mà tôi không cần trong C++.
Những con số
Sau sáu tháng phát triển:
- ~6.300 dòng Rust — bao gồm MCP server, ba phương thức vận chuyển thiết bị (ADB, simctl, Appium), vòng lặp OODA, chế độ khám phá, trình chạy thử nghiệm, SDK server, chú thích màn hình và động cơ tình huống.
- Zero lỗi liên quan đến bộ nhớ trong sản xuất. Không có lỗi use-after-free, double-free, tràn bộ đệm hay cuộc đua dữ liệu nào.
- 189 bài kiểm tra, tất cả đều vượt qua. Bộ kiểm tra chạy trong dưới 3 giây.
- Kích thước tệp nhị phân: ~15MB sau khi loại bỏ mã thừa, với tối ưu hóa LTO lớn. Một bản tương đương C có thể là 3-5MB, nhưng 15MB cho một công cụ bao gồm máy khách HTTP, trình phân tích cú pháp JSON, xử lý ảnh và thời gian chạy async là hợp lý.
- Khởi động lạnh: ~15ms đến phản hồi MCP đầu tiên. Điều này quan trọng khi các tác nhân AI đang đợi.
Điều tôi sẽ làm khác
Nếu tôi bắt đầu lại vào ngày mai, tôi vẫn sẽ chọn Rust. Nhưng tôi sẽ làm một vài thứ khác:
- Bắt đầu với mã đồng bộ, thêm async sau. Tôi đã ưu tiên async với tokio, điều này làm phức tạp giai đoạn tạo mẫu ban đầu. Nhiều tương tác ADB không được hưởng lợi từ async — chúng là các cặp lệnh-phản hồi tuần tự. Tôi có thể đã bắt đầu đồng bộ và di chuyển các phần đồng thời sau.
- Sử dụng ít trừu tượng hơn ở giai đoạn đầu. Tôi đã thiết kế quá mức trait vận chuyển trong phiên bản đầu tiên. Ba triển khai cụ thể của một giao diện đơn giản sẽ rõ ràng hơn một trait với mười hai phương thức và hai kiểu liên kết.
- Chấp nhận nhiều unsafe hơn. Tôi đã hoàn toàn tránh unsafe trong bốn tháng đầu. Một số phân tích giao thức nhị phân ADB sẽ rõ ràng hơn với phép toán con trỏ unsafe trong một mô-đun được kiểm tra tốt, cô lập. Unsafe của Rust không phải là C — đó là một vùng ranh giới rõ ràng nơi bạn nói với trình biên dịch "Tôi đã xác minh điều này theo cách thủ công". Tôi quá thận trọng.
Câu trả lời thực sự
Lý do thực sự tôi chọn Rust thay vì C và C++ không phải là bất kỳ lập luận kỹ thuật đơn lẻ nào. Đó là: Rust cho phép tôi viết mã cấp hệ thống với tốc độ suy nghĩ, với sự tự tin rằng trình biên dịch đã bắt được các lớp lỗi sẽ khiến tôi tốn nhiều ngày gỡ lỗi.
Đối với một nhà phát triển cá nhân xây dựng một dự án nghiên cứu tương tác với phần cứng thực, quản lý các phiên đồng thời và đóng vai trò là cơ sở hạ tầng cho các tác nhân AI — sự tự tin đó không phải là xa xỉ. Đó là sự khác biệt giữa việc phát hành sản phẩm và không phát hành.
Tôi không có một đội ngũ để xem xét phép tính con trỏ của mình. Tôi không có bộ phận QA để bắt các cuộc đua dữ liệu của mình. Tôi có trình biên dịch Rust. Và đó là đồng nghiệp đáng tin cậy nhất mà tôi từng làm việc cùng.
C và C++ là những ngôn ngữ phi thường. Chúng cung cấp sức mạnh cho các hệ thống mà Drengr nằm bên trên — các hệ điều hành, daemon ADB, cơ sở hạ tầng simctl. Tôi có sự tôn trọng sâu sắc đối với chúng. Nhưng đối với dự án này, ở quy mô này, với tư cách là một nhà phát triển cá nhân? Rust là lựa chọn đúng đắn.
Tệp nhị phân hoạt động. Mã là chính xác. Và tôi ngủ ngon vào ban đêm biết rằng trình biên dịch đang bảo vệ tôi.
Drengr miễn phí để sử dụng và có sẵn trên npm. Nó hỗ trợ Android (thiết bị vật lý, trình giả lập), trình giả lập iOS (hỗ trợ cử chỉ đầy đủ) và các trang trại thiết bị đám mây (BrowserStack, SauceLabs, AWS Device Farm, LambdaTest, Perfecto, Kobiton). Được xây dựng bằng Rust. Tệp nhị phân duy nhất. Không có phụ thuộc thời gian chạy.



