Delen via


Richtlijnen voor geheugen<T>- en Span<T-gebruik>

.NET bevat een aantal typen die een willekeurige aaneengesloten regio van het geheugen vertegenwoordigen. Span<T> en ReadOnlySpan<T> zijn lichtgewicht geheugenbuffers die verwijzingen naar beheerd of onbeheerd geheugen verpakken. Omdat deze typen alleen op de stack kunnen worden opgeslagen, zijn ze niet geschikt voor scenario's zoals asynchrone methodeaanroepen. Om dit probleem op te lossen, heeft .NET 2.1 een aantal extra typen toegevoegd, waaronder Memory<T>, ReadOnlyMemory<T>, IMemoryOwner<T>en MemoryPool<T>. Net zoals Span<T>, Memory<T> en de bijbehorende typen kunnen worden ondersteund door zowel beheerd als onbeheerd geheugen. In tegenstelling tot Span<T>, Memory<T> kan worden opgeslagen op de beheerde heap.

Beide Span<T> en Memory<T> zijn wrappers over buffers van gestructureerde gegevens die kunnen worden gebruikt in pijplijnen. Dat wil gezegd, ze zijn zodanig ontworpen dat sommige of alle gegevens efficiënt kunnen worden doorgegeven aan onderdelen in de pijplijn, die ze kunnen verwerken en eventueel de buffer kunnen wijzigen. Omdat Memory<T> en de bijbehorende typen toegankelijk zijn voor meerdere onderdelen of door meerdere threads, is het belangrijk om enkele standaardgebruiksrichtlijnen te volgen om robuuste code te produceren.

Eigenaren, consumenten en levensduurbeheer

Buffers kunnen worden doorgegeven tussen API's en kunnen soms worden geopend vanuit meerdere threads, dus houd er rekening mee hoe de levensduur van een buffer wordt beheerd. Er zijn drie kernconcepten:

  • Eigendom. De eigenaar van een bufferinstantie is verantwoordelijk voor levensduurbeheer, inclusief het vernietigen van de buffer wanneer deze niet meer in gebruik is. Alle buffers hebben één eigenaar. Over het algemeen is de eigenaar het onderdeel dat de buffer heeft gemaakt of die de buffer van een fabriek heeft ontvangen. Eigendom kan ook worden overgedragen; Component-A kan de controle van de buffer naar Component-B terugleggen, op welk punt Component-A de buffer niet meer mag gebruiken en Component-B wordt verantwoordelijk voor het vernietigen van de buffer wanneer deze niet meer in gebruik is.

  • Verbruik. De consument van een bufferexemplaren mag het bufferexemplaren gebruiken door ernaar te lezen en er mogelijk naar te schrijven. Buffers kunnen één consument tegelijk hebben, tenzij er een extern synchronisatiemechanisme is opgegeven. De actieve consument van een buffer is niet noodzakelijkerwijs de eigenaar van de buffer.

  • Lease. De lease is de tijdsduur dat een bepaald onderdeel de consument van de buffer mag zijn.

In het volgende pseudocodevoorbeeld ziet u deze drie concepten. Buffer in de pseudocode staat voor een Memory<T> of Span<T> buffer van het type Char. De Main methode instantieert de buffer, roept de WriteInt32ToBuffer methode aan om de tekenreeksweergave van een geheel getal naar de buffer te schrijven en roept vervolgens de methode aan om de DisplayBufferToConsole waarde van de buffer weer te geven.

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

De Main methode maakt de buffer en is dus de eigenaar. Main Daarom is het verantwoordelijk voor het vernietigen van de buffer wanneer deze niet meer in gebruik is. De pseudocode illustreert dit door een Destroy methode op de buffer aan te roepen. (Noch Memory<T>Span<T> daadwerkelijk een Destroy methode. Verderop in dit artikel ziet u werkelijke codevoorbeelden.)

De buffer heeft twee consumenten en WriteInt32ToBufferDisplayBufferToConsole. Er is slechts één consument tegelijk (eerst WriteInt32ToBuffer, dan DisplayBufferToConsole), en geen van de consumenten is eigenaar van de buffer. Houd er ook rekening mee dat 'consument' in deze context geen alleen-lezen weergave van de buffer impliceert; consumenten kunnen de inhoud van de buffer wijzigen, net als WriteInt32ToBuffer bij een lees-/schrijfweergave van de buffer.

De WriteInt32ToBuffer methode heeft een lease op (kan de buffer verbruiken) tussen het begin van de methode-aanroep en het tijdstip waarop de methode wordt geretourneerd. Op dezelfde manier DisplayBufferToConsole heeft u een lease op de buffer terwijl deze wordt uitgevoerd en wordt de lease vrijgegeven wanneer de methode wordt afwikkeld. (Er is geen API voor leasebeheer; een 'lease' is een conceptuele kwestie.)

Geheugen<T> en het model eigenaar/consument

Zoals de sectie Eigenaren, consumenten en levensduurbeheer aantekent, heeft een buffer altijd een eigenaar. .NET ondersteunt twee eigendomsmodellen:

  • Een model dat ondersteuning biedt voor één eigendom. Een buffer heeft één eigenaar voor de gehele levensduur.

  • Een model dat eigendomsoverdracht ondersteunt. Het eigendom van een buffer kan worden overgedragen van de oorspronkelijke eigenaar (de maker) naar een ander onderdeel, dat vervolgens verantwoordelijk wordt voor het levensduurbeheer van de buffer. Die eigenaar kan op zijn beurt het eigendom overdragen aan een ander onderdeel, enzovoort.

U gebruikt de System.Buffers.IMemoryOwner<T> interface om het eigendom van een buffer expliciet te beheren. IMemoryOwner<T> ondersteunt beide eigendomsmodellen. Het onderdeel met een IMemoryOwner<T> verwijzing is eigenaar van de buffer. In het volgende voorbeeld wordt een IMemoryOwner<T> exemplaar gebruikt om het eigendom van een Memory<T> buffer weer te geven.

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

We kunnen dit voorbeeld ook schrijven met de using instructie:

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

In deze code:

  • De Main methode bevat de verwijzing naar het IMemoryOwner<T> exemplaar, dus de Main methode is de eigenaar van de buffer.

  • De WriteInt32ToBuffer en DisplayBufferToConsole methoden accepteren Memory<T> als een openbare API. Daarom zijn ze consumenten van de buffer. Deze methoden gebruiken de buffer één voor één.

Hoewel de WriteInt32ToBuffer methode is bedoeld om een waarde naar de buffer te schrijven, is de DisplayBufferToConsole methode niet bedoeld. Om dit te weerspiegelen, kan het een argument van het type ReadOnlyMemory<T>hebben geaccepteerd. Zie Regel 2 voor meer informatieReadOnlyMemory<T>: ReadOnlySpan<T> of ReadOnlyMemory<T> gebruiken als de buffer alleen-lezen moet zijn.

'Eigenaarloos' Geheugen-T-exemplaren<>

U kunt een Memory<T> exemplaar maken zonder gebruik te maken van IMemoryOwner<T>. In dit geval is het eigendom van de buffer impliciet in plaats van expliciet en wordt alleen het model met één eigenaar ondersteund. U kunt dit als volgt doen:

  • Een van de Memory<T> constructors rechtstreeks aanroepen, waarbij een T[], zoals in het volgende voorbeeld, wordt doorgegeven.

  • De extensiemethode String.AsMemory aanroepen om een ReadOnlyMemory<char> exemplaar te produceren.

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

De methode waarmee het Memory<T> exemplaar in eerste instantie wordt gemaakt, is de impliciete eigenaar van de buffer. Eigendom kan niet worden overgedragen naar een ander onderdeel omdat er geen IMemoryOwner<T> instantie is om de overdracht te vergemakkelijken. (Als alternatief kunt u zich ook voorstellen dat de garbagecollector van de runtime eigenaar is van de buffer en dat alle methoden alleen de buffer gebruiken.)

Gebruiksrichtlijnen

Omdat een geheugenblok eigendom is van maar bedoeld is om te worden doorgegeven aan meerdere onderdelen, waarvan sommige gelijktijdig op een bepaald geheugenblok kunnen werken, is het belangrijk om richtlijnen vast te stellen voor het gebruik van zowel als Memory<T>Span<T>. Richtlijnen zijn nodig omdat het mogelijk is dat een onderdeel:

  • Behoud een verwijzing naar een geheugenblok nadat de eigenaar het heeft vrijgegeven.

  • Op een buffer werken op hetzelfde moment dat een ander onderdeel erop werkt, waarbij de gegevens in de buffer beschadigd raken.

  • Hoewel de stack-toegewezen aard van het optimaliseren van Span<T> de prestaties en Span<T> het voorkeurstype voor het werken op een geheugenblok maakt, gelden Span<T> er ook enkele belangrijke beperkingen. Het is belangrijk om te weten wanneer u een Span<T> en wanneer moet gebruiken Memory<T>.

Hieronder volgen onze aanbevelingen voor het gebruik van Memory<T> en de bijbehorende typen. Richtlijnen die van toepassing zijn op en Span<T> ook van ReadOnlyMemory<T> toepassing Memory<T> zijn op en ReadOnlySpan<T> tenzij anders vermeld.

Regel 1: Voor een synchrone API gebruikt u Span<T> in plaats van Geheugen<T> als parameter, indien mogelijk.

Span<T> is veelzijdiger dan Memory<T> en kan een grotere verscheidenheid aan aaneengesloten geheugenbuffers vertegenwoordigen. Span<T> biedt ook betere prestaties dan Memory<T>. Ten slotte kunt u de Memory<T>.Span eigenschap gebruiken om een Memory<T> exemplaar te converteren naar een Span<T>, hoewel T-naar-memory<T-conversie> van span<> niet mogelijk is. Dus als uw bellers een Memory<T> exemplaar hebben, kunnen ze uw methoden toch aanroepen met Span<T> parameters.

Het gebruik van een parameter van het type Span<T> in plaats van het type Memory<T> helpt u ook bij het schrijven van een juiste verbruiksmethode-implementatie. U krijgt automatisch compileertijdcontroles om ervoor te zorgen dat u geen toegang probeert te krijgen tot de buffer buiten de lease van uw methode (meer hierover later).

Soms moet u een Memory<T> parameter gebruiken in plaats van een Span<T> parameter, zelfs als u volledig synchroon bent. Misschien accepteert een API die u afhankelijk bent van alleen Memory<T> argumenten. Dit is prima, maar houd rekening met de compromissen die betrokken zijn bij het synchroon gebruiken Memory<T> .

Regel 2: Gebruik ReadOnlySpan<T> of ReadOnlyMemory<T> als de buffer alleen-lezen moet zijn.

In de eerdere voorbeelden wordt de DisplayBufferToConsole methode alleen gelezen uit de buffer. De inhoud van de buffer wordt niet gewijzigd. De handtekening van de methode moet worden gewijzigd in het volgende.

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

Als we deze regel en regel 1 combineren, kunnen we de methodehandtekening als volgt nog beter doen en opnieuw schrijven:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

De DisplayBufferToConsole methode werkt nu met vrijwel elk buffertype dat kan worden gebruikt: T[], opslag die is toegewezen met stackalloc, enzovoort. Je kunt er zelfs een String rechtstreeks in doorgeven! Zie Voor meer informatie gitHub probleem dotnet/docs #25551.

Regel 3: Als uw methode geheugen-T<> accepteert en retourneertvoid, moet u het geheugen-T-exemplaar>< niet gebruiken nadat de methode is geretourneerd.

Dit heeft betrekking op het eerder genoemde 'lease'-concept. De lease van een ongeldige retourmethode op het Memory<T> exemplaar begint wanneer de methode wordt ingevoerd en eindigt wanneer de methode wordt afgesloten. Bekijk het volgende voorbeeld, waarbij een lus wordt aanroepen Log op basis van invoer van de console.

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

Als Log dit een volledig synchrone methode is, gedraagt deze code zich zoals verwacht, omdat er op elk moment slechts één actieve consument van het geheugenexemplaren is. Maar stelt u zich voor dat deze Log implementatie bestaat.

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

In deze implementatie wordt de lease geschonden omdat de instantie Log nog steeds op de achtergrond wordt gebruikt Memory<T> nadat de oorspronkelijke methode is geretourneerd. De Main methode kan de buffer dempen terwijl Log er pogingen worden ondernomen om deze te lezen, wat kan leiden tot beschadiging van gegevens.

U kunt dit op verschillende manieren oplossen:

  • De Log methode kan een Task in plaats van void, zoals de volgende implementatie van de Log methode wel retourneert.

    // 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 kan in plaats daarvan als volgt worden geïmplementeerd:

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

Regel 4: Als uw methode een geheugen-T<> accepteert en een taak retourneert, moet u het geheugen-T-exemplaar>< niet gebruiken nadat de taak is overgestapt op een terminalstatus.

Dit is alleen de asynchrone variant van regel 3. De Log methode uit het eerdere voorbeeld kan als volgt worden geschreven om aan deze regel te voldoen:

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

Hier betekent 'terminalstatus' dat de taak overgaat naar een voltooide, mislukte of geannuleerde status. Met andere woorden: 'terminalstatus' betekent 'alles wat ertoe zou leiden dat er wordt gegooid of voortgezet.'

Deze richtlijnen zijn van toepassing op methoden die een vergelijkbaar type retourneren Task, Task<TResult>of ValueTask<TResult>een vergelijkbaar type retourneren.

Regel 5: Als uw constructor geheugen-T<> accepteert als parameter, wordt ervan uitgegaan dat instantiemethoden voor het samengestelde object consumenten zijn van het geheugen-T-exemplaar<>.

Kijk een naar het volgende voorbeeld:

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

Hier accepteert de OddValueExtractor constructor een ReadOnlyMemory<int> als constructorparameter, dus de constructor zelf is een consument van het ReadOnlyMemory<int> exemplaar en alle exemplaarmethoden op de geretourneerde waarde zijn ook consumenten van het oorspronkelijke ReadOnlyMemory<int> exemplaar. Dit betekent dat TryReadNextOddValue het ReadOnlyMemory<int> exemplaar wordt verbruikt, ook al wordt het exemplaar niet rechtstreeks doorgegeven aan de TryReadNextOddValue methode.

Regel 6: Als u een ingestelde eigenschap geheugen-T<> hebt (of een equivalente instantiemethode) voor uw type, wordt ervan uitgegaan dat instantiemethoden voor dat object consumenten zijn van het Memory<T-exemplaar>.

Dit is eigenlijk gewoon een variant van Regel 5. Deze regel bestaat omdat eigenschapssetters of equivalente methoden worden verondersteld om hun invoer vast te leggen en vast te leggen, dus instantiemethoden op hetzelfde object kunnen gebruikmaken van de vastgelegde status.

In het volgende voorbeeld wordt deze regel geactiveerd:

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

Regel 7: Als u een IMemoryOwner<T-verwijzing> hebt, moet u deze op een bepaald moment verwijderen of het eigendom ervan overdragen (maar niet beide).

Omdat een Memory<T> exemplaar kan worden ondersteund door beheerd of onbeheerd geheugen, moet de eigenaar aanroepen DisposeIMemoryOwner<T> wanneer het werk dat op het Memory<T> exemplaar wordt uitgevoerd, is voltooid. De eigenaar kan het eigendom van het IMemoryOwner<T> exemplaar ook overdragen aan een ander onderdeel, waarna het verwervende onderdeel verantwoordelijk wordt voor het aanroepen Dispose op het juiste moment (meer hierover later).

Het niet aanroepen van de Dispose methode op een IMemoryOwner<T> exemplaar kan leiden tot onbeheerde geheugenlekken of andere prestatievermindering.

Deze regel is ook van toepassing op code die fabrieksmethoden aanroept, zoals MemoryPool<T>.Rent. De aanroeper wordt de eigenaar van de geretourneerde IMemoryOwner<T> en is verantwoordelijk voor het verwijderen van het exemplaar wanneer dit is voltooid.

Regel 8: Als u een IMemoryOwner<T-parameter> in uw API-gebied hebt, accepteert u het eigendom van dat exemplaar.

Als u een exemplaar van dit type accepteert, wordt aangegeven dat uw onderdeel voornemens is eigenaar te worden van dit exemplaar. Uw onderdeel wordt verantwoordelijk voor de juiste verwijdering volgens regel 7.

Elk onderdeel dat het eigendom van het IMemoryOwner<T> exemplaar overdraagt aan een ander onderdeel, mag dat exemplaar niet meer gebruiken nadat de methodeaanroep is voltooid.

Belangrijk

Als uw constructor als parameter accepteertIMemoryOwner<T>, moet het bijbehorende type worden geïmplementeerd IDisposableen moet uw Dispose methode het IMemoryOwner<T> object aanroepenDispose.

Regel 9: Als u een synchrone p/invoke-methode verpakt, moet uw API Span<T> accepteren als parameter.

Volgens regel 1 Span<T> is over het algemeen het juiste type dat moet worden gebruikt voor synchrone API's. U kunt exemplaren vastmaken Span<T> via het fixed trefwoord, zoals in het volgende voorbeeld.

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

In het vorige voorbeeld pbData kan null zijn als de invoerspanne bijvoorbeeld leeg is. Als de geëxporteerde methode absoluut vereist dat deze pbData niet null is, zelfs als cbData dit 0 is, kan de methode als volgt worden geïmplementeerd:

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

Regel 10: Als u een asynchrone p/invoke-methode verpakt, moet uw API geheugen-T<> accepteren als parameter.

Omdat u het fixed trefwoord niet kunt gebruiken voor asynchrone bewerkingen, gebruikt u de Memory<T>.Pin methode om exemplaren vast te maken Memory<T> , ongeacht het type aaneengesloten geheugen dat het exemplaar vertegenwoordigt. In het volgende voorbeeld ziet u hoe u deze API gebruikt om een asynchrone p/invoke-aanroep uit te voeren.

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

Zie ook