【问题标题】:Unsafe slicing a 2D array with ReadOnlySpan<T>使用 ReadOnlySpan<T> 对二维数组进行不安全切片
【发布时间】:2020-08-26 22:09:53
【问题描述】:

在 .Net Core 3.1 上,我有许多大型 2D 数组,我需要对数组中单行的切片进行操作。同一个切片可能被多个操作使用,所以我想只执行一次切片并重用切片。

下面的示例代码对一个数组进行切片,然后调用2个函数对切片进行操作。

public void MyFunc()
{
    double[,] array = ...;  // populate the array

    // select which part of the array to slice, values not important
    int index0 = 0;
    int startIndex1 = 1;
    int sliceLength = 2;

    // slice the array
    ReadOnlySpan<double> slice = Slice(array, index0, startIndex1, sliceLength);

    // do things with the slice
    DoSomething1(slice);
    DoSomething2(slice);
}

public unsafe ReadOnlySpan<double> Slice(double[,] array, int index0, int startIndex1, int sliceLength)
{
    int arrayLength = array.GetLength(0) * array.GetLength(1);
    int arrayStartIndex = index0 * array.GetLength(1) + startIndex1;
    ReadOnlySpan<double> slice;
    fixed (double* arrayPtr = array)
    {
        slice = new ReadOnlySpan<double>(arrayPtr, arrayLength).Slice(arrayStartIndex, sliceLength);
    }

    // does it matter if slice is returned inside or outside of the fixed block?
    return slice;
}

public void DoSomething1(ReadOnlySpan<double> slice)
{
    ...
}

public void DoSomething2(ReadOnlySpan<double> slice)
{
    ...
}

“固定”确保 GC 在创建“切片”时不会移动“数组”。创建“slice”后,如果 GC 移动“array”,它会更新“slice”以引用新的“array”地址还是“slice”仍然引用旧地址?换句话说,DoSomething1(...) 和 DoSomething2(...) 是否总是对原始数组的预期切片进行操作,或者它们是否会无意中对随机内存块进行操作?

另外,“返回切片”是否重要?是在“固定”块的内部还是外部?

编辑https://stackoverflow.com/a/40589439/13532170 的启发下,我设法编写了一个测试来证明 V0ldek 关于 GC 在移动父数组时更新 ReadOnlySpan 的地址是正确的。

public static unsafe void ReadOnlySpanTest()
{
    // create 2D array
    double[,] array = new double[,] { {1, 2, 3}, {4, 5, 6} };

    // parameters to convert 2D array to 1D span
    int arrayLength = array.GetLength(0) * array.GetLength(1);
    int sliceStartIndex = 1;
    int sliceLength = 2;

    // create span
    IntPtr arrayAddressBeforeMove;
    ReadOnlySpan<double> spanFromPointer;
    fixed (double* arrayPtr = array)
    {
        arrayAddressBeforeMove = (IntPtr)arrayPtr;

        // spanFromPointer should contain { 2, 3 }
        spanFromPointer = new ReadOnlySpan<double>(arrayPtr, arrayLength).Slice(sliceStartIndex, sliceLength);
    }

    // trick GC into moving the array
    GC.AddMemoryPressure(10000000);
    GC.Collect();
    GC.RemoveMemoryPressure(10000000);

    // check array address and span contents again
    IntPtr arrayAddressAfterMove;
    fixed (double* arrayPtr = array)
    {
        // arrayAddressAfterMove should be different from arrayAddressBeforeMove
        arrayAddressAfterMove = (IntPtr) arrayPtr;

        // spanFromPointer should still contain { 2, 3 }
    }
}

在调试器中跨过 ReadOnlySpanTest(),我可以看到 arrayAddressAfterMove != arrayAddressBeforeMove,表明 GC 确实移动了我的数组。我还可以看到 spanFromPointer 在数组移动之前和之后都包含 { 2, 3 } 。所以不管 ReadOnlySpan 是用“固定”块创建的,离开“固定”块后仍然可以安全使用。

【问题讨论】:

    标签: c# .net-core garbage-collection unsafe-pointers


    【解决方案1】:

    创建Span&lt;T&gt;ReadOnlySpan&lt;T&gt;Memory&lt;T&gt; 后,所有后续使用都是安全的。

    Here's a reference by Stephen Toub.

    首先,Span 是一个值类型,包含一个 ref 和一个长度,大致定义如下:

    public readonly ref struct Span<T>
    {
      private readonly ref T _pointer;
      private readonly int _length;
      ...
    }
    

    ref T 字段的概念一开始可能很奇怪——事实上,不能在 C# 甚至 MSIL 中真正声明 ref T 字段。但实际上,Span 被编写为在运行时使用一种特殊的内部类型,该类型被视为即时 (JIT) 内在类型,JIT 为其生成相当于 ref T 字段。

    Span 是一种类 ref 类型,因为它包含一个 ref 字段,并且 ref 字段不仅可以引用数组等对象的开头,还可以引用它们的中间(...)这些引用称为内部指针,并且跟踪它们对于 .NET 运行时的垃圾收集器来说是一项相对昂贵的操作。因此,运行时将这些 ref 限制为仅存在于堆栈上,因为它为可能存在的内部指针的数量提供了一个隐含的下限。

    所以 GC 实际上确实会跟踪来自您的 ReadOnlySpan&lt;T&gt; 的指针,因此在构建跨度之后总是安全的。跨度将始终指向您切片的数组,无论您在哪里返回它都无关紧要。 如何具体完成的实现细节是特定于 CLR 的。要搜索的关键字是“托管指针”和“内部指针”。如果您想了解更多细节,我推荐this article

    【讨论】:

    • 在 MS 示例中,Span 指向本机内存,因此 GC 不会移动底层数组。在我的情况下,ReadOnlySpan 指向托管内存,因此 GC 可能会移动底层数组。我看到的所有关于 GC 跟踪 Span 指针的示例都指的是 Span 指向一维数组的情况。就我而言,我将我的二维数组视为一维数组,以便我可以在其上创建一个 ReadOnlySpan。我没有找到任何说明我的 GC 将使我的 1D 数组指针与 2D 数组保持同步的文档。
    • @ElGordo 你是对的。幸运的是,这个例子在宏观上毫无意义,剩下的解释就足够了。
    • @ElGordo 指针位于哪种托管内存无关紧要。它可以是一维数组的元素、二维数组的元素、对象的字段等。GC 仍然知道这些指针并将它们视为其他引用,这意味着对象保持活动状态并且指针在 GC 的压缩阶段更新。
    • 感谢您的帮助 V0ldek。我在我原来的问题中添加了一些测试代码,证明你说的是正确的。
    【解决方案2】:

    您是否考虑过使用Microsoft.Data.Analysis Nuget 包?使用数据填充DataFrame df 后,获取一行(相当于您的Slice 方法)就像df.Rows[rowIndex] 一样简单。要访问返回行中的每个值,您可以再次使用索引器:df.Rows[rowIndex][columnIndex]

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2021-01-29
      • 2014-04-04
      • 2013-06-21
      • 2018-11-07
      • 2016-02-20
      • 2021-12-27
      • 1970-01-01
      相关资源
      最近更新 更多