📌

[.NET 7] P/Invokeの落とし穴メモ

2023/02/17に公開

15年以上P/Invokeに向き合っていますが分からないことは尽きません。雑なメモなので不正確ならすみません。おいおい書き足していくかもしれません。

環境

.NET 7

LibraryImport の話を加味しています。ただし.NET 7特有の話は一部です。

1. 解放処理だけはSafeHandleを使わない

C++側定義の例

ここではVC++向け仕様としています(__declspecのところ)が、この点は本題とは無関係です。

// ラップしたいクラス
class Foo
{
    ...
};

extern "C"
{
    __declspec(dllexport) Foo* __cdecl createFoo()
    {
        return new Foo;
    }
    
    __declspec(dllexport) void __cdecl releaseFoo(Foo* obj)
    {
        delete obj;
    }
}

C#側定義 (動かない例)

上のネイティブ実装を呼び出すC#側の例です。Fooのポインタを表すSafeHandleであるFooHandleと、最新LibraryImportによる呼び出しの定義です。

using System.Runtime.InteropServices;

internal class FooHandle : SafeHandle
{
    internal FooHandle()
        : base(invalidHandleValue: IntPtr.Zero, ownsHandle: true)
    {
    }
    
    public static FooHandle Create()
    {
        return NativeMethods.createFoo();
    }

    protected override bool ReleaseHandle()
    {
        NativeMethods.releaseFoo(this);
        return true;
    }

    public override bool IsInvalid => handle == IntPtr.Zero;
}
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;

internal static partial class NativeMethods
{
    private const string LibraryName = "Foo";
    
    [LibraryImport(LibraryName)]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial FooHandle createFoo();
    
    [LibraryImport(LibraryName)]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial void releaseFoo(FooHandle obj);
}

しかしこれは実行時エラーになります。

// 作ってすぐ解放
{
    using var foo = FooHandle.Create();
}
System.ObjectDisposedException
Safe handle has been closed.
Object name: 'SafeHandle'.
   at System.Runtime.InteropServices.SafeHandle.DangerousAddRef(Boolean& success)

LibraryImportAttributeが生成してくれたコードを見に行くと、理由がわかります。

LibraryImport.g.cs
internal static unsafe partial class NativeMethods
{
    [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.10605")]
    [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
    internal static partial void std_string_delete2(global::FooHandle obj)
    {
        System.IntPtr __obj_native = default;
        // Setup - Perform required setup.
        bool obj__addRefd = false;
        try
        {
            // Marshal - Convert managed data to native data.
            obj.DangerousAddRef(ref obj__addRefd);
            __obj_native = obj.DangerousGetHandle();
            {
                __PInvoke(__obj_native);
            }
        }
        finally
        {
            // Cleanup - Perform required cleanup.
            if (obj__addRefd)
                obj.DangerousRelease(); // ここ?
        }

        // Local P/Invoke
        [System.Runtime.InteropServices.DllImportAttribute("Foo", EntryPoint = "deleteFoo", ExactSpelling = true)]
        [System.Runtime.InteropServices.UnmanagedCallConvAttribute(CallConvs = new System.Type[]{typeof(global::System.Runtime.CompilerServices.CallConvCdecl)})]
        static extern unsafe void __PInvoke(System.IntPtr obj);
    }
}

__PInvokeのところで解放されたあと、obj.DangerousRelease(); で再び解放処理に向かってしまうのが問題のようです。解放以外でSafeHandleを渡すメソッド定義には有効なコード生成ですが、解放では困るということですね。

C#側定義 (正解例)

簡単な解決は、解放だけは素のポインタで定義するというものです。

internal static partial class NativeMethods
{
    [LibraryImport(LibraryName)]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial void releaseFoo(IntPtr obj);
}
internal class FooHandle : SafeHandle
{
    // 中略
    
    protected override bool ReleaseHandle()
    {
        NativeMethods.releaseFoo(handle);
        return true;
    }
}

ちなみにDllImportでも同様の挙動になるようで、裏では似たようなおまじないがなされているのかもしれません。

2. stringが返るP/Invokeメソッド定義はしない

メモリ領域確保の実装上、あまり例は無いかもしれませんが。

C/C++側定義の例

何らかのネイティブ側で確保されているchar*が返るとします。

static const char* str = "Hello world!";
    
extern "C" __declspec(dllexport) const char* __cdecl getString()
{
    return str;
}

C#側定義の例 (動かない例)

internal static partial class NativeMethods
{
    private const string LibraryName = "Foo";
    
    [LibraryImport(LibraryName, StringMarshallingCustomType = typeof(Utf8StringMarshaller))]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial string getString();
}

呼び出すと、私の環境では何秒か待たされたのちに死にます。これもコード生成結果を見ると理由がわかります。

LibraryImport.g.cs
internal static unsafe partial class NativeMethods
{
    [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.10605")]
    [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
    internal static partial string getString()
    {
        string __retVal;
        byte* __retVal_native = default;
        try
        {
            {
                __retVal_native = __PInvoke();
            }

            // Unmarshal - Convert native data to managed data.
            __retVal = global::System.Runtime.InteropServices.Marshalling.Utf8StringMarshaller.ConvertToManaged(__retVal_native);
        }
        finally
        {
            // Cleanup - Perform required cleanup.
            global::System.Runtime.InteropServices.Marshalling.Utf8StringMarshaller.Free(__retVal_native);
        }

        return __retVal;
        // Local P/Invoke
        [System.Runtime.InteropServices.DllImportAttribute("OpenCvSharpExtern", EntryPoint = "foo", ExactSpelling = true)]
        [System.Runtime.InteropServices.UnmanagedCallConvAttribute(CallConvs = new System.Type[]{typeof(global::System.Runtime.CompilerServices.CallConvCdecl)})]
        static extern unsafe byte* __PInvoke();
    }
}

// Cleanup - Perform required cleanup. のところでFreeされています。今回はマネージド側での解放は意図していません。引数でstringを渡すときはこのコード生成が有効に働きそうですが、戻り値では余計なお世話になっています。

この例もまた、DllImportでも同様にダメのようです。

C#側定義 (正解例1)

ポインタのまま得て、自分でマネージド文字列にするのが無難に思われました。

internal static partial class NativeMethods
{
    [LibraryImport(LibraryName, StringMarshallingCustomType = typeof(Utf8StringMarshaller))]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial IntPtr getString();
}
var p = NativeMethods.getString();
var s = Marshal.PtrToStringUTF8(p);

C#側定義 (正解例2)

コード生成結果から拝借です。

internal static partial class NativeMethods
{
    [LibraryImport(LibraryName, StringMarshallingCustomType = typeof(Utf8StringMarshaller))]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static unsafe partial byte* getString();
}
unsafe
{
    var p = NativeMethods.getString();
    var s = System.Runtime.InteropServices.Marshalling.Utf8StringMarshaller.ConvertToManaged(p);
}

C#側定義 (正解例3)

カスタムマーシャラーを定義する方法で、.NET 7以降を要します。今回の例に対しては大げさかもしれませんが。
https://github.com/dotnet/runtime/blob/main/docs/design/libraries/LibraryImportGenerator/UserTypeMarshallingV2.md#value-marshaller-shapes

Discussion