为什么用stackalloc分配的内存超出范围时未释放?

时间:2019-06-21 10:59:39

标签: c#

在C#中使用stackalloc在堆栈上分配内存时,该内存的行为不如通常从堆栈上的普通变量所期望的那样。只有在方法返回时,内存才被释放,这与普通变量超出范围时被释放的情况相反。

我知道这不是一个错误,因为它清楚地写在C#参考(https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc)的stackalloc页上,其中说:“在方法执行期间创建的堆栈分配的内存块将被自动丢弃。当该方法返回时。”

我想知道这种行为背后的原因,因为它让我有些头疼。

考虑以下简单代码:

for (int i = 0; i < 100; i++)
{
    int a = 0;

    //Do something with a
}

上面的代码应为堆栈上的a分配4个字节,但是一旦离开循环范围,该内存就被释放。

然后考虑一下:

unsafe
{
    for (int i = 0; i < 100; i++)
    {
        int* a = stackalloc int[10];

        //Do something with a
    }
}

上面的代码现在在循环的每个迭代中分配40个字节。 当然,可以通过将stackalloc移出循环仅分配一次来优化该特定示例,但是如果需要分配的数据量在每次迭代之间变化,则不可能实现。

人们可能希望在超出范围时会重新分配内存,就像堆栈中的普通变量一样,这就是为什么我对这种行为的任何可能原因感兴趣的原因。

2 个答案:

答案 0 :(得分:1)

该方法返回时,分配的内存将自动丢弃。因此,您可以重构循环以调用执行堆栈分配的方法。这可能会带来调用另一个方法的开销。

让我们看看如何进行堆栈分配。堆栈包含调用方的返回地址和一些数据。当函数必须返回时,VM / cpu从堆栈中弹出地址并切换Progrem计数器/索引以指向该地址。如果函数需要一些临时数据,则可以将堆栈顶部上方的区域用作临时工作存储器。如果没有其他函数被调用,则无需取消分配任何内容。堆栈上方的区域被认为包含随机垃圾数据。函数返回时,从栈顶弹出返回地址,执行在调用函数时返回到调用方。局部变量存储在堆栈顶部上方。在机器代码中,它们只是从顶部偏移。

正常函数调用的堆栈示例:

 Param a - reserved and populated by the caller
 Param b
 Return address - top of the stack . Here points the stack pointer (SP). 
 local var1
 Local var2
 Local varx

本地var1的地址将为sp + size(ret地址) Var2是var1的地址+ var1的大小,依此类推。 (我的解释很简单,但是您可以在此处看到一个真实的示例:https://en.wikibooks.org/wiki/X86_Disassembly/Functions_and_Stack_Frames

由于编译器知道变量的大小,因此可以组织偏移量。习惯上,当前函数可以使用堆栈顶部上方的所有内容,并且必须保留顶部下方的所有内容。 因此,在堆栈上分配变量仅涉及选择非冲突偏移量(在顶部上方)。释放内存不涉及任何操作。我们返回到调用方的事实意味着我们对堆栈顶部上方的区域没有更多的要求。

现在,如果您正在使用顶部上方的堆栈区域,并且需要调用另一个可能(并且很可能会损坏)变量的函数,则必须将堆栈顶部暂时移至正在使用的数组上方,然后调用该函数并恢复返回之前的SP。

如果决定在堆栈上动态分配块,则必须保持其当前的“阴影”顶部和堆栈内容。保留更多的内存只会增加堆栈的影子顶部。并且当函数返回阴影顶时,只是简单地丢弃了阴影顶,而内存又再次可用(而不是具有“阴影”顶),大多数体系结构都会移动堆栈的顶,并且在返回时将其减小回原始值或使用已构建的-在说明中进行更正)。现在您可能会想说,由于您知道块大小,因此可以在返回之前释放它。但那时您可能有几个障碍。您可以保留一个链接列表并从最后一个发布。但是,在中间释放一个块将需要更高级的内存管理,并且几乎丧失了快速堆栈内存分配的所有好处。

答案 1 :(得分:0)

因为它是在alloca函数之后建模的。

或者至少https://www.ecma-international.org/publications/files/ECMA-ST-ARCH/ECMA-334%201st%20edition%20December%202001.pdf秒25.7说

  

在执行该函数成员时创建的所有堆栈分配的内存块都将在该函数成员返回时自动丢弃。 [注意:这对应于alloca函数,这是C和C ++实现的扩展。结束语]

此行为允许使用类似的代码

int*[] arr = new int*[values.Length];

for (int i = 0; i < values.Length; i++)
{
    arr[i] = stackalloc[values[i].Length];
    // do stuff
}

BulkProcess(arr);

编译器当然可以使用数据流和转义分析来了解这是一个提升的分配,但这是一件非常微妙的事情,如果您混淆了分析(例如arr[i] = SkipLeadingZeros(arr[i])),现在您可以难以发现的错误。

要做的真正棘手的事情是

int* outerDelayed;

if (whatever)
{
    int* inner = stackalloc[something];
    ...
    outerDelayed = stackalloc[somethingElse];
}
else
{
    outerDelayed = notRelevant;
}

int* secondOuter = stackalloc[aThirdValue];

secondOuter指向何处?如果inner未分配,并且aThirdValue大于something,则重新使用与inner相同的堆栈位置将重叠outerDelayed的缓冲区。跟踪内存有多大漏洞以及使用它是否合理是内存分配器(无论是malloc还是GC)的工作,但这是stackalloc试图避免的事情。 (如果outerDelayedstackalloc超出作用域时也没有分配,而变量在词法作用域之外时,则存在非常糟糕的“悬空指针”情况)

因此,实际上,最简单的模型是“所有stackalloc都是累积的”,因为它们每次都会滑动%RSP,然后让函数以恢复堆栈指针到%RBP的正常流程结束,同时取消所有固定变量和所有延迟堆栈分配的堆栈分配。这个模型很简单,这意味着开发人员,审阅者和错误修复者都可以理解它,并将其应用于所涉及的代码中。