Wskazówki dotyczące użycia pamięci<T> i span<T>

Platforma .NET zawiera wiele typów reprezentujących dowolny ciągły region pamięci. Span<T> i ReadOnlySpan<T> są lekkimi buforami pamięci, które opakowują odwołania do zarządzanej lub niezarządzanej pamięci. Ponieważ te typy mogą być przechowywane tylko na stosie, są one nieodpowiednie dla scenariuszy, takich jak wywołania metod asynchronicznych. Aby rozwiązać ten problem, platforma .NET 2.1 dodała kilka dodatkowych typów, w tym Memory<T>, ReadOnlyMemory<T>, IMemoryOwner<T>i MemoryPool<T>. Podobnie jak Span<T>i Memory<T> powiązane typy mogą być obsługiwane zarówno przez pamięć zarządzaną, jak i niezarządzaną. W przeciwieństwie do Span<T>programu Memory<T> można przechowywać na zarządzanym stercie.

Memory<T> Oba Span<T> elementy i są otokami buforów danych ustrukturyzowanych, które mogą być używane w potokach. Oznacza to, że są one zaprojektowane tak, aby niektóre lub wszystkie dane mogły być efektywnie przekazywane do składników w potoku, co może je przetwarzać i opcjonalnie modyfikować bufor. Ze względu Memory<T> na to, że dostęp do jego powiązanych typów można uzyskać za pomocą wielu składników lub wielu wątków, ważne jest, aby postępować zgodnie z pewnymi standardowymi wytycznymi dotyczącymi użycia w celu utworzenia niezawodnego kodu.

Właściciele, konsumenci i zarządzanie okresem istnienia

Bufory mogą być przekazywane między interfejsami API i czasami mogą być dostępne z wielu wątków, dlatego należy pamiętać o tym, jak jest zarządzany okres istnienia buforu. Istnieją trzy podstawowe pojęcia:

  • Własność. Właściciel wystąpienia buforu jest odpowiedzialny za zarządzanie okresem istnienia, w tym zniszczenie buforu, gdy nie jest już używany. Wszystkie bufory mają jednego właściciela. Zazwyczaj właścicielem jest składnik, który utworzył bufor lub który odebrał bufor z fabryki. Własność można również przenieść; Składnik-A może zrezygnować z kontroli buforu na Component-B, w którym to momencie Składnik-A nie może już używać buforu, a składnik-B staje się odpowiedzialny za zniszczenie buforu, gdy nie jest już używany.

  • Zużycie. Użytkownik wystąpienia buforu może używać wystąpienia buforu, odczytując z niego i ewentualnie zapisując je. Bufory mogą mieć jednego konsumenta naraz, chyba że zostanie udostępniony jakiś mechanizm synchronizacji zewnętrznej. Aktywny konsument buforu nie musi być właścicielem buforu.

  • Dzierżawa. Dzierżawa to czas, przez jaki dany składnik może być konsumentem buforu.

Poniższy przykład pseudo-kodu ilustruje te trzy koncepcje. Bufferw pseudokodzie reprezentuje Memory<T> bufor Chartypu lub Span<T> . Metoda Main tworzy wystąpienie buforu, wywołuje WriteInt32ToBuffer metodę , aby zapisać reprezentację ciągu liczby całkowitej w buforze, a następnie wywołuje DisplayBufferToConsole metodę w celu wyświetlenia wartości buforu.

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();
        }
    }
}

Metoda Main tworzy bufor i tak jest jego właścicielem. Main W związku z tym jest odpowiedzialny za zniszczenie buforu, gdy nie jest już używany. Przykładowy kod ilustruje to przez wywołanie Destroy metody w buforze. (Ani nie Memory<T>Span<T> ma Destroy metody. W dalszej części tego artykułu zobaczysz rzeczywiste przykłady kodu).

Bufor ma dwóch odbiorców i WriteInt32ToBufferDisplayBufferToConsole. Jednocześnie istnieje tylko jeden konsument (najpierw WriteInt32ToBuffer, a następnie DisplayBufferToConsole), a żaden z konsumentów nie jest właścicielem buforu. Należy również pamiętać, że "konsument" w tym kontekście nie oznacza widoku buforu tylko do odczytu; użytkownicy mogą modyfikować zawartość buforu, tak jak WriteInt32ToBuffer w przypadku wyświetlania widoku odczytu/zapisu buforu.

Metoda WriteInt32ToBuffer ma dzierżawę (może zużywać) bufor między rozpoczęciem wywołania metody a czasem zwracania metody. Podobnie, DisplayBufferToConsole ma dzierżawę buforu podczas jego wykonywania, a dzierżawa jest zwalniana, gdy metoda się odwija. (Nie ma interfejsu API do zarządzania dzierżawami; "dzierżawa" jest kwestią koncepcyjną).

Pamięć<T> i model właściciela/odbiorcy

Jak zauważa sekcja Właściciele, konsumenci i zarządzanie okresem istnienia, bufor zawsze ma właściciela. Platforma .NET obsługuje dwa modele własności:

  • Model, który obsługuje pojedynczą własność. Bufor ma jednego właściciela przez cały okres istnienia.

  • Model, który obsługuje przenoszenie własności. Własność buforu może zostać przeniesiona z oryginalnego właściciela (jego twórcy) do innego składnika, który następnie staje się odpowiedzialny za zarządzanie okresem istnienia buforu. Ten właściciel może z kolei przenieść własność do innego składnika i tak dalej.

Interfejs służy System.Buffers.IMemoryOwner<T> do jawnego zarządzania własnością buforu. IMemoryOwner<T> obsługuje oba modele własności. Składnik, który ma odwołanie, jest właścicielem IMemoryOwner<T> buforu. W poniższym przykładzie użyto IMemoryOwner<T> wystąpienia do odzwierciedlenia własności buforu 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}'");
}

Możemy również napisać ten przykład za pomocą instrukcji 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}'");
}

W tym kodzie:

  • Metoda Main przechowuje odwołanie do IMemoryOwner<T> wystąpienia, więc Main metoda jest właścicielem buforu.

  • Metody WriteInt32ToBuffer i DisplayBufferToConsole akceptują Memory<T> jako publiczny interfejs API. W związku z tym są one konsumentami buforu. Te metody zużywają bufor pojedynczo.

WriteInt32ToBuffer Mimo że metoda ma na celu zapisanie wartości w buforze, DisplayBufferToConsole metoda nie jest przeznaczona. Aby to odzwierciedlić, może zaakceptować argument typu ReadOnlyMemory<T>. Aby uzyskać więcej informacji na temat ReadOnlyMemory<T>programu , zobacz Rule #2: Use ReadOnlySpan T or ReadOnlyMemory T if the buffer should be read-only (Reguła nr 2: Używanie funkcji ReadOnlySpan<T> lub ReadOnlyMemory<T> , jeśli bufor powinien być tylko do odczytu).

Wystąpienia "Bez właściciela" pamięci<T>

Wystąpienie można utworzyć Memory<T> bez użycia polecenia IMemoryOwner<T>. W takim przypadku własność buforu jest niejawna, a nie jawna, a obsługiwany jest tylko model pojedynczego właściciela. Można to zrobić w następujący sposób:

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}'");
}

Metoda, która początkowo tworzy Memory<T> wystąpienie, jest niejawny właściciel buforu. Nie można przenieść własności do żadnego innego składnika, ponieważ nie IMemoryOwner<T> ma wystąpienia ułatwiającego transfer. (Alternatywnie można sobie wyobrazić, że moduł odśmieceń pamięci środowiska uruchomieniowego jest właścicielem buforu, a wszystkie metody po prostu zużywają bufor).

Wytyczne dotyczące użycia

Ponieważ blok pamięci jest własnością, ale ma zostać przekazany do wielu składników, niektóre z nich mogą działać jednocześnie na określonym bloku pamięci, ważne jest ustanowienie wytycznych dotyczących używania obu Memory<T> elementów i Span<T>. Wytyczne są niezbędne, ponieważ możliwe jest, aby składnik:

  • Zachowaj odwołanie do bloku pamięci po jego wydaniu przez właściciela.

  • Działają w buforze w tym samym czasie, w którym działa inny składnik, w procesie uszkadzającym dane w buforze.

  • Podczas gdy przydzielony stos charakter optymalizacji Span<T> wydajności i sprawia, że Span<T> preferowany typ działania w bloku pamięci, podlega Span<T> również pewnym poważnym ograniczeniom. Ważne jest, aby wiedzieć, kiedy używać a Span<T> i kiedy należy używać polecenia Memory<T>.

Poniżej przedstawiono nasze zalecenia dotyczące pomyślnego użycia Memory<T> i powiązanych typów. Wskazówki, które mają zastosowanie do Memory<T> i mają zastosowanie do ReadOnlyMemory<T> i Span<T>ReadOnlySpan<T>, o ile nie określono inaczej.

Reguła nr 1: w przypadku synchronicznego interfejsu API użyj opcji Span<T zamiast pamięci<T>> jako parametru, jeśli to możliwe.

Span<T> jest bardziej wszechstronny niż Memory<T> i może reprezentować szerszą gamę ciągłych buforów pamięci. Span<T> ponadto zapewnia lepszą wydajność niż Memory<T>. Na koniec możesz użyć Memory<T>.Span właściwości , aby przekonwertować Memory<T> wystąpienie na Span<T>wartość , chociaż<konwersja T-to-Memory>< nie> jest możliwa. Jeśli więc osoby wywołujące mają Memory<T> wystąpienie, będą mogli mimo to wywołać metody z parametrami Span<T> .

Użycie parametru typu zamiast typu Span<T>Memory<T> pomaga również napisać poprawną implementację metody zużywania. Automatycznie uzyskasz kontrole czasu kompilacji, aby upewnić się, że nie próbujesz uzyskać dostępu do buforu poza dzierżawą metody (więcej na ten temat później).

Czasami trzeba będzie użyć parametru Memory<T> zamiast parametru Span<T> , nawet jeśli jesteś w pełni synchroniczny. Być może interfejs API, który zależy od akceptowania tylko Memory<T> argumentów. Jest to w porządku, ale należy pamiętać o kompromisach związanych z użyciem Memory<T> synchronicznie.

Reguła nr 2: Użyj funkcji ReadOnlySpan<T lub ReadOnlyMemory<T>>, jeśli bufor powinien być tylko do odczytu.

We wcześniejszych przykładach DisplayBufferToConsole metoda odczytuje tylko z buforu; nie modyfikuje zawartości buforu. Sygnatura metody powinna zostać zmieniona na następującą.

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

W rzeczywistości, jeśli połączymy tę regułę i regułę nr 1, możemy zrobić jeszcze lepiej i przepisać podpis metody w następujący sposób:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

Metoda DisplayBufferToConsole działa teraz z praktycznie każdym typem buforu, które można sobie wyobrazić: T[], magazyn przydzielony stosalloc itd. Możesz nawet przekazać String do niego bezpośrednio! Aby uzyskać więcej informacji, zobacz problem z usługą GitHub dotnet/docs #25551.

Reguła nr 3: Jeśli metoda akceptuje pamięć<T i zwraca voidwartość , nie można użyć wystąpienia pamięci<T> po> powrocie metody.

Dotyczy to koncepcji "dzierżawy", o której wspomniano wcześniej. Dzierżawa metody zwracanej przez void w wystąpieniu Memory<T> rozpoczyna się po wprowadzeniu metody i kończy się po zakończeniu metody. Rozważmy poniższy przykład, który wywołuje Log pętlę na podstawie danych wejściowych z konsoli programu .

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;
    }
}

Jeśli Log jest w pełni synchroniczną metodą, ten kod będzie zachowywał się zgodnie z oczekiwaniami, ponieważ w danym momencie istnieje tylko jeden aktywny użytkownik wystąpienia pamięci. Wyobraźmy sobie jednak, że Log ma tę implementację.

// !!! 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);
    });
}

W tej implementacji narusza dzierżawę, Log ponieważ nadal próbuje użyć Memory<T> wystąpienia w tle po zwróconej oryginalnej metodzie. Metoda Main może mutować bufor podczas Log próby odczytu z niego, co może spowodować uszkodzenie danych.

Istnieje kilka sposobów rozwiązania tego problemu:

  • Metoda Log może zwracać wartość Task zamiast void, jak wykonuje następująca implementacja Log metody .

    // 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 Zamiast tego można zaimplementować w następujący sposób:

    // 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();
        });
    }
    

Reguła nr 4: Jeśli metoda akceptuje pamięć<T> i zwraca zadanie, nie można użyć wystąpienia pamięci<T> po przejściu zadania do stanu terminalu.

Jest to tylko wariant asynchroniczny reguły #3. Metodę Log z wcześniejszego przykładu można napisać w następujący sposób, aby zachować zgodność z tą regułą:

// 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();
    });
}

W tym miejscu "stan terminalu" oznacza, że zadanie przechodzi do stanu ukończonego, uszkodzonego lub anulowanego. Innymi słowy, "stan terminalu" oznacza "wszystko, co mogłoby spowodować wyrzucenie lub kontynuowanie wykonywania".

Te wskazówki dotyczą metod, które zwracają Task, Task<TResult>, ValueTask<TResult>lub dowolnego podobnego typu.

Reguła nr 5: Jeśli konstruktor akceptuje pamięć<T> jako parametr, przyjmuje się, że metody wystąpień obiektu skonstruowanego są konsumentami wystąpienia pamięci<T> .

Rozważmy następujący przykład:

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 W tym miejscu konstruktor przyjmuje ReadOnlyMemory<int> jako parametr konstruktora, więc sam konstruktor jest użytkownikiem ReadOnlyMemory<int> wystąpienia, a wszystkie metody wystąpienia zwróconej wartości są również konsumentami oryginalnego ReadOnlyMemory<int> wystąpienia. Oznacza to, że TryReadNextOddValue korzysta z ReadOnlyMemory<int> wystąpienia, mimo że wystąpienie nie jest przekazywane bezpośrednio do TryReadNextOddValue metody.

Reguła nr 6: Jeśli w typie jest ustawiona właściwość T pamięci<> (lub równoważna metoda wystąpienia), zakłada się, że metody wystąpień tego obiektu są konsumentami<wystąpienia wystąpienia T>.

To jest naprawdę tylko wariant reguły #5. Ta reguła istnieje, ponieważ metody ustawiania właściwości lub równoważne metody są zakładane do przechwytywania i utrwalania ich danych wejściowych, więc metody wystąpień na tym samym obiekcie mogą korzystać ze stanu przechwyconego.

Poniższy przykład wyzwala tę regułę:

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;
}

Reguła nr 7: Jeśli masz odwołanie IMemoryOwner<T> , musisz w pewnym momencie usunąć go lub przenieść jego własność (ale nie oba).

Memory<T> Ponieważ wystąpienie może być wspierane przez zarządzaną lub niezarządzaną pamięć, właściciel musi wywołać metodę IMemoryOwner<T>Dispose po zakończeniu pracy wykonywanej na wystąpieniuMemory<T>. Alternatywnie właściciel może przenieść własność IMemoryOwner<T> wystąpienia do innego składnika, w którym to momencie składnik uzyskiwania staje się odpowiedzialny za wywołanie Dispose w odpowiednim czasie (więcej na ten temat później).

Błąd wywołania metody w wystąpieniu DisposeIMemoryOwner<T> może prowadzić do niezarządzanych przecieków pamięci lub innego obniżenia wydajności.

Ta reguła dotyczy również kodu, który wywołuje metody fabryki, takie jak MemoryPool<T>.Rent. Obiekt wywołujący staje się właścicielem zwróconego IMemoryOwner<T> obiektu i odpowiada za usunięcie wystąpienia po zakończeniu.

Reguła nr 8: Jeśli masz parametr IMemoryOwner<T> na powierzchni interfejsu API, akceptujesz własność tego wystąpienia.

Zaakceptowanie wystąpienia tego typu sygnalizuje, że składnik zamierza przejąć własność tego wystąpienia. Składnik staje się odpowiedzialny za właściwą dyspozycję zgodnie z regułą nr 7.

Każdy składnik, który przenosi własność IMemoryOwner<T> wystąpienia do innego składnika, nie powinien już używać tego wystąpienia po zakończeniu wywołania metody.

Ważne

Jeśli konstruktor przyjmuje IMemoryOwner<T> jako parametr, jego typ powinien implementować IDisposablemetodę , a Dispose metoda powinna wywołać DisposeIMemoryOwner<T> obiekt .

Reguła nr 9: Jeśli opakowujesz synchroniczną metodę p/invoke, interfejs API powinien zaakceptować opcję Span<T> jako parametr.

Zgodnie z regułą nr 1, Span<T> jest zazwyczaj poprawnym typem używanym dla synchronicznych interfejsów API. Wystąpienia można przypiąć Span<T> za pomocą słowa kluczowego fixed , jak w poniższym przykładzie.

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;
    }
}

W poprzednim przykładzie może mieć wartość null, pbData jeśli na przykład zakres danych wejściowych jest pusty. Jeśli wyeksportowana metoda absolutnie wymaga wartości pbData innej niż null, nawet jeśli cbData ma wartość 0, można zaimplementować metodę w następujący sposób:

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;
    }
}

Reguła nr 10: Jeśli opakowujesz asynchroniczną metodę p/invoke, interfejs API powinien zaakceptować pamięć<T> jako parametr.

Ponieważ nie można użyć słowa kluczowego fixed w operacjach asynchronicznych, należy użyć Memory<T>.Pin metody do przypinania Memory<T> wystąpień, niezależnie od rodzaju ciągłej pamięci reprezentowanej przez wystąpienie. W poniższym przykładzie pokazano, jak używać tego interfejsu API do wykonywania asynchronicznego wywołania 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;
}

Zobacz też