C#からC/C++のネイティブプラグインを使用するときに注意すること

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

本記事では、Unity/C#からC/C++で作られたネイティブプラグインを呼び出す、いわゆるC# Bridgeなどを作成する際に知っておくべきこと、注意すべきことを紹介します。

ネイティブプラグインを自分で触っているとUnityをクラッシュさせてしまうことも多いと思いますが、通常のように分かりやすいログが出るわけではないため何が原因なのか分かりにくいことも多いのではないでしょうか?

最近はUnityはC#のIL2CPPビルドでパフォーマンスも改善されていますし、そこまでC/C++を触るケースが多くないのかネットにあまり情報も多くはない印象ですが、知らないとデバッグ自体しづらいことも多いです。

自分が雰囲気でコードを書いていたらすぐにUnityをクラッシュさせてしまったので、そのデバッグ過程で得られた理解を整理して共有できればと思います。

もし自分と同じように何かのC/C++ライブラリのC# Bridgeを自分で作りたい・作らざるを得ないけどよく分からないという方の参考になれば幸いです。

背景

先日、WebAssemblyのランタイムを触ってUnity上でWebAssemblyのプログラムを動かしてみる記事を書きました。

synamon.hatenablog.com

記事を書き始めた時点では結局どのランタイムを使うべきか悩んでいたのですが、Unityで使用することを想定するならきちんと主要なプラットフォームには対応しているべきだと思い、最も理想的な選択肢「Wasmerの最新版をネイティブプラグインとして組み込み、C# Bridgeの部分を自作する」を取るべきだと思いました。

少しずつWasmのAPIの対応を進めているのがこちらのRepositoryです。

github.com

ところがいざ自分で0からコードを書いていると、これまで基本的にはC#と3rd Partyのライブラリを使うだけで完結していたことがほとんどで、C/C++製のライブラリを自分で組み込んだり、一からUnsafeなコードを書いたりした経験がないことを改めて思い知らされました。

ここ3週間ほどで何度も何度も、もう余裕で100回以上はUnityをクラッシュさせました...

テストコードを書きながら試行錯誤しつつ改めてC/C++やネイティブプラグインの基礎知識を勉強し、理解できたことを整理して紹介します。

前提

まず今回紹介する内容を検証していた状況を書きます。

ご自身で開発される環境とは異なる場合もあると思いますのでご注意ください。

環境

  • PC: M2 MacBook Air
  • OS: macOS 13.1
  • Unity: 2021.3.0f1(Apple Silicon版)
  • Editor: Rider

対象

対象とするライブラリは、WebAssembly(Wasm)のランタイムの一つであるWasmer v3.1.1で、これはRustで書かれているものですが、WasmのC-APIに準拠したWasmerのC-APIも提供されています。

github.com

公式のReleaseにAppleSilicon版macOS(Darwin ARM64bit)向けのビルドが用意されているので、この .dylib を利用してUnity Editor上で動作検証をしています。

github.com

もし自分の環境のビルドがない場合は、自分でライブラリのビルドをするか、Dockerなどの環境を使用することになりますが、今回は触れません。

今回はこのCライブラリをC#のP/Invoke(プラットフォーム呼び出し)で利用する形になります。

参考となる実装

今回WasmerのC# Bridgeを自分で実装するにあたって参考になる実装は2つありました。

  • 古いバージョンのWasmerのC# Bridge
    • 一応公式からリンクが貼られているものです
    • バージョンの問題でAPIが異なることに注意が必要です
    • 良くも悪くも1スクリプトにまとめられているので、そのまま参考にするのは難しいです
  • 最新版のWasmtimeのC# Bridge
    • 前回の記事で自分がUnityに組み込んだものです
    • WasmerとWasmtimeで少しAPIが異なること注意が必要です
    • 実装は比較的綺麗に分けられているため、コピペではうまくいかないもののかなり参考になりました

どういう意図でその実装方法をとっているのかまで理解できないと自分のコードには落とせない(分かったつもりで適当に書くとすぐクラッシュする)ので、特に後者の実装を参考にしつつも最終的には自分で0から書いています。

マーシャリング

よく知られているようにC#ではGarbage Collectionの動的なメモリ管理がされていてManagedと呼ばれる一方、C/C++では自身でメモリ管理をする必要がありUnmanagedと呼ばれます。

これらのメモリ管理の境界は厳格に引かれているため、これらの間で適切なデータの受け渡し(マーシャリング)をきちんとする必要があります。

こちらの記事の後半の図が分かりやすいです。

tech.blog.aerie.jp

他にもこれらの内容が参考になると思いますので、初めての方は目を通しておくと良いかもしれません。

learn.microsoft.com

Unityで使うC#/DLLマーシャリング事典 (技術の泉シリーズ(NextPublishing)) | 山田 英伸 | 工学 | Kindleストア | Amazon

プリミティブ型などでは自動的にマーシャリングができるものもありますが、C/C++側でstructなどで定義されている独自のデータ型をP/Invokeで扱う場合には自分でマーシャリングをする必要があります。

マーシャリングを不適切に行うとOutOfMemoryエラー、アプリケーションのクラッシュに繋がるため細心の注意が必要です。

具体例

structをマーシャリングする具体例として、配列があります。

Wasmのライブラリとのbyte配列のやり取りには、wasm_byte_vec_t という型を使用します。

C:

#define WASM_DECLARE_VEC(name, ptr_or_none) \
  typedef struct wasm_##name##_vec_t { \
    size_t size; \
    wasm_##name##_t ptr_or_none* data; \
  } wasm_##name##_vec_t; \
  \
  // 省略

// Byte vectors

typedef byte_t wasm_byte_t;
WASM_DECLARE_VEC(byte, )

C#では例えば下記のようなstructとして実装を用意してマーシャリングすることができます。

C#:

[StructLayout(LayoutKind.Sequential)]
internal readonly unsafe struct ByteVector : IDisposable
{
    internal readonly nuint size;
    internal readonly byte* data;

    public void Dispose()
    {
         // 省略
    }

    // 省略
}
  • size ... C:size_t <-> C#:nuintUIntPtr
  • data ... C:byte_t <-> C#:byte* or IntPtr

の対応関係になっています。

このC#で定義した ByteVector は例えば下記のようにP/Invokeの引数に直接使用することが可能です。

C:

WASM_API_EXTERN void wasm_##name##_vec_new_empty(own wasm_##name##_vec_t* out);

C#:

[DllImport(NativePlugin.LibraryName)]
public static extern void wasm_byte_vec_new_empty(out ByteVector vector);

ここではデータ構造のみ抜粋していますが、全文はこちらです。

wasmer-unity/ByteVector.cs at 8f81e9559951e965c933a9a73ee539a7185553bd · mochi-neko/wasmer-unity · GitHub

失敗例

例えば下記のように sizedata の順番を入れ変えると、マーシャリングに失敗し、意図しないデータになります。

[StructLayout(LayoutKind.Sequential)]
internal readonly unsafe struct ByteVector : IDisposable
{
    internal readonly byte* data;
    internal readonly nuint size;

    public void Dispose()
    {
         // 省略
    }

    // 省略
}

ネイティブオブジェクトの生成・破棄

ネイティブ(C/C++)側で管理されるオブジェクトをC#側で触る時には、APIを通してネイティブ側で初期化を行い、不要になった時にもやはりAPIを呼び出してネイティブ側でリソースの解放をしなくてはなりません。

ネイティブ側のオブジェクトを勝手にC#側で作ってもネイティブに渡すとエラーになりますし、逆にネイティブ側でのメモリ解放を明示的に行うことを怠るといわゆるメモリーリークにつながります。

WasmのAPIは特にライブラリの利用者側で色々セットアップをする仕組みになっているため、ネイティブ側のオブジェクトをC#で色々触ることになります。

具体例

初期化時に使用する Config というオブジェクトを、ランタイム固有の設定を省略した空の実装は例えばこのように書くことができます。

    [OwnPointed]
    public sealed class Config : IDisposable
    {
        [return: OwnReceive]
        public static Config New()
        {
            return new Config(WasmAPIs.wasm_config_new(), hasOwnership: true);
        }

        private Config(IntPtr handle, bool hasOwnership)
        {
            this.handle = new NativeHandle(handle, hasOwnership);
        }

        public void Dispose()
        {
            handle.Dispose();
        }

        private readonly NativeHandle handle;

        internal NativeHandle Handle
        {
            get
            {
                if (handle.IsInvalid)
                {
                    throw new ObjectDisposedException(typeof(Config).FullName);
                }

                return handle;
            }
        }

        internal sealed class NativeHandle : SafeHandleZeroOrMinusOneIsInvalid
        {
            public NativeHandle(IntPtr handle, bool ownsHandle)
                : base(ownsHandle)
            {
                SetHandle(handle);
            }

            protected override bool ReleaseHandle()
            {
                WasmAPIs.wasm_config_delete(handle);
                return true;
            }
        }

        private static class WasmAPIs
        {
            [DllImport(NativePlugin.LibraryName)]
            [return: OwnReceive]
            public static extern IntPtr wasm_config_new();

            [DllImport(NativePlugin.LibraryName)]
            public static extern void wasm_config_delete(
                [OwnPass] [In] IntPtr handle);
        }
    }

wasmer-unity/Config.cs at 8f81e9559951e965c933a9a73ee539a7185553bd · mochi-neko/wasmer-unity · GitHub

Own〇〇 のようなAttributeは独自に定義しているものですが、その意図は所有権の話で説明します。

生成に

public static extern IntPtr wasm_config_new();

破棄に

public static extern void wasm_config_delete(IntPtr handle);

を使用しているのが分かると思います。

SafeHandle でラップしている部分は別の節で紹介します。

このような生成・破棄のAPIを基本として、各オブジェクト固有の生成方法や関数を適宜実装していくイメージです。

失敗例

下記のように delete を呼ぶのを忘れるとネイティブ側でメモリーリークが発生します。

  public sealed class Config : IDisposable
  {
      [return: OwnReceive]
      public static Config New()
      {
            return new Config(WasmAPIs.wasm_config_new(), hasOwnership: true);
      }

      private Config(IntPtr handle, bool hasOwnership)
      {
          this.handle = new NativeHandle(handle, hasOwnership);
      }

      public void Dispose()
      {
          // handle.Dispose();
      }
  }

オブジェクトの所有権

WasmerのC-APIにはオブジェクトの所有権の概念があります。

github.com

所有権の概念はWasmの仕様書には見られないですが、元のライブラリがRustで実装されているのを考えると、Rustの言語仕様のOwnership(所有権)やそのBorrow(借用)をそのまま引きずっているのでしょうか?

下記はWamserのビルドの wasm.h に書かれているものです。

// Ownership

#define own

// The qualifier `own` is used to indicate ownership of data in this API.
// It is intended to be interpreted similar to a `const` qualifier:
//
// - `own wasm_xxx_t*` owns the pointed-to data
// - `own wasm_xxx_t` distributes to all fields of a struct or union `xxx`
// - `own wasm_xxx_vec_t` owns the vector as well as its elements(!)
// - an `own` function parameter passes ownership from caller to callee
// - an `own` function result passes ownership from callee to caller
// - an exception are `own` pointer parameters named `out`, which are copy-back
//   output parameters passing back ownership from callee to caller
//
// Own data is created by `wasm_xxx_new` functions and some others.
// It must be released with the corresponding `wasm_xxx_delete` function.
//
// Deleting a reference does not necessarily delete the underlying object,
// it merely indicates that this owner no longer uses it.
//
// For vectors, `const wasm_xxx_vec_t` is used informally to indicate that
// neither the vector nor its elements should be modified.
// TODO: introduce proper `wasm_xxx_const_vec_t`?

このため、WasmのAPIを呼び出す際には own のキーワードの有無を見てそのパラメータと戻り値の所有権が移るかどうかを考えながら実装をする必要があります。

ただこの own キーワードが書かれているのは当然C側で、C#側の実装を書いている途中にいちいち確認するのは手間かつ事故の元になるため、自作のAttributeをマーカーとして付けています。

具体例

自分が所有権で一番最初に躓いたのは、Configを使用したEngineの初期化処理でした。

    [OwnPointed]
    public sealed class Engine : IDisposable
    {
        [return: OwnReceive]
        public static Engine New()
        {
            return new Engine(
                WasmAPIs.wasm_engine_new(),
                hasOwnership: true);
        }

        [return: OwnReceive]
        public static Engine New([OwnPass] Config config)
        {
            if (config is null)
            {
                throw new ArgumentNullException(nameof(config));
            }

            var engine = new Engine(
                WasmAPIs.wasm_engine_new_with_config(config.Handle),
                hasOwnership: true);

            // Passes ownership to native.
            config.Handle.SetHandleAsInvalid();

            return engine;
        }

        private Engine(IntPtr handle, bool hasOwnership)
        {
            this.handle = new NativeHandle(handle, hasOwnership);
        }

        public void Dispose()
        {
            handle.Dispose();
        }

        private readonly NativeHandle handle;

        internal NativeHandle Handle
        {
            get
            {
                if (handle.IsInvalid)
                {
                    throw new ObjectDisposedException(typeof(Engine).FullName);
                }

                return handle;
            }
        }

        internal sealed class NativeHandle : SafeHandleZeroOrMinusOneIsInvalid
        {
            public NativeHandle(IntPtr handle, bool ownsHandle)
                : base(ownsHandle)
            {
                SetHandle(handle);
            }

            protected override bool ReleaseHandle()
            {
                WasmAPIs.wasm_engine_delete(handle);
                return true;
            }
        }

        private static class WasmAPIs
        {
            [DllImport(NativePlugin.LibraryName)]
            [return: OwnReceive]
            public static extern IntPtr wasm_engine_new();

            [DllImport(NativePlugin.LibraryName)]
            [return: OwnReceive]
            public static extern IntPtr wasm_engine_new_with_config(
                [OwnPass] [In] Config.NativeHandle config);

            [DllImport(NativePlugin.LibraryName)]
            public static extern void wasm_engine_delete(
                [OwnPass] [In] IntPtr handle);
        }
    }

wasmer-unity/Engine.cs at 8f81e9559951e965c933a9a73ee539a7185553bd · mochi-neko/wasmer-unity · GitHub

Configを渡してEngineを初期化するAPI

C#:

[DllImport(NativePlugin.LibraryName)]
public static extern IntPtr wasm_engine_new_with_config(Config.NativeHandle config);

はCでは下記のように定義されています。

C:

WASM_API_EXTERN own wasm_engine_t* wasm_engine_new_with_config(own wasm_config_t*);

この引数に own が付いているのが所有権を気にすべき目印で、説明の

// - an `own` function parameter passes ownership from caller to callee

にあるように、関数の引数に own が付く場合には所有権を caller(C#)から callee(C)に渡すことになります。

所有権を渡してしまったConfigはC#側ではもう使用できなくなるため、

// Passes ownership to native.
config.Handle.SetHandleAsInvalid();

のようにConfigの SafeHandle をCloseして利用できなくします。

するとConfigの Dispose() が呼ばれても ReleaseHandle() が呼ばれない、つまり void wasm_config_delete(IntPtr handle) も呼ばれなくなります。

失敗例

逆に下記のように Config をInvalidにせずに Dispose() を呼ぶだけで、所有権を持っていないオブジェクトのAPIを叩くことになりクラッシュします。

        [return: OwnReceive]
        public static Engine New([OwnPass] Config config)
        {
            if (config is null)
            {
                throw new ArgumentNullException(nameof(config));
            }

            var engine = new Engine(
                WasmAPIs.wasm_engine_new_with_config(config.Handle),
                hasOwnership: true);

            // Passes ownership to native.
            // config.Handle.SetHandleAsInvalid();

            return engine;
        }eturn engine;
    }

これはシンプルな例ですが、複数のパラメータを持ったり、オブジェクトを各所で複雑に受け渡しをしていると流石に追うのが難しくなってきますので目印のAttributeを付けるようにしています。

SafeHandleによるリソース解放

ネイティブ側で確保されたUnmanagedメモリを必ず解放するために、System.IDisposable を用いたDisposeパターンの実装が必要になります。

learn.microsoft.com

その際に生の IntPtr を扱うのではなく、それをラップしてくれる System.Runtime.InteropServices.SafeHandle を使用するとエラーハンドリング等をよしなに行ってくれます。

learn.microsoft.com

少し手間ではありますがSafeHandleの実装を各オブジェクト毎に用意しておき、delete以外のAPIの引数に指定しておくとAPIの見通しも良くなります。

ただし前節で説明したように、所有権を持っていないオブジェクトを破棄してしまうとクラッシュを引き起こしてしまうのでした。

そのような時に便利なのが SafeHandle.SetHandleAsInvalid()SafeHandle.ownsHandle です。

learn.microsoft.com

learn.microsoft.com

所有権をネイティブに渡してしまったオブジェクトをSafeHandle.SetHandleAsInvalid() によってCloseしておくと、Dispose() が呼ばれても ReleaseHandle() が呼ばれなくなり、不正な操作を防ぐことができます。

また、オブジェクト生成時に所有権を受け取っていない場合にもやはりdeleteのAPIを呼び出しはできないため、Constructorの引数の ownsHandlefalse を渡しておくことで、同様に ReleaseHandle() の呼び出しを抑制できます。

このような所有権をC# Bridgeの利用者側に意識させるのは流石に無理があるため、表面的には IDisposable を必ず呼ぶようにしておき、内部的には所有権を適切に扱っておく、という対処がベストでしょう。

具体例 / 失敗例

既に紹介した具体例で SafeHandle を使用しているため、改めて確認してみてください。

SafeHandle を利用する際は、用意しているPropertyの Handle を利用し、オブジェクトが破棄されているかのチェックが事前にかかるようにします。

まとめ

Unity/C#からC/C++のネイティブプラグインのAPIを呼び出して利用する際に注意すべきことを紹介しました。

  • データのマーシャリング、メモリレイアウトに細心の注意を払うこと
  • ネイティブ側で利用されているオブジェクトはAPIを通してネイティブ側で生成・破棄を行うこと
  • (Wasmの場合は)オブジェクトの所有権を考慮して適切に破棄処理を行うこと
  • SafeHandleを利用してなるべく安全にリソース解放をする方が良いこと

これらの取り扱いに失敗するとすぐにアプリケーションがクラッシュし、知らないとなかなか原因が分かりくいことも多いためとっつきづらいのですが、逆にこれらの仕組みが見えてくればちゃんとデバッグしていけると思います。

改めてC/C++のメモリ管理の大変さ、C#などのGarbage Collectionのありがたさを感じるとともに、RustのOwnershipは難しい概念ではありますがうまくできているんだなと思いました。

おわりに

自分がここ3週間ほど趣味で取り組んでいるWasmerのC# Bridgeの開発もまだ途中で、いったんHello Worldを動かすところまで行けるのですが、Native WasmのAPIもまだカバーしきれておらず、まだまだやることが残っています。

引き続いて実装していく中でVector系をSafeHandleで扱えないかやネイティブ側のメモリーリークの検出方法などまだちゃんと理解しきれていないことも進展があればまた何かの記事にしたいと思います。

Wasm/Wasmerの表面的なAPIだけではなく内部の仕様まで踏み込んで実装をしているので、Wasmの仕様そのものの理解に繋がっているのは大きい収穫でした。

Native Plugin! Unsafe! Pointer! Unmanaged! Crash! と初心者には近寄りがたいキーワードばかりの領域ですが、怖がらずに触ってみると意外と低レイヤーの基本的な仕組みの理解にもつながるため、もし触ってみたいネイティブのライブラリがありましたらぜひ挑戦してみてはいかがでしょうか。