内存<T> 和 Span<T> 使用指南

.NET 包含许多类型,这些类型表示任意连续内存区域。 Span<T>ReadOnlySpan<T> 是轻量级内存缓冲区,用于封装对托管或非托管内存的引用。 由于这些类型只能存储在堆栈上,因此不适用于异步方法调用等方案。 为了解决此问题,.NET 2.1 添加了一些其他类型,包括Memory<T>ReadOnlyMemory<T>IMemoryOwner<T>MemoryPool<T>。 例如 Span<T>Memory<T> 其相关类型可以同时由托管和非托管内存提供支持。 与Span<T>不同,Memory<T>可以存储在托管堆上。

Span<T>Memory<T> 都是可用于管道的结构化数据的缓冲区的包装器。 也就是说,它们的设计使某些或所有数据可以有效地传递到管道中的组件,这些组件可以处理这些组件,并选择性地修改缓冲区。 由于 Memory<T> 及其相关类型可由多个组件或多个线程访问,因此请务必遵循一些标准使用准则来生成可靠的代码。

所有者、使用者和生存期管理

可以在 API 之间传递缓冲区,有时可以从多个线程访问缓冲区,因此请注意缓冲区的生存期的管理方式。 有三个核心概念:

  • 所有权。 缓冲区实例的所有者负责生存期管理,包括在不再使用缓冲区时销毁缓冲区。 所有缓冲区都具有单个所有者。 通常,所有者是创建缓冲区或从工厂接收缓冲区的组件。 所有权也可以转让; 组件 A 可以将缓冲区的控制放弃到 Component-B,此时 Component-A 可能不再使用缓冲区, 组件 B 负责在不再使用缓冲区时销毁缓冲区。

  • 消耗。 允许缓冲区实例的使用者通过从中读取并可能写入其中来使用缓冲区实例。 除非提供了一些外部同步机制,否则缓冲区一次可以有一个使用者。 缓冲区的活动使用者不一定是缓冲区的所有者。

  • 租约。 租约是允许特定组件成为缓冲区使用者的时间长度。

下面的伪代码示例说明了这三个概念。 Buffer在伪代码中表示类型为Memory<T>Span<T>Char缓冲区。 该方法 Main 实例化缓冲区,调用 WriteInt32ToBuffer 方法将整数的字符串表示形式写入缓冲区,然后调用 DisplayBufferToConsole 该方法以显示缓冲区的值。

using System;

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

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

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

该方法 Main 创建缓冲区,其所有者也是如此。 因此,在不再使用缓冲区时, Main 负责销毁缓冲区。 伪代码通过在缓冲区上调用 Destroy 方法来说明这一点。 (Memory<T>Span<T> 实际上都不具有 Destroy 方法。本文稍后会显示实际的代码示例。)

缓冲区具有两个使用者, WriteInt32ToBuffer 以及 DisplayBufferToConsole。 一次只有一个使用者(首先WriteInt32ToBuffer,然后DisplayBufferToConsole),并且两个使用者都没有拥有缓冲区。 另请注意,此上下文中的“使用者”并不表示缓冲区的只读视图;如果给定缓冲区的读/写视图,使用者可以修改缓冲区的内容 WriteInt32ToBuffer

WriteInt32ToBuffer 方法在方法调用的开始时间和方法返回的时间之间会租用(可以使用)缓冲区。 同样,DisplayBufferToConsole 在执行时拥有缓冲区的使用权,而在方法结束时该租约会被释放。 (没有用于租赁管理的 API;“租约”是一个概念性问题。

内存<T> 和所有者/使用者模型

正如 所有者、使用者和生存期管理 部分所述,缓冲区始终具有所有者。 .NET 支持两种所有权模型:

  • 支持单个所有权的模型。 缓冲区在其整个生存期内都有一个所有者。
  • 支持所有权转让的模型。 缓冲区的所有权可以从其原始所有者(其创建者)转移到另一个组件,然后负责缓冲区的生存期管理。 该所有者可以反过来将所有权转让给另一个组件,等等。

使用 System.Buffers.IMemoryOwner<T> 接口显式管理缓冲区的所有权。 IMemoryOwner<T> 支持这两种所有权模型。 具有 IMemoryOwner<T> 引用的组件拥有缓冲区。 以下示例使用实例 IMemoryOwner<T> 来反映缓冲区的 Memory<T> 所有权。

using System;
using System.Buffers;

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

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

            if (s is null)
                return;

            var value = Int32.Parse(s);

            var memory = owner.Memory;

            WriteInt32ToBuffer(value, memory);

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

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

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

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

还可以使用 using 语句编写此示例:

using System;
using System.Buffers;

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

                if (s is null)
                    return;

                var value = Int32.Parse(s);

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

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

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

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

在此代码中:

  • 该方法 Main 保存对实例的 IMemoryOwner<T> 引用,因此该方法 Main 是缓冲区的所有者。
  • WriteInt32ToBufferDisplayBufferToConsole方法接受Memory<T>为公共 API。 因此,它们是缓冲区的使用者。 这些方法逐个耗尽缓冲区。

尽管WriteInt32ToBuffer方法旨在将值写入缓冲区,但DisplayBufferToConsole方法并不如此。 为了反映这一点,它可能已接受类型的 ReadOnlyMemory<T>参数。 有关详细信息ReadOnlyMemory<T>,请参阅规则 #2:如果缓冲区应为只读,请使用 ReadOnlySpan<T> 或 ReadOnlyMemory<T>

“无所有者”内存 <T> 实例

您可以在不使用Memory<T>的情况下创建IMemoryOwner<T>实例。 在这种情况下,缓冲区的所有权是隐式的,而不是显式的,并且仅支持单所有者模型。 为此,可以执行以下作:

  • 直接调用其中一个 Memory<T> 构造函数,并传入一个参数T[],如以下示例所示。
  • 调用 String.AsMemory 扩展方法以生成 ReadOnlyMemory<char> 实例。
using System;

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

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

        if (s is null)
            return;

        var value = Int32.Parse(s);

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

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

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

最初创建Memory<T>实例的方法是缓冲区的默认所有者。 所有权不能转移到任何其他组件,因为没有 IMemoryOwner<T> 实例来促进转让。 作为替代方法,还可以想象运行时的垃圾回收器管理缓冲区,并且所有方法只会使用缓冲区。

使用指南

由于内存块是拥有的,但旨在传递给多个组件,其中一些组件可能会同时对特定内存块进行操作,因此必须建立关于如何使用Memory<T>Span<T>的准则。 指南是必要的,因为组件可以:

  • 在内存块的所有者发布内存块之后保留对该内存块的引用。
  • 同时在缓冲区上操作另一个组件,在此过程中导致缓冲区中的数据损坏。

虽然堆栈分配的性质 Span<T> 可以优化性能,并且使 Span<T> 首选类型用于在内存块上运行,但它也会受到 Span<T> 一些重大限制。 请务必了解何时使用Span<T>以及何时使用Memory<T>

下面是成功使用 Memory<T> 的建议及其相关类型。 指导方针适用于Memory<T>Span<T>,同样适用于ReadOnlyMemory<T>ReadOnlySpan<T>,除非另有说明。

规则 1:对于同步 API,如果可能,请使用 Span<T> 而不是 Memory<T> 作为参数

Span<T>Memory<T> 更通用,可以表示更广泛的连续内存缓冲区。 Span<T> 的性能优于 Memory<T>。 最后,您可以使用 Memory<T>.Span 属性将 Memory<T> 实例转换为 Span<T>,但无法将 Span<T> 转换为 Memory<T>。 因此,如果调用方碰巧有一个 Memory<T> 实例,那么他们仍然可以使用 Span<T> 参数来调用您的方法。

使用Span<T>类型的参数而不是Memory<T>类型的参数,也可以帮助您编写正确的消费方法实现。 您将自动获得有关编译时验证,以确保不会尝试访问超出您方法的访问许可范围的缓冲区(之后会详细说明)。

有时,即使完全同步,也必须使用 Memory<T> 参数而不是 Span<T> 参数。 也许您所依赖的 API 只接受 Memory<T> 参数。 这很好,但请注意同步使用 Memory<T> 时涉及的权衡。

规则 2:如果缓冲区应为只读,请使用 ReadOnlySpan<T> 或 ReadOnlyMemory<T>

在前面的示例中, DisplayBufferToConsole 该方法仅从缓冲区读取;它不会修改缓冲区的内容。 方法签名应更改为以下内容。

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

事实上,如果合并此规则和规则 #1,我们可以做得更好,并重写方法签名,如下所示:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

该方法 DisplayBufferToConsole 现在适用于几乎可以想象的每个缓冲区类型: T[]、使用 stackalloc 分配的存储,等等。 甚至可以向其直接传递 String! 有关详细信息,请参阅 GitHub 问题 dotnet/docs #25551

规则 #3:如果方法接受内存<T> 并返回,则方法返回 void后不得使用内存<T> 实例

这与前面提到的“租约”概念有关。 返回 void 的方法对 Memory<T> 实例的租用将在进入该方法时开始,并在退出该方法时结束。 请考虑以下示例,该示例基于控制台的输入在循环中调用 Log

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

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

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

                if (s is null)
                    return;

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

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

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

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

如果是 Log 完全同步的方法,则此代码将按预期方式运行,因为在任何给定时间只有内存实例的一个活动使用者。 但想象一下,Log 有如下实现。

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

在此实现中, Log 违反其租约,因为它仍尝试在返回原始方法后在后台使用 Memory<T> 实例。 此方法 Main 可以在尝试从缓冲区中读取时改变缓冲区 Log ,这可能会导致数据损坏。

有多种方法可解决此问题:

  • Log 方法可以返回 Task 来替代 void,如下 Log 方法的实现所示。

    // An acceptable implementation.
    static Task Log(ReadOnlyMemory<char> message)
    {
        // Run in the background so that we don't block the main thread while performing IO.
        return Task.Run(() => {
            StreamWriter sw = File.AppendText(@".\input-numbers.dat");
            sw.WriteLine(message);
            sw.Flush();
        });
    }
    
  • Log 可以改为按如下所示实现:

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

规则 #4:如果方法接受内存<T> 并返回任务,则不能在任务转换为终端状态后使用内存<T> 实例

这只是 Rule #3 的异步变体。 Log前面的示例中的方法可以按如下方式编写,以符合以下规则:

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

此处的“终端状态”表示任务转换为已完成、出错或已取消的状态。 换而言之,“终止状态”表示“导致等待引发或继续执行的任何状态”。

本指南适用于返回 TaskTask<TResult>ValueTask<TResult>任何类似类型的方法。

规则 #5:如果构造函数接受内存<T> 作为参数,则构造对象的实例方法假定为内存<T> 实例的使用者

请看下面的示例:

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

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

在这里, OddValueExtractor 构造函数接受 ReadOnlyMemory<int> 构造函数参数,因此构造函数本身是实例的 ReadOnlyMemory<int> 使用者,返回值上的所有实例方法也是原始 ReadOnlyMemory<int> 实例的使用者。 这意味着,即使实例未直接传递给TryReadNextOddValue方法,ReadOnlyMemory<int>仍然会消耗该TryReadNextOddValue实例。

规则 6:如果类型上有一个可设置的内存<T> 类型属性(或等效的实例方法),则假定该对象的实例方法是内存<T> 实例的使用者

这确实只是规则 #5 的变体。 之所以存在此规则,是因为假定属性 setter 或等效方法捕获并保留其输入,因此同一对象的实例方法可能会利用捕获的状态。

以下示例触发此规则:

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

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

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

规则 7:如果具有 IMemoryOwner<T> 引用,则必须在某些时候对其进行处理或转让其所有权(但不同时执行两个操作)

由于Memory<T>实例可能由托管或非托管内存提供支持,因此当在Dispose实例上完成工作时,所有者必须在IMemoryOwner<T>上调用Memory<T>。 或者,所有者可将实例的 IMemoryOwner<T> 所有权转让给其他组件,此时获取组件将负责在适当时间调用 Dispose (稍后对此进行更多作)。

如果未能对Dispose实例调用IMemoryOwner<T>方法,可能会导致非托管内存泄漏或其他性能下降。

此规则也适用于调用工厂方法的代码,例如 MemoryPool<T>.Rent。 调用方将成为返回 IMemoryOwner<T> 的所有者,并负责在使用完毕后销毁实例。

规则 8:如果在 API 图面中有 IMemoryOwner<T> 参数,则表示你接受该实例的所有权

接受此类型的实例表示组件打算获取此实例的所有权。 组件将负责根据规则 7 进行正确处理。

将实例所有权 IMemoryOwner<T> 转移到其他组件的任何组件都不应在方法调用完成后再使用该实例。

重要

如果构造函数接受 IMemoryOwner<T> 为参数,则其类型应实现 IDisposable,并且方法 Dispose 应调用 Dispose 对象 IMemoryOwner<T>

规则 9:如果要包装同步 p/invoke 方法,API 应接受 Span<T> 作为参数

根据规则 #1, Span<T> 通常为用于同步 API 的正确类型。 可以通过 Span<T> 关键字固定 fixed 实例,如以下示例所示。

using System.Runtime.InteropServices;

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

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

        /* error checking retVal goes here */

        return retVal;
    }
}

在之前的示例中,如果输入范围为空,pbData 可能为 null。 如果导出的方法绝对要求 pbData 为非 null,即使 cbData 为 0,也可以按如下所示实现该方法:

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

        /* error checking retVal goes here */

        return retVal;
    }
}

规则 10:如果要包装异步 p/invoke 方法,API 应接受内存<T> 作为参数

由于不能在异步操作中使用fixed关键字,因此可以使用Memory<T>.Pin方法来固定Memory<T>实例,而不考虑该实例所表示的连续内存种类。 以下示例演示如何使用此 API 执行异步 p/invoke 调用。

using System.Runtime.InteropServices;

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

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

private static readonly IntPtr _callbackPtr = GetCompletionCallbackPointer();

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

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

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

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

    return tcs.Task;
}

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

    /* error checking result goes here */

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

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

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

另请参阅