次の方法で共有


メモリ<T> とスパン<T> 使用ガイドライン

.NET には、任意の連続したメモリ領域を表す多くの型が含まれています。 Span<T> および ReadOnlySpan<T> は、マネージド メモリまたはアンマネージド メモリへの参照をラップする軽量メモリ バッファーです。 これらの型はスタックにのみ格納できるため、非同期メソッド呼び出しなどのシナリオには適しません。 この問題に対処するために、.NET 2.1 では、 Memory<T>ReadOnlyMemory<T>IMemoryOwner<T>MemoryPool<T>など、いくつかの種類が追加されました。 Span<T>と同様に、Memory<T>とその関連する型は、マネージド メモリとアンマネージド メモリの両方でサポートできます。 Span<T>とは異なり、Memory<T>はマネージド ヒープに格納できます。

Span<T>Memory<T>はどちらも、パイプラインで使用できる構造化データのバッファーのラッパーです。 つまり、一部またはすべてのデータをパイプライン内のコンポーネントに効率的に渡すことができるように設計されています。パイプライン内のコンポーネントを処理し、必要に応じてバッファーを変更できます。 Memory<T>とその関連する型には複数のコンポーネントまたは複数のスレッドからアクセスできるため、堅牢なコードを生成するには、いくつかの標準的な使用ガイドラインに従う必要があります。

所有者、コンシューマー、および有効期間管理

バッファーは API 間で渡すことができ、複数のスレッドからアクセスできることがあるため、バッファーの有効期間の管理方法に注意してください。 次の 3 つの主要な概念があります。

  • 所有権 バッファー インスタンスの所有者は、バッファーが使用されなくなったときにバッファーを破棄するなど、有効期間管理を担当します。 すべてのバッファーには 1 つの所有者があります。 通常、所有者は、バッファーを作成したコンポーネント、またはファクトリからバッファーを受信したコンポーネントです。 所有権を譲渡することもできます。 コンポーネント A は、バッファーの制御を Component-B に放棄できます。この時点で 、Component-A はバッファーを使用しなくなり、 コンポーネント B は使用されなくなったバッファーを破棄する役割を担います。

  • 消費。 バッファー インスタンスのコンシューマーは、バッファー インスタンスから読み取り、場合によってはバッファー インスタンスに書き込むことによって、バッファー インスタンスを使用できます。 一部の外部同期メカニズムが提供されていない限り、バッファーには一度に 1 つのコンシューマーを含めることができます。 バッファーのアクティブなコンシューマーは、必ずしもバッファーの所有者であるとは限りません。

  • リース。 リースは、特定のコンポーネントがバッファーのコンシューマーとして許可される時間の長さです。

次の擬似コード例は、これら 3 つの概念を示しています。 Buffer擬似コードでは、Memory<T>型のSpan<T>またはCharバッファーを表します。 Main メソッドはバッファーをインスタンス化し、WriteInt32ToBuffer メソッドを呼び出して整数の文字列形式をバッファーに書き込み、DisplayBufferToConsole メソッドを呼び出してバッファーの値を表示します。

using System;

class Program
{
    // Write 'value' as a human-readable string to the output buffer.
    void WriteInt32ToBuffer(int value, Buffer buffer);

    // Display the contents of the buffer to the console.
    void DisplayBufferToConsole(Buffer buffer);

    // Application code
    static void Main()
    {
        var buffer = CreateBuffer();
        try
        {
            int value = Int32.Parse(Console.ReadLine());
            WriteInt32ToBuffer(value, buffer);
            DisplayBufferToConsole(buffer);
        }
        finally
        {
            buffer.Destroy();
        }
    }
}

Main メソッドはバッファーを作成し、その所有者でもあります。 したがって、 Main は、バッファーが使用されなくなったときにバッファーを破棄する役割を担います。 擬似コードは、バッファーで Destroy メソッドを呼び出すことによってこれを示しています。 ( Memory<T>Span<T> も、実際には Destroy メソッドを持っていない。実際のコード例については、この記事の後半で説明します)。

バッファーには、 WriteInt32ToBufferDisplayBufferToConsoleの 2 つのコンシューマーがあります。 一度に 1 つのコンシューマー (最初の WriteInt32ToBuffer、次に DisplayBufferToConsole) があり、どちらのコンシューマーもバッファーを所有していない。 また、このコンテキストの "コンシューマー" は、バッファーの読み取り専用ビューを意味しません。コンシューマーは、バッファーの読み取り/書き込みビュー WriteInt32ToBuffer 与えられた場合と同様に、バッファーの内容を変更できます。

WriteInt32ToBuffer メソッドは、メソッド呼び出しの開始からメソッドから戻る時刻までの間にバッファーをリース (使用できます) します。 同様に、 DisplayBufferToConsole の実行中にバッファーにリースがあり、メソッドがアンワインドされるとリースが解放されます。 (リース管理用の API はありません。"リース" は概念的な問題です)。

Memory<T> と所有者/コンシューマーモデル

所有者、コンシューマー、および有効期間管理セクションでメモされているように、バッファーには常に所有者が含まれます。 .NET では、次の 2 つの所有権モデルがサポートされています。

  • 単一所有権をサポートするモデル。 バッファーには、その有効期間全体の所有者が 1 人います。
  • 所有権の譲渡をサポートするモデル。 バッファーの所有権は、元の所有者 (その作成者) から別のコンポーネントに転送できます。これにより、バッファーの有効期間管理が行われます。 その所有者は、別のコンポーネントに所有権を譲渡できます。

System.Buffers.IMemoryOwner<T> インターフェイスを使用して、バッファーの所有権を明示的に管理します。 IMemoryOwner<T> では、両方の所有権モデルがサポートされています。 IMemoryOwner<T>参照を持つコンポーネントは、バッファーを所有します。 次の例では、 IMemoryOwner<T> インスタンスを使用して、 Memory<T> バッファーの所有権を反映します。

using System;
using System.Buffers;

class Example
{
    static void Main()
    {
        IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent();

        Console.Write("Enter a number: ");
        try
        {
            string? s = Console.ReadLine();

            if (s is null)
                return;

            var value = Int32.Parse(s);

            var memory = owner.Memory;

            WriteInt32ToBuffer(value, memory);

            DisplayBufferToConsole(owner.Memory.Slice(0, value.ToString().Length));
        }
        catch (FormatException)
        {
            Console.WriteLine("You did not enter a valid number.");
        }
        catch (OverflowException)
        {
            Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}.");
        }
        finally
        {
            owner?.Dispose();
        }
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();

        var span = buffer.Span;
        for (int ctr = 0; ctr < strValue.Length; ctr++)
            span[ctr] = strValue[ctr];
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}

この例はusing ステートメントで記述することもできます。

using System;
using System.Buffers;

class Example
{
    static void Main()
    {
        using (IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent())
        {
            Console.Write("Enter a number: ");
            try
            {
                string? s = Console.ReadLine();

                if (s is null)
                    return;

                var value = Int32.Parse(s);

                var memory = owner.Memory;
                WriteInt32ToBuffer(value, memory);
                DisplayBufferToConsole(memory.Slice(0, value.ToString().Length));
            }
            catch (FormatException)
            {
                Console.WriteLine("You did not enter a valid number.");
            }
            catch (OverflowException)
            {
                Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}.");
            }
        }
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();

        var span = buffer.Slice(0, strValue.Length).Span;
        strValue.AsSpan().CopyTo(span);
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}

このコードでは、次の操作を行います。

  • Main メソッドはIMemoryOwner<T> インスタンスへの参照を保持するため、Main メソッドはバッファーの所有者です。
  • WriteInt32ToBufferメソッドと DisplayBufferToConsole メソッドは、パブリック API としてMemory<T>を受け入れます。 したがって、これらはバッファーのコンシューマーです。 これらのメソッドは、バッファーを一度に 1 つずつ使用します。

WriteInt32ToBuffer メソッドはバッファーに値を書き込む目的ですが、DisplayBufferToConsole メソッドは意図されていません。 これを反映するために、 ReadOnlyMemory<T>型の引数を受け入れた可能性があります。 ReadOnlyMemory<T>の詳細については、「規則 2: ReadOnlySpan<T> または ReadOnlyMemory<T> バッファーを読み取り専用にする必要がある場合を参照してください。

"所有者なしの" Memory<T> インスタンス

Memory<T>を使用せずに、IMemoryOwner<T> インスタンスを作成できます。 この場合、バッファーの所有権は明示的ではなく暗黙的であり、単一所有者モデルのみがサポートされます。 これを行うには、次の操作を行います。

  • 次の例のように、 Memory<T> コンストラクターのいずれかを直接呼び出し、 T[]を渡します。
  • String.AsMemory 拡張メソッドを呼び出して、ReadOnlyMemory<char> インスタンスを生成します。
using System;

class Example
{
    static void Main()
    {
        Memory<char> memory = new char[64];

        Console.Write("Enter a number: ");
        string? s = Console.ReadLine();

        if (s is null)
            return;

        var value = Int32.Parse(s);

        WriteInt32ToBuffer(value, memory);
        DisplayBufferToConsole(memory);
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();
        strValue.AsSpan().CopyTo(buffer.Slice(0, strValue.Length).Span);
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}

最初に Memory<T> インスタンスを作成するメソッドは、バッファーの暗黙的な所有者です。 譲渡を容易にする IMemoryOwner<T> インスタンスがないため、所有権を他のコンポーネントに譲渡することはできません。 (別の方法として、ランタイムのガベージ コレクターがバッファーを所有しており、すべてのメソッドがバッファーを使用するだけであるとも考えることができます)。

使用ガイドライン

メモリ ブロックは所有されていますが、複数のコンポーネントに渡されることを意図しているため、その一部は特定のメモリ ブロックで同時に動作する可能性があるため、 Memory<T>Span<T>の両方を使用するためのガイドラインを確立することが重要です。 ガイドラインが必要な理由は、コンポーネントで次の場合があるためです。

  • 所有者がメモリ ブロックを解放した後も、メモリ ブロックへの参照を保持します。
  • バッファー内のデータを破損するプロセスで、別のコンポーネントが操作しているのと同時にバッファーを操作します。

Span<T>のスタック割り当て特性はパフォーマンスを最適化し、Span<T>メモリ ブロックで動作するための推奨される種類になりますが、いくつかの大きな制限Span<T>対象にもなります。 Span<T>を使用するタイミングと、Memory<T>を使用するタイミングを把握することが重要です。

Memory<T>とその関連する型を正常に使用するための推奨事項を次に示します。 Memory<T>およびSpan<T>に適用されるガイダンスは、特に明記されていない限り、ReadOnlyMemory<T>ReadOnlySpan<T>にも適用されます。

規則 1: 同期 API の場合は、可能であれば、パラメーターとして Memory<T> の代わりに Span<T> を使用します

Span<T> は、 Memory<T> よりも汎用性が高く、さまざまな連続したメモリ バッファーを表すことができます。 Span<T> また、 Memory<T>よりも優れたパフォーマンスを提供します。 最後に、 Memory<T>.Span プロパティを使用して、 Memory<T> インスタンスを Span<T> に変換できますが、Span<T>-to-Memory<T> 変換はできません。 したがって、呼び出し元が Memory<T> インスタンスを持っている場合は、とにかく Span<T> パラメーターを使用してメソッドを呼び出すことができます。

Span<T>の代わりにMemory<T>型のパラメーターを使用すると、正しい使用メソッドの実装を記述するのにも役立ちます。 メソッドのリースを超えてバッファーにアクセスしようとしていないことを確認するために、コンパイル時のチェックが自動的に行われます (詳細については後で説明します)。

完全に同期している場合でも、Memory<T> パラメーターの代わりに Span<T> パラメーターを使用する必要がある場合があります。 依存している API では、 Memory<T> 引数のみを受け入れる場合があります。 これは問題ありませんが、 Memory<T> を同期的に使用する場合のトレードオフに注意してください。

規則 2: バッファーを読み取り専用にする場合は、ReadOnlySpan<T> または ReadOnlyMemory<T> を使用する

前の例では、 DisplayBufferToConsole メソッドはバッファーからのみ読み取ります。バッファーの内容は変更しません。 メソッドシグネチャを次のように変更する必要があります。

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

実際、このルールとルール #1 を組み合わせると、メソッドシグネチャをさらに改善し、次のように書き換えることができます。

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

DisplayBufferToConsoleメソッドは、T[]stackalloc で割り当てられたストレージなど、事実上すべてのバッファー型で動作するようになりました。 String を直接渡すこともできます。 詳細については、GitHub の 発行 dotnet/docs #25551 を参照してください。

規則 3: メソッドが Memory<T> を受け入れて voidを返す場合は、メソッドが戻った後に Memory<T> インスタンスを使用しないでください

これは、前に説明した "リース" の概念に関連しています。 Memory<T> インスタンスでの void を返すメソッドのリースは、メソッドが入力されたときに開始され、メソッドが終了すると終了します。 次の例では、コンソールからの入力に基づいてループ内の Log を呼び出します。

// <Snippet1>
using System;
using System.Buffers;

public class Example
{
    // implementation provided by third party
    static extern void Log(ReadOnlyMemory<char> message);

    // user code
    public static void Main()
    {
        using (var owner = MemoryPool<char>.Shared.Rent())
        {
            var memory = owner.Memory;
            var span = memory.Span;
            while (true)
            {
                string? s = Console.ReadLine();

                if (s is null)
                    return;

                int value = Int32.Parse(s);
                if (value < 0)
                    return;

                int numCharsWritten = ToBuffer(value, span);
                Log(memory.Slice(0, numCharsWritten));
            }
        }
    }

    private static int ToBuffer(int value, Span<char> span)
    {
        string strValue = value.ToString();
        int length = strValue.Length;
        strValue.AsSpan().CopyTo(span.Slice(0, length));
        return length;
    }
}
// </Snippet1>

// Possible implementation of Log:
    // private static void Log(ReadOnlyMemory<char> message)
    // {
    //     Console.WriteLine(message);
    // }

Logが完全同期メソッドの場合、このコードは、メモリ インスタンスのアクティブなコンシューマーが一度に 1 つしかないため、期待どおりに動作します。 しかし、代わりに、 Log にこの実装があるとします。

// !!! INCORRECT IMPLEMENTATION !!!
static void Log(ReadOnlyMemory<char> message)
{
    // Run in background so that we don't block the main thread while performing IO.
    Task.Run(() =>
    {
        StreamWriter sw = File.AppendText(@".\input-numbers.dat");
        sw.WriteLine(message);
    });
}

この実装では、元のメソッドが返された後もLog インスタンスをバックグラウンドで使用しようとするため、Memory<T>はリースに違反します。 Mainメソッドはバッファーから読み取ろうとLogバッファーを変更する可能性があり、その結果、データが破損する可能性があります。

これを解決する方法はいくつかあります。

  • Log メソッドは、Task メソッドの次の実装のように、voidではなくLogを返すことができます。

    // An acceptable implementation.
    static Task Log(ReadOnlyMemory<char> message)
    {
        // Run in the background so that we don't block the main thread while performing IO.
        return Task.Run(() => {
            StreamWriter sw = File.AppendText(@".\input-numbers.dat");
            sw.WriteLine(message);
            sw.Flush();
        });
    }
    
  • Log 代わりに次のように実装できます。

    // An acceptable implementation.
    static void Log(ReadOnlyMemory<char> message)
    {
        string defensiveCopy = message.ToString();
        // Run in the background so that we don't block the main thread while performing IO.
        Task.Run(() =>
        {
            StreamWriter sw = File.AppendText(@".\input-numbers.dat");
            sw.WriteLine(defensiveCopy);
            sw.Flush();
        });
    }
    

規則 4: メソッドが Memory<T> を受け入れて Task を返す場合は、Task が終了状態に遷移した後に Memory<T> インスタンスを使用しないでください

これは、ルール #3 の非同期バリアントにすぎません。 前の例の Log メソッドは、この規則に準拠するために次のように記述できます。

// An acceptable implementation.
static Task Log(ReadOnlyMemory<char> message)
{
    // Run in the background so that we don't block the main thread while performing IO.
    return Task.Run(() => {
        StreamWriter sw = File.AppendText(@".\input-numbers.dat");
        sw.WriteLine(message);
        sw.Flush();
    });
}

ここで、「終了状態」とは、タスクが完了状態、障害状態、または取り消された状態に遷移することを意味します。 つまり、"終了状態" とは、"スローまたは実行継続の待機を発生させるすべてのもの" を意味します。

このガイダンスは、 TaskTask<TResult>ValueTask<TResult>、または同様の型を返すメソッドに適用されます。

規則 5: コンストラクターが Memory<T> をパラメーターとして受け入れる場合、構築されたオブジェクトのインスタンス メソッドは Memory<T> インスタンスのコンシューマーであると見なされます

次の例を確認してください。

class OddValueExtractor
{
    public OddValueExtractor(ReadOnlyMemory<int> input);
    public bool TryReadNextOddValue(out int value);
}

void PrintAllOddValues(ReadOnlyMemory<int> input)
{
    var extractor = new OddValueExtractor(input);
    while (extractor.TryReadNextOddValue(out int value))
    {
      Console.WriteLine(value);
    }
}

ここでは、 OddValueExtractor コンストラクターはコンストラクター パラメーターとして ReadOnlyMemory<int> を受け取るので、コンストラクター自体は ReadOnlyMemory<int> インスタンスのコンシューマーであり、戻り値のすべてのインスタンス メソッドも元の ReadOnlyMemory<int> インスタンスのコンシューマーです。 つまり、インスタンスがTryReadNextOddValue メソッドに直接渡されない場合でも、ReadOnlyMemory<int>TryReadNextOddValue インスタンスを使用します。

規則 6: 型に設定可能な Memory<T> 型プロパティ (または同等のインスタンス メソッド) がある場合、そのオブジェクトのインスタンス メソッドは Memory<T> インスタンスのコンシューマーであると見なされます

これは実際にはルール #5 の一種にすぎません。 この規則は、プロパティ セッターまたは同等のメソッドが入力をキャプチャして保持すると見なされるため、同じオブジェクト上のインスタンス メソッドがキャプチャされた状態を利用する可能性があるために存在します。

次の例では、この規則をトリガーします。

class Person
{
    // Settable property.
    public Memory<char> FirstName { get; set; }

    // alternatively, equivalent "setter" method
    public SetFirstName(Memory<char> value);

    // alternatively, a public settable field
    public Memory<char> FirstName;
}

規則 7: IMemoryOwner<T> 参照がある場合は、ある時点で破棄するか、その所有権を譲渡する必要があります (両方を譲渡することはできません)

Memory<T> インスタンスはマネージド メモリまたはアンマネージド メモリによってサポートされる可能性があるため、Dispose インスタンスで実行された作業が完了したら、所有者はIMemoryOwner<T>Memory<T>を呼び出す必要があります。 または、所有者は、 IMemoryOwner<T> インスタンスの所有権を別のコンポーネントに譲渡できます。その時点で、取得コンポーネントは適切なタイミングで Dispose を呼び出す役割を担います (詳細については後で説明します)。

Dispose インスタンスでIMemoryOwner<T> メソッドを呼び出さなければ、アンマネージ メモリ リークやその他のパフォーマンス低下が発生する可能性があります。

この規則は、 MemoryPool<T>.Rentなどのファクトリ メソッドを呼び出すコードにも適用されます。 呼び出し元は、返された IMemoryOwner<T> の所有者になり、完了時にインスタンスを破棄する役割を担います。

規則 8: API サーフェイスに IMemoryOwner<T> パラメーターがある場合は、そのインスタンスの所有権を受け入れます

この型のインスタンスを受け入れることは、コンポーネントがこのインスタンスの所有権を取得しようとしていることを示します。 規則 7 に従い、コンポーネントは適切な破棄の責任を負うようになります。

IMemoryOwner<T> インスタンスの所有権を別のコンポーネントに転送するコンポーネントは、メソッド呼び出しが完了した後で、そのインスタンスを使用しなくなります。

重要

コンストラクターがパラメーターとしてIMemoryOwner<T>を受け入れる場合、その型はIDisposableを実装する必要があり、Dispose メソッドはDispose オブジェクトのIMemoryOwner<T>を呼び出す必要があります。

規則 9: 同期 p/invoke メソッドをラップする場合、API は Span<T> をパラメーターとして受け入れる必要があります

規則 1 によると、 Span<T> は通常、同期 API に使用する適切な種類です。 Span<T>インスタンスは、次の例のように、fixed キーワードを使用してピン留めできます。

using System.Runtime.InteropServices;

[DllImport(...)]
private static extern unsafe int ExportedMethod(byte* pbData, int cbData);

public unsafe int ManagedWrapper(Span<byte> data)
{
    fixed (byte* pbData = &MemoryMarshal.GetReference(data))
    {
        int retVal = ExportedMethod(pbData, data.Length);

        /* error checking retVal goes here */

        return retVal;
    }
}

前の例では、たとえば入力スパンが空の場合、 pbData は null にすることができます。 エクスポートされたメソッドで null 以外 pbData 必要がある場合は、 cbData が 0 であっても、次のようにメソッドを実装できます。

public unsafe int ManagedWrapper(Span<byte> data)
{
    fixed (byte* pbData = &MemoryMarshal.GetReference(data))
    {
        byte dummy = 0;
        int retVal = ExportedMethod((pbData != null) ? pbData : &dummy, data.Length);

        /* error checking retVal goes here */

        return retVal;
    }
}

規則 10: 非同期の p/invoke メソッドをラップする場合、API は Memory<T> をパラメーターとして受け入れる必要があります

非同期操作では fixed キーワードを使用できないため、インスタンスが表す連続したメモリの種類に関係なく、 Memory<T>.Pin メソッドを使用してインスタンス Memory<T> 固定します。 次の例は、この API を使用して非同期の p/invoke 呼び出しを実行する方法を示しています。

using System.Runtime.InteropServices;

[UnmanagedFunctionPointer(...)]
private delegate void OnCompletedCallback(IntPtr state, int result);

[DllImport(...)]
private static extern unsafe int ExportedAsyncMethod(byte* pbData, int cbData, IntPtr pState, IntPtr lpfnOnCompletedCallback);

private static readonly IntPtr _callbackPtr = GetCompletionCallbackPointer();

public unsafe Task<int> ManagedWrapperAsync(Memory<byte> data)
{
    // setup
    var tcs = new TaskCompletionSource<int>();
    var state = new MyCompletedCallbackState
    {
        Tcs = tcs
    };
    var pState = (IntPtr)GCHandle.Alloc(state);

    var memoryHandle = data.Pin();
    state.MemoryHandle = memoryHandle;

    // make the call
    int result;
    try
    {
        result = ExportedAsyncMethod((byte*)memoryHandle.Pointer, data.Length, pState, _callbackPtr);
    }
    catch
    {
        ((GCHandle)pState).Free(); // cleanup since callback won't be invoked
        memoryHandle.Dispose();
        throw;
    }

    if (result != PENDING)
    {
        // Operation completed synchronously; invoke callback manually
        // for result processing and cleanup.
        MyCompletedCallbackImplementation(pState, result);
    }

    return tcs.Task;
}

private static void MyCompletedCallbackImplementation(IntPtr state, int result)
{
    GCHandle handle = (GCHandle)state;
    var actualState = (MyCompletedCallbackState)(handle.Target);
    handle.Free();
    actualState.MemoryHandle.Dispose();

    /* error checking result goes here */

    if (error)
    {
        actualState.Tcs.SetException(...);
    }
    else
    {
        actualState.Tcs.SetResult(result);
    }
}

private static IntPtr GetCompletionCallbackPointer()
{
    OnCompletedCallback callback = MyCompletedCallbackImplementation;
    GCHandle.Alloc(callback); // keep alive for lifetime of application
    return Marshal.GetFunctionPointerForDelegate(callback);
}

private class MyCompletedCallbackState
{
    public TaskCompletionSource<int> Tcs;
    public MemoryHandle MemoryHandle;
}

こちらも参照ください