はじめに
.NET 5には新しい機能「関数ポインタ」を追加されました。これは、ネイティブ相互運用を改善し、パフォーマンスを上げるために作られました。
関数ポインタは下位レベルのデリゲートで、関数呼び出しのより効率的な方法を提供します。既知の方法にSystem.Action<>, System.Func<>
といい、System.Delegate
といい、どちらもオーバーヘッドがたくさんあります。でも関数ポインタはそういうオーバーエンドがありません。
さらに関数ポインタはマネージ関数のみをサポートすることではなく、アンマネージ関数もサポートされています。だからこそ、ネイティブ相互運用を行う場合は、関数ポインタを使用すると非常に便利になります。
じゃ、早速始めましょう。
使い方
関数ポインタの型の形式は delegate*
+ managed
/unmanaged
+ [呼び出し規則(ネイティブのみ)]
+<パラメータリスト(void を使えます)>
です。
例えば:
言語 | 宣言 | 関数ポインタの型 |
---|---|---|
C++ | void __cdecl f() |
delegate* unmanaged[Cdecl]<void> |
C++ | void __cdecl f(int) |
delegate* unmanaged[Cdecl]<int, void> |
C++ | int __cdecl f(int) |
delegate* unmanaged[Cdecl]<int, int> |
C# | static string f(int) |
delegate* managed<int, string> |
C# | static void f(string) |
delegate* managed<string, void> |
[Cdecl]
のみならず、[Stdcall]
と[Fastcall]
と[Thiscall]
も使えますよ。
アンマネージ関数の使い方
まずはネイティブ相互運用を紹介しましょう。
C++コードを書けます:
#define WIN32
#define UNICODE
#include <cstring>
#include <cstdio>
extern "C" __declspec(dllexport)
// C# 関数はパラメーター gen を介して渡され、呼び出されます
char* __cdecl foo(char* (*gen)(int), int count) {
return gen(count);
}
関数ポインタを使用する場合は、不安全なコードを許可するが必要です。そのために、プロパティファイル*.csproj
に次のコードを追加してください:
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
C#コード:
using System;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// unsafe が必要です
unsafe class Program
{
// ネイティブ関数の宣言
// nint はネイティブ整数の意味である、IntPtr にコンパイルされます
[DllImport("./foo.dll", EntryPoint = "foo")]
static extern string Foo(delegate* unmanaged[Cdecl]<int, nint> gen, int count);
// UnmanagedCallersOnly でマークされたメソッドには次の制限があります:
// * "static" に設定する
// * マネージコードから呼び出すことはダメ
// * Blittable パラメータのみ
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
static nint Generate(int count)
{
// count 個 'w' を含む文字列の生成
var str = Enumerable.Repeat("w", count).Aggregate((a, b) => $"{a}{b}");
return Marshal.StringToHGlobalAnsi(str);
}
static void Main(string[] args)
{
// 関数ポインタの作成
var f = (delegate* unmanaged[Cdecl]<int, nint>)(delegate*<int, nint>)&Generate;
// 呼び出す
var result = Foo(f, 5);
Console.WriteLine(result);
}
}
上記のコードは、C# の関数を C++ に渡して、C++ は C# から渡された関数を呼び出します。
最後はコードをコンパイルして実行します:
$ clang++ foo.cpp -shared -o foo.dll # C++ コードのコンパイル
$ dotnet run # C# コードのコンパイルと実行
出力:
wwwww
ここまで関数ポインタでネイティブ相互運用を使う方法は紹介しました!
マネージ関数の使い方
これはアンマネージ関数と大体同じで、より簡単ですよ。
C# コード:
using System;
using System.Linq;
unsafe class Program
{
static string Generate(int count)
{
// count 個 'w' を含む文字列の生成
var str = Enumerable.Repeat("w", count).Aggregate((a, b) => $"{a}{b}");
return str;
}
static void Main(string[] args)
{
// 関数ポインタの作成
var f = (delegate*<int, string>)&Generate;
// 呼び出す
var result = f(5);
Console.WriteLine(result);
}
}
コンパイルと実行:
$ dotnet run
出力:
wwwww
Bravo!
パフォーマンスの比較
デリゲートと比較して、なぜ関数ポインタがより効率的ですか?コンパイルされた IL コードを見てみましょう。
C# コード:
static void FuntionPointerApproach()
{
// 関数ポインタの作成
var f = (delegate*<int, string>)&Generate;
// 呼び出す
f(5);
}
static void DelegateApproach()
{
// デリゲートの作成
var f = (Func<int, string>)Generate;
// 呼び出す
f(5);
}
IL コード:
.method private hidebysig static
void FuntionPointerApproach () cil managed
{
// Method begins at RVA 0x2084
// Code size 16 (0x10)
.maxstack 2
.locals init (
[0] method string *(int32)
)
IL_0000: ldftn string Program::Generate(int32)
IL_0006: stloc.0
IL_0007: ldc.i4.5
IL_0008: ldloc.0
IL_0009: calli string(int32)
IL_000e: pop
IL_000f: ret
} // end of method Program::FuntionPointerApproach
.method private hidebysig static
void DelegateApproach () cil managed
{
// Method begins at RVA 0x20a0
// Code size 20 (0x14)
.maxstack 8
IL_0000: ldnull
IL_0001: ldftn string Program::Generate(int32)
IL_0007: newobj instance void class [System.Private.CoreLib]System.Func`2<int32, string>::.ctor(object, native int)
IL_000c: ldc.i4.5
IL_000d: callvirt instance !1 class [System.Private.CoreLib]System.Func`2<int32, string>::Invoke(!0)
IL_0012: pop
IL_0013: ret
} // end of method Program::DelegateApproach
IL を見れば理解できるはずです。関数ポインタは冗長オブジェクトSystem.Func<>
を作成する必要はなく、関数はcalli
命令を介して直接呼び出すことができます。
シンプルなテストコード:
static void Main(string[] args)
{
var count = 100000000;
var st = new Stopwatch();
st.Start();
for (var i = 0; i < count; i++)
{
FuntionPointerApproach();
}
st.Stop();
Console.WriteLine($"関数ポインタ: {st.ElapsedMilliseconds} ms");
st.Restart();
for (var i = 0; i < count; i++)
{
DelegateApproach();
}
st.Stop();
Console.WriteLine($"デリゲート: {st.ElapsedMilliseconds} ms");
}
結果:
$ dotnet run -c Release
関数ポインタ: 11608 ms
デリゲート: 12462 ms
予想通り!
まとめ
関数ポインタを使用するとネイティブインターロップはすぐに簡単になります。
さらに、これはデリゲートよりもっと効率的です。
こんなに素晴らしい機能があるし、早速.NET 5をダウンロードして新しい関数ポインタの機能を試しようぜ!