Partager via


Directives d’utilisation de Memory<T> et de Span<T>

.NET inclut un nombre de types qui représentent une région contiguë arbitraire de mémoire. Span<T> et ReadOnlySpan<T> sont des mémoires tampons légères qui encapsulent des références à une mémoire managée ou non managée. Ces types pouvant uniquement être stockés sur la pile, ils sont ne sont pas adaptés aux scénarios comme les appels de méthode asynchrone. Pour résoudre ce problème, .NET 2.1 a ajouté des types supplémentaires, notamment Memory<T>, ReadOnlyMemory<T>, IMemoryOwner<T> et MemoryPool<T>. Comme Span<T>, Memory<T> et ses types associés peuvent être sauvegardés par la mémoire managée et non managée. Contrairement à Span<T>, Memory<T> peut être stockée sur le tas managé.

Span<T> et Memory<T> sont des wrappers sur des mémoires tampons de données structurées qui peuvent être utilisées dans les pipelines. Autrement dit, elles sont conçues pour que certaines ou toutes les données puissent être transmises efficacement à des composants du pipeline pouvant les traiter et éventuellement modifier la mémoire tampon. Étant donné que Memory<T> et ses types associés sont accessibles par plusieurs composants ou par plusieurs threads, il est important de suivre des instructions d’utilisation standard pour produire un code robuste.

Gestion des propriétaires, des consommateurs et de la durée de vie

Les mémoires tampons peuvent être transmises entre différents API et sont parfois accessibles à partir de plusieurs threads. Par conséquent, tenez compte de la manière dont la durée de vie d’une mémoire tampon est gérée. Il y a trois concepts fondamentaux :

  • Propriété. Le propriétaire d’une instance de la mémoire tampon est responsable de la gestion de la durée de vie, notamment de la destruction de la mémoire tampon lorsqu’elle n’est plus utilisée. Toutes les mémoires tampons ont un propriétaire unique. En règle générale, le propriétaire est le composant qui a créé la mémoire tampon ou l’a reçue à partir d’une fabrique. La propriété peut également être transférée ; Component-A peut abandonner le contrôle de la mémoire tampon à Component-B, à la suite de quoi Component-A ne peut plus utiliser la mémoire tampon, et Component-B devient responsable de sa destruction lorsqu’elle n’est plus utilisée.

  • Consommation. Le consommateur d’une instance de la mémoire tampon est autorisé à utiliser l’instance de la mémoire tampon en la lisant et, éventuellement, en écrivant dedans. Les mémoires tampons peuvent avoir un consommateur à la fois, sauf si un mécanisme de synchronisation externe est disponible. Le consommateur actif d’une mémoire tampon n’est pas nécessairement son propriétaire.

  • Bail. Le bail est la durée pendant laquelle un composant particulier est autorisé à être le consommateur de la mémoire tampon.

L'exemple de pseudo-code suivant illustre ces trois concepts. Buffer dans le pseudo-code représente une mémoire tampon Memory<T> ou Span<T> de type Char. La méthode Main instancie une mémoire tampon, appelle la méthode WriteInt32ToBuffer pour écrire la représentation sous forme de chaîne d’un entier dans la mémoire tampon, puis appelle la méthode DisplayBufferToConsole pour afficher la valeur de la mémoire tampon.

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

La méthode Main crée la mémoire tampon et est donc son propriétaire. Pour cette raison, Main est responsable de la destruction de la mémoire tampon lorsqu’elle n’est plus utilisée. Le pseudo-code illustre cela en appelant une méthode Destroy sur la mémoire tampon. (Ni Memory<T>, ni Span<T> n’ont réellement de méthode Destroy. Vous verrez des exemples de code réel plus loin dans cet article.)

La mémoire tampon a deux consommateurs, à savoir WriteInt32ToBuffer et DisplayBufferToConsole. Il n'y a qu’un seul consommateur à la fois (d’abord WriteInt32ToBuffer, puis DisplayBufferToConsole), et aucune des consommateurs ne possède la mémoire tampon. Notez également que « consommateur » dans ce contexte n’implique pas une vue en lecture seule de la mémoire tampon ; comme WriteInt32ToBuffer, les consommateurs peuvent modifier le contenu de la mémoire tampon s’ils disposent d’une vue en lecture/écriture de cette dernière.

La méthode WriteInt32ToBuffer a un bail pour la mémoire tampon (peut consommer) entre le début de l’appel de méthode et le moment du retour de la méthode. De même, DisplayBufferToConsole a un bail pour la mémoire tampon pendant son exécution et en est libéré lorsque la méthode se déroule. (Il n’existe aucune API pour la gestion de bail ; un « bail » est un concept.)

Memory<T> et le modèle propriétaire/consommateur

Comme mentionné dans la section Propriétaires, consommateurs et gestion de la durée de vie, une mémoire tampon a toujours un propriétaire. .NET prend en charge deux modèles de propriété :

  • Un modèle qui prend en charge la propriété unique. Une mémoire tampon a un propriétaire unique pour toute sa durée de vie.

  • Un modèle qui prend en charge le transfert de propriété. La propriété d’une mémoire tampon peut être transférée de son propriétaire d’origine (son créateur) à un autre composant, qui devient alors responsable de la gestion de la durée de vie de la mémoire tampon. Ce propriétaire peut à son tour transférer la propriété à un autre composant, et ainsi de suite.

Vous utilisez l’interface System.Buffers.IMemoryOwner<T> pour gérer explicitement la propriété d’une mémoire tampon. IMemoryOwner<T> prend en charge les deux modèles de propriété. Le composant qui a une référence IMemoryOwner<T> possède la mémoire tampon. L’exemple suivant utilise une instance IMemoryOwner<T> pour refléter la propriété d’une mémoire tampon 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}'");
}

Nous pouvons également écrire cet exemple avec l’instruction 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}'");
}

Dans ce code :

  • La méthode Main conserve la référence à l’instance IMemoryOwner<T>, donc la méthode Main est propriétaire de la mémoire tampon.

  • Les méthodes WriteInt32ToBuffer et DisplayBufferToConsole acceptent Memory<T> comme API publique. Par conséquent, il y a des consommateurs de la mémoire tampon. Ces méthodes consomment la mémoire tampon individuellement.

La méthode WriteInt32ToBuffer est destinée à écrire une valeur dans la mémoire tampon, ce n’est pas le cas de la méthode DisplayBufferToConsole. Pour refléter cette modification, un argument de type ReadOnlyMemory<T> peut avoir été accepté. Pour plus d’informations sur ReadOnlyMemory<T>, consultez Règle n°2 : Utiliser ReadOnlySpan<T> ou ReadOnlyMemory<T> si la mémoire tampon doit être en lecture seule.

Instances Memory<T> « sans propriétaire »

Vous pouvez créer une instance Memory<T> sans utiliser IMemoryOwner<T>. Dans ce cas, la propriété de la mémoire tampon est implicite plutôt qu’explicite et seul le modèle de propriétaire unique est pris en charge. Pour ce faire, vous pouvez :

  • Appeler directement un des constructeurs Memory<T> en passant en entrée un T[], comme dans l’exemple suivant.

  • appeler la méthode d'extension String.AsMemory pour produire une instance 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}'");
}

La méthode qui crée initialement l’instance Memory<T> est le propriétaire implicite de la mémoire tampon. La propriété ne peut pas être transférée à n’importe quel autre composant, car il n’y a pas d’instance IMemoryOwner<T> pour faciliter le transfert. (Comme alternative, vous pouvez également imaginer que le récupérateur de mémoire du runtime possède la mémoire tampon et que toutes les méthodes ne fassent que consommer la mémoire tampon.)

Instructions d’utilisation

Un bloc de mémoire étant une propriété, mais destiné à être transmis vers plusieurs composants dont certains peuvent fonctionner simultanément sur un bloc de mémoire particulier, il est important de définir des instructions pour l’utilisation de Memory<T> et de Span<T>. Des instructions sont nécessaires, car un composant peut :

  • Conserver une référence à un bloc de mémoire une fois que son propriétaire l’a libéré.

  • Fonctionner sur une mémoire tampon en même temps qu’un autre composant et ainsi endommager les données de la mémoire tampon.

  • Alors que l’allocation par pile de Span<T> optimise les performances et fait de Span<T> le type préféré de fonctionnement sur un bloc de mémoire, elle soumet également Span<T> à certaines restrictions majeures. Il est important de savoir quand utiliser un Span<T> et quand utiliser Memory<T>.

Voici nos recommandations quant à l’utilisation réussie de Memory<T> et de ses types associés. Les directives qui s’appliquent à Memory<T> et Span<T> s’appliquent également à ReadOnlyMemory<T> et ReadOnlySpan<T>, sauf indication contraire.

Règle n°1 : Pour une API synchrone, utilisez Span<T> au lieu de Memory<T> si possible comme paramètre.

Span<T> est plus polyvalente que Memory<T> et peut représenter une plus grande variété de mémoires tampons contiguës. Span<T> offre également de meilleures performances que Memory<T>. Enfin, vous pouvez utiliser la propriété Memory<T>.Span pour convertir une instance Memory<T> en Span<T>, bien que la conversion Span<T>-to-Memory<T> ne soit pas possible. Par conséquent, si vos appelants ont une instance Memory<T>, ils pourront de toute façon appeler vos méthodes avec des paramètres Span<T>.

L’utilisation d’un paramètre de type Span<T> au lieu d’un paramètre de type Memory<T> vous aide également à écrire une implémentation correcte de la méthode de consommation. Des vérifications automatiques au moment de la compilation vous permettent de garantir que vous ne tentez pas d’accéder à la mémoire tampon au-delà de votre bail de méthode (nous y reviendrons plus tard).

Vous devrez parfois utiliser un paramètre Memory<T> au lieu d’un paramètre Span<T>, même si vous êtes entièrement synchrone. Il est possible qu’une API dont vous dépendez n’accepte que des arguments Memory<T>. C’est bien, mais tenez compte des compromis impliqués par l’utilisation synchrone de Memory<T>.

Règle n°2 : Utilisez ReadOnlySpan<T> ou ReadOnlyMemory<T> si la mémoire tampon doit être en lecture seule.

Dans les exemples précédents, la méthode DisplayBufferToConsole lit uniquement à partir de la mémoire tampon ; elle ne modifie pas le contenu de la mémoire tampon. La signature de méthode doit être modifiée comme suit.

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

En fait, si nous combinons cette règle et la règle 1, nous pouvons faire encore mieux et réécrire la signature de méthode comme suit :

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

La méthode DisplayBufferToConsole fonctionne désormais avec pratiquement chaque type de mémoire tampon imaginable : T[], stockage alloué avec stackalloc, et ainsi de suite. Vous pouvez même y passer directement une chaîne String ! Pour plus d’informations, consultez le problème GitHub dotnet/docs #25551.

Règle n°3 : Si votre méthode accepte Memory<T> et retourne void, vous ne devez pas utiliser l’instance Memory<T> après le retour de votre méthode.

Ceci est lié au concept de « bail » mentionné précédemment. Un bail de méthode avec renvoi d’annulation sur l’instance Memory<T> commence lorsqu’on entre dans la méthode et se termine lorsqu’on la quitte. Prenons l’exemple suivant, qui appelle Log dans une boucle basée sur l’entrée à partir de la 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;
    }
}

Si Log est une méthode parfaitement synchrone, ce code se comporte comme prévu, car il n’y a qu’un seul consommateur actif de l’instance de la mémoire à un moment donné. Mais imaginez plutôt que Log a cette implémentation.

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

Dans cette implémentation, Log enfreint son bail, car il tente toujours d’utiliser l’instance Memory<T> en arrière-plan après le retour de la méthode d’origine. La méthode Main pourrait muter la mémoire tampon pendant que Log tente de la lire, ce qui pourrait entraîner une altération des données.

Il y a de nombreuses manières de résoudre ce problème :

  • La méthode Log peut retourner une Task au lieu de void, comme le fait l’implémentation suivante de la méthode 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();
        });
    }
    
  • À la place, Log peut plutôt être implémenté comme suit :

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

Règle n°4 : Si votre méthode accepte une Memory<T> et retourne une tâche, vous ne devez pas utiliser l’instance Memory<T> après que la tâche est passée à un état terminal.

Il s’agit simplement la variante asynchrone de la règle 3. La méthode Log de l’exemple précédent peut être écrite comme suit pour se conformer à cette règle :

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

Ici, « état terminal » signifie que la tâche passe à un état terminé, ayant généré une erreur ou annulé. En d’autres termes, « état terminal » signifie « tout ce qui provoquerait une attente lors du lancement ou de la poursuite de l’exécution. »

Ces conseils s’appliquent aux méthodes qui retournent Task, Task<TResult>, ValueTask<TResult>, ou n’importe quel type similaire.

Règle n°5 : Si votre constructeur accepte Memory<T> en tant que paramètre, les méthodes d’instance sur l’objet construit sont supposées être des consommateurs de l’instance Memory<T>.

Prenons l’exemple suivant :

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

Ici, le constructeurOddValueExtractor accepte ReadOnlyMemory<int> comme paramètre de constructeur, si bien que le constructeur lui-même est un consommateur de l’instance ReadOnlyMemory<int> et que toutes les méthodes d’instance sur la valeur retournée sont également des consommateurs de l’instance ReadOnlyMemory<int> d’origine. Cela signifie que TryReadNextOddValue consomme l’instance ReadOnlyMemory<int>, même si l’instance n’est pas passée directement à la méthode TryReadNextOddValue.

Règle n°6 : Si vous avez une propriété définissable de type Memory<T> (ou une méthode d’instance équivalente) sur votre type, les méthodes d’instance sur cet objet sont supposées être des consommateurs de l’instance Memory<T>.

Il s’agit simplement d’une variante de la règle 5. Cette règle existe, car les setters de propriété ou les méthodes équivalentes sont supposés capturer et conserver leurs entrées, de manière que les méthodes d’instances sur le même objet puissent utiliser l’état de capture.

L'exemple suivant déclenche cette règle :

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

Règle n°7 : Si vous avez une référence IMemoryOwner<T>, vous devez à un certain moment soit la supprimer, soit transférer sa propriété (mais pas les deux).

Dans la mesure où une instance Memory<T> peut s’appuyer sur de la mémoire managée ou non managée, le propriétaire doit appeler Dispose sur IMemoryOwner<T> quand le travail effectué sur l’instance Memory<T> est terminé. Le propriétaire peut également transférer la propriété de l’instance IMemoryOwner<T> à un autre composant. Le composant d’acquisition devient alors responsable de l’appel de Dispose au moment opportun (nous y reviendrons plus tard).

Ne pas réussir à appeler la méthode Dispose sur une instance IMemoryOwner<T> peut entraîner des fuites de mémoire non managée ou une autre dégradation des performances.

Cette règle s’applique également au code qui appelle les méthodes de fabrique, telles que MemoryPool<T>.Rent. L’appelant devient le propriétaire du IMemoryOwner<T> retourné et est responsable de la suppression de l’instance terminée.

Règle n°8 : Si vous avez un paramètre IMemoryOwner<T> dans la surface de votre API, vous acceptez la propriété de cette instance.

Accepter une instance de ce type signale que votre composant a l’intention de prendre possession de cette instance. Votre composant devient responsable de la suppression correcte conformément à la règle 7.

Tout composant qui transfère la propriété de l’instance IMemoryOwner<T> à un autre composant ne doit plus utiliser cette instance lorsque l’appel de méthode se termine.

Important

Si votre constructeur accepte IMemoryOwner<T> comme paramètre, son type doit implémenter IDisposable et votre méthode Dispose doit appeler Dispose sur l’objet IMemoryOwner<T>.

Règle n°9 : Si vous encapsulez une méthode p/invoke synchrone, votre API doit accepter Span<T> comme paramètre.

Conformément à la règle 1, Span<T> est généralement le type correct à utiliser pour les API synchrones. Vous pouvez épingler des instances Span<T> via le mot clé fixed mot clé, comme dans l’exemple suivant.

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

Dans l’exemple précédent, pbData peut être Null si, par exemple, l’étendue d’entrée est vide. Si la méthode exportée requiert absolument que pbData soit non Null, même si cbData est égal à 0, la méthode peut être implémentée comme suit :

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

Règle n°10 : Si vous encapsulez une méthode p/invoke asynchrone, votre API doit accepter Memory<T> comme paramètre.

Étant donné que vous ne pouvez pas utiliser le mot clé fixed lors d’opérations asynchrones, vous utilisez la méthode Memory<T>.Pin pour épingler les instances Memory<T>, quel que soit le type de mémoire contiguë représenté par l’instance. L’exemple suivant montre comment utiliser cette API pour effectuer un appel p/invoke asynchrone.

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

Voir aussi