× Install ThecoreGrid App
Tap below and select "Add to Home Screen" for full-screen experience.
B2B Engineering Insights & Architectural Teardowns

FFI для Rust и C# в ScyllaDB

Разбор того, как ScyllaDB связала Rust driver и C# через FFI, чтобы сохранить low latency и async-модель без лишних копий.

Проблема у ScyllaDB была не в одном языке, а в масштабе поддержки. Для каждого языка нужен свой driver, и отдельная реализация быстро превращается в тяжёлую операцию по сопровождению. Команда Driver Team искала путь к модели One Driver To Rule Them All: один Rust Driver как ядро и тонкие языковые оболочки поверх него. В этом контексте C# стал ещё одним тестом на то, насколько далеко можно зайти в унификации без потери предсказуемости.

Ключевой вызов был в межъязыковой границе. Для Rust и C# доступных готовых решений оказалось мало, а те bindgen-подходы, которые они рассматривали, скрывали часть FFI-сложности и могли увести ошибки глубоко внутрь автосгенерированного glue-кода. Дополнительным ограничением стала async-совместимость: готовых инструментов для Rust <-> C# async interop не нашлось. Поэтому команда выбрала прагматичный путь и собрала собственный bridge на базе опыта, уже накопленного на CPP RS Driver и Node.js RS Driver.

Решение строилось вокруг C ABI как общего языка между runtimes. Rust отдаёт наружу `extern «C»` функции, а C# вызывает их через P/Invoke, переводя типы в эквивалентные C-формы. Обратное направление сложнее: Rust вызывает C#, а там нельзя полагаться на статический адрес функции, как в нативной библиотеке. Для этого использовали `UnmanagedCallersOnly` и передачу указателей на функции как callbacks. Это и есть Reverse P/Invoke, но с важной инженерной оговоркой: вызов идёт через границу managed и unmanaged мира, поэтому каждый шаг должен быть предсказуемым по типам и по времени жизни объектов.

Отдельный слой сложности был в async-модели. И Rust с tokio, и современный C# используют async/await, но их исполнители ведут себя не одинаково. В C# continuation может выполниться синхронно на том же потоке, а tokio ожидает, что задачи не будут блокировать executor thread. Именно здесь возник риск starvation для tokio worker threads. Команда закрыла этот разрыв через `TaskCompletionSource` с флагом `RunContinuationsAsynchronously`, чтобы продолжения уходили в .NET thread pool, а не оставались на tokio worker thread. Это не декоративная настройка, а способ согласовать две разные модели планирования.

Переход данных через границу оказался не менее чувствительным, чем вызовы функций. Blittable-типы вроде чисел и raw pointers проходят почти без стоимости, но строки, массивы и сложные структуры требуют явного соглашения о layout. Для этого использовали C ABI-совместимые структуры с `#[repr(C)]` в Rust и `StructLayout(LayoutKind.Sequential)` в C#. Так обе стороны видят одинаковую раскладку памяти и одинаковый порядок полей. Для срезов применили `FFISlice`, а для строк — обёртки поверх UTF-8 представления, которые можно преобразовать в managed string на стороне C#.

Отдельный практический урок команда получила на `bool`. Формально тип кажется простым, но стандартный marshalling в .NET может расширять его до 4 байт, что ломает ожидания Rust и приводит к undefined behavior. Попытка использовать `[MarshalAs(UnmanagedType.U1)]` частично помогла, но сломалась на callback-сценарии с `UnmanagedCallersOnly`. В итоге команда ушла от неоднозначности совсем и заменила `bool` на `byte / u8`, завернутый в `FFIBool`. Это компромиссный, но надёжный выбор: меньше магии, меньше скрытых правил, меньше шансов на несоответствие между runtime.

Чтобы FFI-слой не стал источником трудноуловимых ошибок, команда усилила типизацию. Они адаптировали идеи из `argconv.rs` в `ffi.rs` для C# RS Driver и добавили сильную типизацию указателей и проверку совпадения размеров структур на стороне Rust. Это особенно важно там, где смешиваются opaque Rust data, opaque C# data и managed GC. Для управления ресурсами со стороны C# выбрали `SafeHandle`. Это позволило связать ручное `Dispose()` и финализацию при сборке мусора в одну модель, не полагаясь на хрупкие самописные схемы освобождения памяти.

Итог этого подхода не описан через метрики в исходнике, поэтому их здесь нет. Но архитектурный эффект понятен: ScyllaDB получила способ связать Rust driver и C# driver через более прозрачный FFI-слой, сохранив async-поведение и снизив риск скрытых ошибок на границе рантаймов. Для систем, где цена ошибки — не только latency, но и память, это не косметическое улучшение, а инженерно выверенная стабилизация интерфейса между двумя мирами.

Ознакомиться с источником

×

🚀 Deploy the Blocks

Controls: ← → to move, ↑ to rotate, ↓ to drop.
Mobile: use buttons below.