これは ビットバンク株式会社 Advent Calendar 2020 の 4 日目の記事です。
Bitbank システム部の宮本ジョーです。
普段はビットコイン関連開発を専門としていますが、今回はビットコインに直接関係のない内容について書きます。
C# と Rust を同時に使用して開発するためのプラクティスについてです。
イントロ
私は Bitcoin のライトニングネットワーク関連の処理を .NET で行うためのライブラリを自作しています。
似た内容の Rust ライブラリがユーザーも増え安定してきているらしいため、こちらを直接呼び出すことで自前実装に伴う保守の負担を減らせないか検討してみました。
このアプローチはうまくいき、 C# のウェブアプリケーションフレームワークからライトニングネットワークの処理を行うサーバを現在作成しています。
この記事では、実装時に気付いた C# と Rust の連携方法と注意点について書いていこうと思います。
コードだけ見たい人は、以下を参照してください。
What and Why
Rust と C# は非常に優れた組み合わせです。
サイモンとガーファンクルのデュエット、ウッチャンに対するナンチャン、
高森朝雄の原作に対するちばてつやの「あしたのジョー」
ってぐらい食い合わせが良いです。
Rust の利点
セキュアでハイパフォーマンスなコードを書きたい際の選択肢として、 Rust は非常に優れています。
Rust 自体の利点は、さまざまなところで述べられているので詳しくはここでは述べません。
なぜすべて Rust で開発しないのかというと
- ライフタイムや参照を適切に扱うための学習コストが高い。
- 非同期コードを書くのがまだちょっとつらい。
- Web アプリケーションフレームワークや GUI 処理などといったライブラリがまだ若干未成熟
などといった理由からです。
C# の利点
上記の Rust の問題点に対して、 C# (F#) を部分的に用いることでお互いの足りない点を補うことができます。
- 汎用言語としての歴史があるため、Rust では探すのが難しい機能がある (GUI, マルチスレッディング関連など)
- FFI のコードが書きやすい
SafeHandle
クラス- オブジェクトのマーシャリングを自由に決められる
- 特定のオブジェクトに対して GC を行わない (Pinning)ことなども簡単。
- F# で書くと Rust に似ている
- F# は C# とシームレスに扱える関数型言語
- いずれも OCaml を参考にした文法をしているため、馴染みやすい。
前提知識
rust で FFI を行う際の一般的なガイドライン
TRPL の FFI の章をまずは読み、その後は
Using Unsafe for Fun and Profit を参照しましょう。
Rustonomicon も参考になります。
ここでは、生ポインタ型などの基礎的な知識はすでにあるものとして進めます。
C# で FFI を行う際の一般的なガイドライン
MS 公式のドキュメントに詳しいです。
また、 native code 側の値を長時間扱うときは、 SafeHandle
を扱うのでこれも把握しておきましょう。
WASM はどうなの?
呼び出す側が JS なら wasm-bindgen が使えるかもしれません。
私の場合は
- 呼び出したい言語が JS ではなかった
- WASM のインターフェイス型(任意の言語から WASM を呼び出すための統一的な規格)がまだ未成熟だったので、つまずきたくなかった。
などの理由から、 rust を wasm にコンパイルしてそれを呼び出す。という処理は早い段階で検討から外しました。
基本的な要件
通常 FFI というと非ジェネリックな関数を呼び出して、戻り値を受け取ったら終わり。というケースが多く、ネストした参照を持つような複雑なオブジェクトを長時間ラッパ側で保持する。といった処理はバグの元なのでできる限り避けるのが常です。
今回は複雑な状態を持つ必要のある処理をすべて Rust 側に任せてしまいたかったので、単純な関数だけにとどめるのは難しく、以下を行う必要がありました。
- Rust 側で定義しているトレイトを、 C# 側で実装して Rust に渡す。
- そのトレイトを使って初期化したオブジェクトを、長時間 C# 側から参照し、必要に応じて使う。
- オブジェクトは内部に別オブジェクトを持っており、こちらも直接使用したいケースがあるので、 C# から直接参照する。
- 利用が終わったらオブジェクトの解放を C# 側から行う。
図にすると以下のような感じになります
図1:
PeerManager
および ChannelManager
というのは、今回私が C# から扱いたいオブジェクトの名前です。
それぞれの機能はここでは重要ではありませんが、以下の二点に注意してください。
PeerManager
はChannelManager
への参照を持つ必要がある。- C# から
ChannelManager
の機能を直接呼び出したい場合もある。
C# 、 Rust とともに Native Interoperation の機能が充実しているため、このような複雑なケースにも対応できます。
図2:
C# が持つ参照の実体は IntPtr
(rust でいう isize
) 型ですが、オブジェクトの解放を確実に行うため、 SafeHandle
型を介して使います。
図3:
SafeHandle 型を使っているので、例外発生時なども確実にデストラクタが実行されます。
参照がネストしているので、二重解放をしないように Rust 側で std::mem::forget
を適切に使う必要があります。
FFI の書き方: Rust 側
Rust のコードを C 互換性のある形式にコンパイルして、別言語から呼び出す方法それ自体は、ほかの場所ですでに詳しく述べられているのでここでは詳しく述べません。
ここではあくまで応用例としてできる限り安全に使う方法を述べていきます。
独自のポインタ型を定義
通常 FFI 境界を超えるようなコードを書く際は生ポインタ型 (*const
, *mut
) を使いますが、
これだけだと表現力に乏しく、間違った使い方になってしまいがちです。
たとえば、ポインタの指す先がスレッドセーフなのかそうでないのかによって、処理を変える必要があります。スレッドセーフでないオブジェクトのメソッドを C# で複数スレッドから呼び出してしまうとスレッドが競合してバグになります。
また、 C# から渡された参照が、SafeHandle
で管理されたものなのか、それとも単に普通の値を参照先に書き込むことを想定したものなのかによっても処理を分ける必要があります。
こういったことを判断するために、独自のポインタ型を定義します。
これは、 rust 側ではスマートポインタ (&
, &mut
) とできる限り同じように扱えるようにしつつ、かつ以下を行えるようにするためのものです。
- ライフタイムの管理を Rust ではなく C# が行う
- 何をするためのポインタなのか (C# に値をコピーして返す? C# から値を受け取る? C# の
SafeHandle
で保持する? 複数スレッドから扱える?) を型レベルで明示する
/// 参照 `&` のように扱いたいので、ライフタイムを PhantomData で指定
/// PhantomData はコンパイル時に消去されるので、メモリ上の実態としては `*const` と同じになる。
#[repr(transparent)]
pub struct HandleShared<'a, 'T: ?Sized>(*const T, PhantomData<&'a T>);
/// `&` が通常持っているトレイトを定義
unsafe impl<'a, T: ?Sized> Send for HandleShared<'a, T> where &'a T: Send {}
unsafe impl<'a, T: ?Sized> Sync for HandleShared<'a, T> where &'a T: Sync {}
impl<'a, T: ?Sized + RefUnwindSafe> UnwindSafe for HandleShared<'a, T> {}
/// 値を初期化するためのメソッドを追加
impl<'a, T> HandleShared<'a, T>
where
HandleShared<'a, T>: Send + Sync,
{
pub(super) fn alloc(value: T) -> Self
where
T: 'static,
{
let v = Box::new(value);
HandleShared(Box::into_raw(v), PhantomData)
}
}
これは、 T がスレッド安全である場合、かつ C# 側で SafeHandle
で持つ場合の型を表します。
T がスレッド安全でない場合、処理がもう少し複雑になります。
通常 Rust では Sync + Send
トレイトでスレッド安全であることを明示しますが、これはコンパイル時にチェックされるものであるため、 C# 側が値を複数スレッドから参照するのを防ぐことができません。
したがってランタイムにチェックします。 詳細は割愛するので興味がある人は、元になった記事か私の GitHub リポジトリを参照してください。
また C# では SafeHandle
を渡すだけではなく、 ref
や out
といったキーワードで目的に応じた参照渡しをすることもできるのでそれに対応した型を同様に作ります。
#[repr(transparent)]
pub struct Out<'a, T: ?Sized>(*mut T, PhantomData<&'a mut T>);
// こちらにも、上と同様にトレイトを実装し、 `&mut` と同じように扱えるようにする。(省略)
// ポインタが指している先の領域に値を書き込むためのメソッドを追加
impl<'a, T> Out<'a, T> {
pub fn init(&mut self, value: T) {
ptr::write(self.0, value);
}
);
あとは、これらを ffi 関数の引数として受け取ります。
FFIResult
型
Rust での処理は常に panic する可能性があります。
そのような場合にアプリケーション全体がクラッシュしないよう、 panic はできる限りキャッチして、C# には常に FFIResult
型を返します。
これは、 enum
ですがフィールドを持たない(C 互換性のある) enum です。 smart enum (Discriminated union)
を使うと、シリアライゼーションを適切に行うのが面倒になるためです。
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FFIResult {
kind: Kind,
id: u32,
}
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Ok, // 成功
EmptyPointerProvided, // C# から null ポインタが渡された
InternalError, // 内部の関数が `Err` を返した。あるいは panic した。
}
impl FFIResult {
pub(super) fn internal_error() -> Self {
FFIResult { kind: Kind::InternalError, id: next_err_id() }
}
/// スレッドローカルな `LAST_RESULT` という変数を用意しておき、エラーが発生したらそこにエラーメッセージを書き込む。
/// エラー発生時に C# 側でその値を取得し、適切なエラーハンドリングができるようにする。
pub(super) fn context(self, e: impl Error) -> Self {
assert!(
self.as_err().is_some(),
"context can only be attached to errors"
);
let err = Some(format_error(&e));
LAST_RESULT.with(|last_result| {
*last_result.borrow_mut() = Some(LastResult { value: self, err, });
});
self
}
}
/// 任意のエラー型から変換できるようにしておく。
impl<E> From<E> for FFIResult
where E:
std::error::Error
{
/// ライブラリ側のエラーは、エラーメッセージを `LAST_RESULT` にセットした上で `InternalError` として返す。
fn from(e: E) -> Self {
FFIResult::internal_error().context(e)
}
}
これに、 std::ops::Try
トレイトを実装し、通常の Result と同じように ?
オペレータで扱えるようにします。
なお、この機能は現在 nightly です。
impl Try for FFIResult {
type Ok = Self;
type Error = Self;
fn into_result(self) -> Result<<Self as Try>::Ok, <Self as Try>::Error> {
match self.kind {
Kind::Ok => Ok(self),
_ => Err(self)
}
}
fn from_error(result: Self::Error) -> Self {
if result.as_err().is_none() {
panic!(format!("attempted to return success code `{:?}` as an error", result));
}
result
}
fn from_ok(result: <Self as Try>::Ok) -> Self {
if result.as_err().is_some() {
panic!(format!("attempted to return error code `{:?}` as success", result));
}
result
}
}
また、 catch
というメソッドも実装します。
これは任意の処理を実行し、内部で panic が発生したらスレッドローカルの LAST_RESULT
という値にエラーの内容を保存するものです。
catch メソッドは長いので割愛します。興味がある人は github から参照してみてください。
後述の ffi!
マクロを使うことで、実際に実行したいそのライブラリ特有の処理はすべてこの catch の中で行うことを保証します。
Rust から FFIResult::Ok
以外の値が帰ってきた場合、 C# はこの LAST_RESULT
という変数の中身を取得してエラーの詳細を知ることができます。
再度例外として投げても良いし、無視しても OK です。
ffi!
マクロ
Rust 側の関数ではまず最初に、受け取った引数がポインタ型の場合、null ポインタでないことをチェックする必要があります。
ほかにも、上で説明した panic のキャッチなど、すべての ffi に共通して出てくる処理を何度も記述しないために以下のようにマクロにまとめます。
macro_rules! ffi {
(
$($(#[$meta:meta])*
fn $name:ident ( $( $arg_ident:ident : $arg_ty:ty),* ) -> FFIResult $body:expr)*) => {
$(
$(#[$meta])*
#[allow(unsafe_code, unused_attributes)]
#[no_mangle]
pub unsafe extern "cdecl" fn $name( $($arg_ident : $arg_ty),* ) -> FFIResult {
#[allow(unused_mut)]
#[deny(unsafe_code)]
fn call( $(mut $arg_ident: $arg_ty),* ) -> FFIResult {
$(
if $crate::is_null::IsNull::is_null(&$arg_ident) {
return FFIResult::empty_pointer_provided().context($crate::is_null::Error { arg: stringify!($arg_ident) });
}
)*
$body
}
FFIResult::catch(move || call( $($arg_ident),* ))
}
)*
};
}
あとは、これらを使って以下のように関数を定義してやれば呼び出せます。
// --- FFI 境界をまたぎたいオブジェクトを C 互換性のある形で定義 ---
/// C# から Rust に渡したい型
pub struct MyStruct {
field1: i32
}
/// ライブラリ特有のエラー型
pub enum MyError {
// 実装は省略
}
// C# 側から操りたい型
pub struct MyType {
pub index: i32
pub fields: MyStruct
}
impl MyType {
pub fn try_create(index: i32, argument: MyStruct) -> Result<Self, MyError> {
Ok(MyType {index, fields: argument})
}
pub fn hello_world(&mut self) {
println!("hello world for {}th time!", self.index);
self.index++;
}
}
/// Rust から C# (SafeHandle を使う)
/// MyType は C 互換性のある型でなくとも良い。
type MyTypeHandle<'a> = HandleShared<'a, MyType>;
ffi! {
/// MyType を初期化して、参照を C# に返す。
fn create_my_type(my_value_arg: u32, my_ref_arg: Ref<MyStruct>, my_type_handle: Out<MyTypeHandle>) -> FFIResult {
let my_ref_arg = unsafe { &*my_ref_arg.0 }; // 中身をスマートポインタとして取得 (実際は専用のメソッドを用意する)
let my_type = MyType::try_create(my_value_arg, my_ref_arg.clone())?;
/// 参照を C# の管理する領域に書き込んで、初期化する。
/// 実体はアンマネージドメモリ上にあるが、Rust は管理を放棄しているので解放されない。
/// 解放は SafeHandle の効果によって行う。
my_type_handle.init(MyTypeHandle::alloc(my_type));
FFIResult::ok()
}
fn use_my_type(handle: MyTypeHandle) -> FFIResult {
let handle = unsafe { &*my_ref_arg.0 }; // 中身をスマートポインタとして取得 (実際は専用のメソッドを用意する。)
handle.hello_world();
FFIResult::ok()
}
/// 参照を解放する。
fn release_my_type(handle: MyTypeHandle) -> FFIResult {
unsafe {
// box 化することでスコープを抜けると自動で解放される (実際は専用のメソッドを HandleShared 型に用意する)
let _ = Box::from_raw(handle.0 as *mut T);
// 場合によってはこの後で、特定の field を `std::mem::forget` することで二重解放を防ぐ。
}
}
}
FFI の書き方: C# 側
C# は昔から P/Invoke というしくみを介して C/C++ の呼び出しをサポートしています。
まずは、Rust のコードを呼び出すための関数たちを定義します。
[DllImport(RustLightning, EntryPoint = "create_my_type", ExactSpelling = true,
CallingConvention = CallingConvention.Cdecl)]
internal static extern unsafe FFIResult create_my_type(int my_value_arg, ref MyStruct my_ref_arg);
[DllImport(RustLightning, EntryPoint = "use_my_type", ExactSpelling = true,
CallingConvention = CallingConvention.Cdecl)]
internal static extern unsafe FFIResult use_my_type(MyTypeHandle handle);
[DllImport(RustLightning, EntryPoint = "release_my_type", ExactSpelling = true,
CallingConvention = CallingConvention.Cdecl)]
internal static extern unsafe FFIResult release_my_type(MyTypeHandle handle);
次に、SafeHandle を使って、以下のように Rust のオブジェクトを操るためのクラスを作ります。
public class MyTypeHandle : SafeHandle
{
public MyTypeHandle() : base(IntPtr.Zero, true)
{
}
protected override bool ReleaseHandle()
{
if (handle == IntPtr.Zero) return true;
var h = handle;
handle = IntPtr.Zero;
// デストラクタを実行するための Rust のコードを呼び出す。
Interop.release_my_type(h, false);
return true;
}
public override bool IsInvalid => handle == IntPtr.Zero;
}
こうしておくと、例外発生時でも確実に値を解放してくれます。
最後に、 ユーザーが扱うためのラッパクラスを作成します。
長くなってきたので省略しますが、これは FFI 関連部分をすべて隠蔽して通常の C# クラスと同じように扱えるようにするためのものです。
FFI できないこと
こういった処理を始める際は、何ができないのかを早めに把握しておくことが大切です。
だいたいの処理は何とかなりますが、
唯一、値を C# に返す際にデータのサイズが不定だと、面倒なことになります。
具体的には、 C# で適当な長さの初期化された buffer 領域をマネージドメモリに作り、それを rust に渡します。
長さが足りなかった場合、専用のエラーを返し、そのエラーを見た C# 側は、再度より大きな buffer を渡す。
といった感じです。
Rust に渡す C# の delegate (関数ポインタ) がサイズ不定の引数を受け取るような場合だと、さらに面倒になります。
ネストした参照 (スマートポインタ) を持つオブジェクトをやりとりする際そのまま C# に返すと、参照先は Rust の管理領域になってしまっているので、
参照先の値だけ解放されてしまいクラッシュするようなケースがあります。
これを防ぐため、このようなオブジェクトに関しては一度バイト列にシリアライズしてから上記のやり方で返します。
シリアライズ、デシリアライズするためのコードを C# と Rust 側に両方に記述せねばならず、面倒です。
できる限りシンプルなデータ構造をやりとりしましょう。
最後に
Rust は優れた言語ですが、専業エンジニアだけに限っても 90% 以上の人にとってはオーバーキルだと私は思います。
しかし、ほかの言語とのインターオペラビリティやさまざまな形式へのクロスコンパイルをサポートしているため、
特にセキュリティクリティカルな要件で内容を再実装したくないような場合には優れた選択肢になると思います。
皆さんも興味があったら触って見ましょう。