A Self-Baked Async FFI Framework for Rust <-> C# Interop

Bài tóm tắt ScyllaDB 2026 3. Quy mô lớn Độ khó: Khó

ScyllaDB · 2026 · 3. Quy mô lớn · Độ khó: Khó — bài gốc

Phần 1/2 của cặp bài về C# RS Driver: phần này là khung FFI, ScyllaDB 2026 là driver và benchmark. Bài nhà cung cấp; wiki chỉ tóm tắt chiến lược và các bẫy chính, bỏ chi tiết cú pháp interop.

Bối cảnh

  • ScyllaDB chỉ có một database nhưng phải bảo trì một đàn driver, mỗi ngôn ngữ một cái. Ý tưởng "One Driver To Rule Them All" (từ hackathon nội bộ năm 2021): chỉ phát triển driver Rust, các driver khác là lớp binding mỏng bên trên. Bản đầu tiên là CPP RS Driver (đã lên 1.x), rồi Node.js RS Driver.
  • C# đã có bản fork driver DataStax được ScyllaDB tuỳ biến (biết shard, biết tablet), nhưng mục tiêu là viết lại API trên lõi Rust.
  • Các bindgen có sẵn (uniffi-bindgen-cs, csbindgen) chỉ lo phần tương thích cơ bản, che bớt độ phức tạp FFI nên khó gỡ lỗi, và không hỗ trợ async. Với Node.js, napi-rs đã cho họ một trải nghiệm tệ (kém hiệu quả, lỗi ẩn sâu, glue tự sinh khó hiểu). Vì thế họ tự viết khung.

Thay đổi chính

  • Gặp nhau ở C ABI: C# gọi Rust bằng P/Invoke, chi phí chỉ khoảng 10–30 lệnh x86 mỗi lần gọi; Rust gọi C# qua con trỏ hàm UnmanagedCallersOnly được truyền sang trước (reverse P/Invoke), vì luồng gọi có thể là luồng Tokio chứ không phải luồng .NET.
  • Cầu nối async: C# tạo TaskCompletionSource, bọc trong GCHandle, đóng gói cùng hai con trỏ hàm (thành công/thất bại) thành một Task Control Block, gọi sang Rust và nhận về ngay; Rust spawn future trên Tokio, xong thì gọi ngược để hoàn tất Task. Với người dùng chỉ là await session.ExecuteAsync(...).
  • Bẫy hai runtime: luồng executor của Tokio chạy luôn phần continuation của C# (vì .NET tối ưu để giảm chuyển ngữ cảnh). .NET tự thêm thread khi phát hiện deadlock, còn Tokio tin task không chặn và có số executor cố định, nên executor có thể bị đói. Sửa bằng cờ RunContinuationsAsynchronously để đẩy continuation về thread pool của .NET.
  • Dữ liệu: kiểu blittable qua miễn phí; slice, string, bool có struct cùng layout ở hai phía (#[repr(C)] / StructLayout); mọi con trỏ được bọc kiểu mạnh (học từ argconv.rs của CPP RS Driver), có static assert kích thước.
  • Bộ nhớ chéo: tài nguyên Rust do C# giữ dùng SafeHandle (đếm tham chiếu, dọn khi GC hoặc Dispose); đối tượng C# đưa cho Rust thì ghim: gọi đồng bộ dùng con trỏ tới tham chiếu trên stack (rẻ), gọi bất đồng bộ dùng GCHandle bọc trong kiểu có Drop để tự giải phóng.

Vì sao

  • Theo ScyllaDB: logic khó (định tuyến theo shard/tablet, pool kết nối, thực thi query) chỉ viết và sửa một lần ở Rust; mỗi ngôn ngữ chỉ còn lớp vỏ. Cái giá là phải tự làm cầu nối an toàn giữa hai runtime có GC và không GC.

Bài học cho SWE

  • Gom logic chung vào một lõi và bọc mỏng cho từng ngôn ngữ giảm số codebase phải giữ đồng bộ, nhưng biên FFI thành nơi tập trung rủi ro bộ nhớ và đồng thời.
  • Hai runtime async có giả định khác nhau về luồng (bị chặn thì có thêm thread hay không); cầu nối phải quyết định code của bên nào chạy trên luồng của bên nào.
  • Công cụ sinh binding tiện lúc đầu nhưng che mất chỗ lỗi; khi hiệu năng và gỡ lỗi quan trọng, một khung tự viết nhỏ, kiểu mạnh có thể rẻ hơn về lâu dài.

Liên quan