Linee guida per l'utilizzo di Memory<T> e Span<T>

.NET include vari tipi che rappresentano una regione contigua arbitraria della memoria. Span<T> e ReadOnlySpan<T> sono buffer di memoria leggeri che eseguono il wrapping dei riferimenti alla memoria gestita o non gestita. Dato che questi tipi possono solo essere archiviati nello stack, non sono adatti per scenari come le chiamate asincrone. Per risolvere questo problema, .NET 2.1 ha aggiunto alcuni tipi aggiuntivi, tra cui Memory<T>, ReadOnlyMemory<T>, IMemoryOwner<T> e MemoryPool<T>. Come Span<T>, Memory<T> e i tipi correlati possono essere supportati da memoria gestita e non gestita. Diversamente da Span<T>, Memory<T> supporta l'archiviazione nell'heap gestito.

Sia Span<T> che Memory<T> sono wrapper su buffer di dati strutturati che possono essere usati nelle pipeline. Questo significa che sono progettati in modo che alcuni o tutti i dati possano essere passati in modo efficiente ai componenti nella pipeline, che possono elaborarli e facoltativamente modificare il buffer. Dato che Memory<T> e i tipi correlati sono accessibili da più componenti o da più thread, è importante seguire alcune linee guida sull'utilizzo standard in modo da produrre codice solido.

Proprietari, consumer e gestione della durata

I buffer possono essere passati tra le API e talvolta è possibile accedervi da più thread, quindi tenere presente come viene gestita la durata di un buffer. Esistono tre concetti principali:

  • Titolarità. Il proprietario di un'istanza del buffer è responsabile della gestione della durata, inclusa l'eliminazione definitiva del buffer quando non è più in uso. Tutti i buffer hanno un solo proprietario. Il proprietario è in genere il componente che ha creato il buffer o che ha ricevuto il buffer da una factory. La proprietà può anche essere trasferita. Il componente A può cedere il controllo del buffer al componente-B e a quel punto il componente A non può più usare il buffer e il componente B diventa responsabile dell'eliminazione definitiva del buffer quando non è più in uso.

  • A consumo. Il consumer di un'istanza del buffer è autorizzato a usare l'istanza del buffer leggendo da tale istanza e forse anche scrivendo in tale istanza. I buffer possono avere un solo consumer alla volta, a meno che non venga fornito un meccanismo di sincronizzazione esterno. Il consumer attivo di un buffer non è necessariamente il proprietario del buffer.

  • Lease. Il lease è il periodo di tempo per cui un particolare componente è autorizzato a essere il consumer del buffer.

L'esempio di pseudocodice seguente illustra questi tre concetti. Buffer nello pseudo-codice rappresenta un buffer Memory<T> o Span<T> di tipo Char. Il metodo Main crea un'istanza del buffer, chiama il metodo WriteInt32ToBuffer per scrivere la rappresentazione stringa di un intero nel buffer e quindi chiama il metodo DisplayBufferToConsole per visualizzare il valore del buffer.

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

Il metodo Main crea il buffer e pertanto ne è il proprietario. Main è quindi responsabile dell'eliminazione definitiva del buffer quando non è più in uso. Lo pseudo-codice illustra questa operazione chiamando un metodo Destroy nel buffer. (Né Memory<T>Span<T> hanno in realtà un metodo Destroy. Più avanti in questo articolo verranno visualizzati esempi di codice reali.

Il buffer ha due consumer, WriteInt32ToBuffer e DisplayBufferToConsole. Esiste un solo consumer alla volta (prima WriteInt32ToBuffer e poi DisplayBufferToConsole), e nessuno dei consumer è proprietario del buffer. Si noti anche che il concetto di "consumer" in questo contesto non implica una visualizzazione di sola lettura del buffer. I consumer possono modificare il contenuto del buffer, come nel caso di WriteInt32ToBuffer, se ricevono una vista in lettura/scrittura del buffer.

Il metodo WriteInt32ToBuffer ha un lease per il buffer (può utilizzarlo) dall'inizio della chiamata al metodo fino al momento in cui il metodo restituisce il controllo. Analogamente, DisplayBufferToConsole ha un lease per il buffer mentre è in esecuzione e il lease viene rilasciato quando viene rimosso il metodo. (Non è disponibile alcuna API per la gestione del lease. Un "lease" è una questione concettuale.)

Memory<T> e il modello proprietario/consumer

Come indicato nella sezione Proprietari, consumer e gestione della durata, un buffer ha sempre un proprietario. .NET supporta due modelli di proprietà:

  • Un modello che supporta la proprietà singola. Un buffer ha un solo proprietario per tutta la sua durata.

  • Un modello che supporta il trasferimento della proprietà. La proprietà di un buffer può essere trasferita dal relativo proprietario (autore) originale a un altro componente, che quindi diventa responsabile della gestione della durata del buffer. Tale proprietario può a sua volta trasferire la proprietà a un altro componente e così via.

Per gestire in modo esplicito la proprietà di un buffer si usa l'interfaccia System.Buffers.IMemoryOwner<T>. IMemoryOwner<T> supporta entrambi i modelli di proprietà. Il componente con un riferimento a IMemoryOwner<T> è proprietario del buffer. L'esempio seguente usa un'istanza di IMemoryOwner<T> per riflettere la proprietà di un buffer 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}'");
}

È anche possibile scrivere questo esempio con l'istruzione 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}'");
}

In questo codice:

  • Il metodo Main mantiene il riferimento all'istanza di IMemoryOwner<T>, quindi il metodo Main è il proprietario del buffer.

  • I metodi WriteInt32ToBuffer e DisplayBufferToConsole accettano Memory<T> come API pubblica. Sono pertanto consumer del buffer Questi metodi utilizzano il buffer uno alla volta.

Anche se il metodo WriteInt32ToBuffer è progettato per scrivere un valore nel buffer, il metodo DisplayBufferToConsole non lo è. Di conseguenza, potrebbe avere accettato un argomento di tipo ReadOnlyMemory<T>. Per altre informazioni su ReadOnlyMemory<T>, vedere Regola 2: Usare ReadOnlySpan<T> o ReadOnlyMemory<T> se il buffer deve essere di sola lettura.

Istanza di Memory<T> "senza proprietario"

È possibile creare un'istanza di Memory<T> senza usare IMemoryOwner<T>. In questo caso, la proprietà del buffer è implicita anziché esplicita ed è supportato solo il modello con proprietario singolo. A questo proposito:

  • Chiamare direttamente uno dei costruttori Memory<T> passando un T[], come nell'esempio seguente.

  • Chiamare il metodo di estensione String.AsMemory per produrre un'istanza di 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}'");
}

Il metodo che crea inizialmente l'istanza di Memory<T> è il proprietario implicito del buffer. Non è possibile trasferire la proprietà a qualsiasi altro componente perché non esiste alcuna istanza di IMemoryOwner<T> per facilitare il trasferimento. (In alternativa, è anche possibile immaginare che il Garbage Collector del runtime sia il proprietario del buffer e che i metodi utilizzino semplicemente il buffer.)

Linee guida per l'uso

Dato che un blocco di memoria ha un proprietario, ma è destinato a essere passato a più componenti, alcuni dei quali potrebbero operare simultaneamente su un blocco di memoria specifico, è importante definire delle linee guida per l'uso sia di Memory<T> che di Span<T>. Le linee guida sono necessarie perché è possibile che un componente:

  • Mantenga un riferimento a un blocco di memoria dopo il rilascio da parte del proprietario.

  • Operi su un buffer in contemporanea con un altro componente, con un processo che danneggia i dati nel buffer.

  • Anche se il funzionamento basato sull'allocazione dello stack di Span<T> consente di ottimizzare le prestazioni e rende Span<T> il tipo preferito per operare su un blocco di memoria, Span<T> diventa soggetto ad alcune restrizioni significative. È importante sapere quando usare Span<T> e quando usare Memory<T>.

Di seguito sono riportati alcuni consigli per usare correttamente Memory<T> e i tipi correlati. Le linee guida valide per Memory<T> e Span<T> si applicano anche a ReadOnlyMemory<T> e ReadOnlySpan<T>, se non diversamente indicato.

Regola 1: Per un'API sincrona, usare Span<T> invece di Memory<T> come parametro se possibile.

Span<T> è più versatile di Memory<T> e può rappresentare una più ampia gamma di buffer di memoria contigui. Span<T> offre anche prestazioni migliori di Memory<T>. Infine, è possibile usare la proprietà Memory<T>.Span per convertire un'istanza di Memory<T> in Span<T>, anche se la conversione da Span<T> a Memory<T> non è possibile. Nel caso i chiamanti abbiano un'istanza di Memory<T>, pertanto, potranno chiamare comunque i metodi con i parametri Span<T>.

L'uso di un parametro di tipo Span<T> anziché di tipo Memory<T> consente anche di scrivere un'implementazione corretta del metodo consumer. Si otterranno automaticamente controlli in fase di compilazione per assicurarsi che non si stia tentando di accedere al buffer dopo la scadenza del lease del metodo (più avanti sono disponibili altre informazioni su questo argomento).

Sarà a volte necessario usare un parametro Memory<T> invece di un parametroSpan<T>, anche in caso di sincronia completa. È possibile che un'API da cui si dipende accetti solo argomenti Memory<T>. Non è un problema, ma tenere presenti i compromessi che implica l'uso sincrono di Memory<T>.

Regola 2: Usare ReadOnlySpan<T> o ReadOnlyMemory<T> se il buffer deve essere di sola lettura.

Negli esempi precedenti il metodo DisplayBufferToConsole legge solo dal buffer e non modifica il contenuto del buffer. La firma del metodo deve essere modificata come segue.

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

In effetti, se si combinano questa regola e la regola 1, è possibile migliorare ancora e riscrivere la firma del metodo come segue:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

Il metodo DisplayBufferToConsole ora funziona praticamente con qualsiasi tipo di buffer immaginabile: T[], archiviazione allocata con stackalloc e così via. È anche possibile passare direttamente una String. Per altre informazioni, vedere il problema di GitHub #25551 in dotnet/docs.

Regola 3: Se il metodo accetta Memory<T> e restituisce void, non è necessario usare l'istanza di Memory<T> dopo il completamento del metodo.

Questo aspetto è correlato al concetto di "lease" menzionato in precedenza. Il lease di un metodo che restituisce void per l'istanza di Memory<T> inizia con l'accesso al metodo e termina quando il metodo viene chiuso. Si consideri l'esempio seguente, che chiama Log in un ciclo in base all'input dalla 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;
    }
}

Se Log è un metodo completamente sincrono, questo codice si comporterà come previsto poiché non esiste un solo consumer attivo dell'istanza di memoria in qualsiasi momento. Si immagini, invece, che Log abbia questa implementazione.

// !!! 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 questa implementazione Log viola il lease perché tenta ancora di usare l'istanza di Memory<T> in background dopo l'uscita dal metodo originale. Il metodo Main potrebbe modificare il buffer mentre Log tenta una lettura, con potenziale danneggiamento dei dati.

Esistono diversi modi per risolvere questo problema:

  • Il metodo Log può restituire un Task invece di void, come l'implementazione seguente del metodo 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 può invece essere implementato come segue:

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

Regola 4: Se il metodo accetta Memory<T> e restituisce Task, non si deve usare l'istanza di Memory<T> dopo il passaggio di Task a uno stato terminale.

Si tratta semplicemente della variante asincrona della regola 3. Il metodo Log dall'esempio precedente può essere scritto come segue per la conformità a questa regola:

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

In questo caso, "stato terminale" significa che l'attività passa a uno stato completato, di errore o annullato. In altre parole, "stato terminale" significa "tutto ciò che potrebbe causare la generazione di await o la continuazione dell'esecuzione".

Queste linee guida si applicano ai metodi che restituiscono Task, Task<TResult>, ValueTask<TResult> o qualsiasi tipo simile.

Regola 5: Se il costruttore accetta Memory<T> come parametro, si presuppone che i metodi di istanza per l'oggetto costruito siano i consumer dell'istanza di Memory<T>.

Si consideri l'esempio seguente:

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

In questo caso, il costruttore OddValueExtractor accetta un ReadOnlyMemory<int> come parametro costruttore, pertanto il costruttore stesso è un consumer dell'istanza di ReadOnlyMemory<int> e tutti i metodi di istanza per il valore restituito sono anche consumer dell'istanza di ReadOnlyMemory<int> originale. Questo significa che TryReadNextOddValue utilizza l'istanza di ReadOnlyMemory<int>, anche se non viene passata direttamente al metodo TryReadNextOddValue.

Regola 6: Se per il tipo è disponibile una proprietà di tipo Memory<T> (o un metodo di istanza equivalente), si presuppone che i metodi di istanza per tale oggetto siano consumer dell'istanza di Memory<T>.

Si tratta semplicemente di una variante della regola 5. Questa regola esiste perché si presuppone che i setter delle proprietà o i metodi equivalenti acquisiscano gli input e li salvino in modo permanente, in modo che i metodi di istanza per lo stesso oggetto possano utilizzare lo stato acquisito.

L'esempio seguente attiva questa regola:

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

Regola 7: In presenza di un riferimento a IMemoryOwner<T>, a un certo punto è necessario eliminarlo o trasferirne la proprietà (ma non entrambe le operazioni).

Dato che un'istanza di Memory<T> potrebbe essere supportata da memoria gestita o non gestita, il proprietario deve chiamare Dispose su IMemoryOwner<T> al termine delle operazioni eseguite sull'istanza di Memory<T>. In alternativa, il proprietario potrebbe trasferire la proprietà dell'istanza di IMemoryOwner<T> a un altro componente e a quel punto il componente di destinazione diventa responsabile di chiamare Dispose al momento appropriato (più avanti sono disponibili altre informazioni su questo argomento).

Se non viene chiamato il metodo Dispose su un'istanza di IMemoryOwner<T>, potrebbero verificarsi perdite della memoria non gestita o altre forme di riduzione del livello delle prestazioni.

Questa regola si applica anche al codice che chiama i metodi factory, ad esempio MemoryPool<T>.Rent. Il chiamante diventa il proprietario dell'oggetto restituito IMemoryOwner<T> ed è responsabile dell'eliminazione dell'istanza al termine.

Regola 8: In presenza di un parametro IMemoryOwner<T> nella superficie API, si accetta la proprietà di tale istanza.

L'accettazione di un'istanza di questo tipo segnala che il componente intende acquisire la proprietà di questa istanza. Il componente diventa responsabile della corretta eliminazione in base alla regola 7.

Qualsiasi componente che trasferisce la proprietà dell'istanza di IMemoryOwner<T> a un altro componente non deve più usare tale istanza dopo il completamento della chiamata al metodo.

Importante

Se il costruttore accetta IMemoryOwner<T> come parametro, il tipo deve implementare IDisposable e il metodo Dispose deve chiamare Dispose sull'oggetto IMemoryOwner<T>.

Regola 9: Se si esegue il wrapping di un metodo P/Invoke sincrono, l'API deve accettare Span<T> come parametro.

In base alla regola 1, Span<T> è in genere il tipo corretto da usare per le API sincrone. È possibile bloccare le istanze di Span<T> tramite la parola chiave fixed, come nell'esempio seguente.

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

Nell'esempio precedente, pbData può essere Null se, ad esempio, l'intervallo di input è vuoto. Se il metodo esportato richiede assolutamente che pbData non sia Null, anche se cbData è 0, il metodo può essere implementato come segue:

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

Regola 10: Se si esegue il wrapping di un metodo P/Invoke asincrono, l'API deve accettare Memory<T> come parametro.

Poiché non è possibile usare la parola chiave fixed su operazioni asincrone, usare il metodo Memory<T>.Pin per bloccare le istanze di Memory<T>, indipendentemente dal tipo di memoria contigua che rappresenta l'istanza. Nell'esempio seguente viene illustrato come usare questa API per eseguire una chiamata P/Invoke asincrona.

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

Vedi anche