创建堆栈大小为默认值 50 倍的线程时有啥危险?

Posted

技术标签:

【中文标题】创建堆栈大小为默认值 50 倍的线程时有啥危险?【英文标题】:What are the dangers when creating a thread with a stack size of 50x the default?创建堆栈大小为默认值 50 倍的线程时有什么危险? 【发布时间】:2014-08-03 18:03:38 【问题描述】:

我目前正在开发一个对性能非常关键的程序,我决定探索可能有助于减少资源消耗的一条路径是增加我的工作线程的堆栈大小,以便我可以移动大部分数据 (float[]s)我将访问堆栈(使用stackalloc)。

我 read 认为线程的默认堆栈大小为 1 MB,因此为了移动我所有的 float[]s,我必须将堆栈扩展大约 50 倍(至 50 MB~)。

我知道这通常被认为是“不安全的”并且不被推荐,但是在针对此方法对我当前的代码进行基准测试后,我发现处理速度提高了 530%!所以我不能在没有进一步调查的情况下简单地通过这个选项,这导致了我的问题;将堆栈增加到如此大的大小有哪些危险(可能会出现什么问题),我应该采取哪些预防措施来最大程度地减少这种危险?

我的测试代码,

public static unsafe void TestMethod1()

    float* samples = stackalloc float[12500000];

    for (var ii = 0; ii < 12500000; ii++)
    
        samples[ii] = 32768;
    


public static void TestMethod2()

    var samples = new float[12500000];

    for (var i = 0; i < 12500000; i++)
    
        samples[i] = 32768;
    

【问题讨论】:

+1。严重地。你问什么看起来像一个愚蠢的问题,然后你做了一个很好的案例,在你的特定场景中考虑这是一件明智的事情,因为你做了功课并衡量了结果。这非常好 - 我想念很多问题。非常好 - 很好,您考虑这样的事情,遗憾的是许多 C# 程序员并没有意识到这些优化机会。是的,通常不需要 - 但有时它很关键并且会产生巨大的影响。 我有兴趣看到两个代码在处理速度上有 530% 的差异,这仅仅是因为将数组移动到堆栈。只是感觉不对。 在您跳上这条路之前:您是否尝试过使用Marshal.AllocHGlobal(也不要忘记FreeHGlobal)来分配托管内存外部的数据?然后将指针转换为float*,您应该会被排序。 如果您进行大量分配,感觉确实不错。 Stackalloc 绕过了所有 GC 问题,这些问题也可以创建/确实在处理器级别上创建了一个非常强大的局部性。这是看起来像微优化的事情之一 - 除非您编写高性能数学程序并且具有这种行为并且它会有所作为;) 我的怀疑:其中一种方法会在每次循环迭代时触发边界检查,而另一种则不会,或者它已被优化掉。 【参考方案1】:

由于性能差异太大,问题几乎与分配有关。很可能是数组访问引起的。

我反汇编了函数的循环体:

测试方法1:

IL_0011:  ldloc.0 
IL_0012:  ldloc.1 
IL_0013:  ldc.i4.4 
IL_0014:  mul 
IL_0015:  add 
IL_0016:  ldc.r4 32768.
IL_001b:  stind.r4 // <----------- This one
IL_001c:  ldloc.1 
IL_001d:  ldc.i4.1 
IL_001e:  add 
IL_001f:  stloc.1 
IL_0020:  ldloc.1 
IL_0021:  ldc.i4 12500000
IL_0026:  blt IL_0011

测试方法2:

IL_0012:  ldloc.0 
IL_0013:  ldloc.1 
IL_0014:  ldc.r4 32768.
IL_0019:  stelem.r4 // <----------- This one
IL_001a:  ldloc.1 
IL_001b:  ldc.i4.1 
IL_001c:  add 
IL_001d:  stloc.1 
IL_001e:  ldloc.1 
IL_001f:  ldc.i4 12500000
IL_0024:  blt IL_0012

我们可以检查指令的使用情况,更重要的是检查它们在ECMA spec 中抛出的异常:

stind.r4: Store value of type float32 into memory at address

它抛出的异常:

System.NullReferenceException

stelem.r4: Replace array element at index with the float32 value on the stack.

它抛出的异常:

System.NullReferenceException
System.IndexOutOfRangeException
System.ArrayTypeMismatchException

如您所见,stelem 在数组范围检查和类型检查方面做得更多。由于循环体几乎没有做任何事情(仅赋值),因此检查的开销支配了计算时间。这就是为什么性能相差 530%。

这也回答了您的问题:危险在于缺少数组范围和类型检查。这是不安全的(如函数声明中所述;D)。

【讨论】:

【参考方案2】:

在将测试代码与 Sam 进行比较后,我确定我们都是对的! 但是,关于不同的事情:

无论在堆栈、全局还是堆中,访问内存(读取和写入)都一样快。 但是,分配它在堆栈上最快,在堆上最慢。

它是这样的:stack global heap。 (分配时间) 从技术上讲,堆栈分配并不是真正的分配,运行时只是确保堆栈的一部分(帧?)为数组保留。

不过,我强烈建议您谨慎行事。 我推荐以下:

    当您需要频繁创建永远不会离开函数的数组时(例如通过传递其引用),使用堆栈将是一个巨大的改进。 如果您可以回收阵列,请尽可能回收!堆是长期对象存储的最佳场所。 (污染全局内存不好;堆栈帧可能会消失)

(注意:1.只适用于值类型;引用类型会分配在堆上,收益会降为0)

回答这个问题本身:我在任何大型堆栈测试中都没有遇到任何问题。 我相信唯一可能的问题是堆栈溢出,如果你不小心你的函数调用并且在系统运行不足的情况下创建你的线程时内存不足。

以下部分是我的初步回答。这是错误的,测试不正确。仅供参考。


我的测试表明堆栈分配的内存和全局内存至少比在数组中使用的堆分配内存慢 15%(需要 120% 的时间)!

This is my test code,这是一个示例输出:

Stack-allocated array time: 00:00:00.2224429
Globally-allocated array time: 00:00:00.2206767
Heap-allocated array time: 00:00:00.1842670
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 100.80 %| 120.72 %|
--+---------+---------+---------+
G |  99.21 %|    -    | 119.76 %|
--+---------+---------+---------+
H |  82.84 %|  83.50 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

我在 .NET 4.5.1 下使用 i7 4700 MQ 在 Windows 8.1 Pro(更新 1)上进行了测试 我用 x86 和 x64 测试过,结果是一样的。

编辑:我将所有线程的堆栈大小增加到 201 MB,样本大小增加到 5000 万,并将迭代次数减少到 5。 结果同上

Stack-allocated array time: 00:00:00.4504903
Globally-allocated array time: 00:00:00.4020328
Heap-allocated array time: 00:00:00.3439016
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 112.05 %| 130.99 %|
--+---------+---------+---------+
G |  89.24 %|    -    | 116.90 %|
--+---------+---------+---------+
H |  76.34 %|  85.54 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

不过,似乎堆栈实际上变慢了

【讨论】:

我不得不不同意,根据my benchmark's results(结果见页面底部的评论)表明堆栈比全局快一点,比堆快得多;为了确定我的结果是准确的,我运行了 20 次测试,并且每次测试迭代都调用了每种方法 100 次。您确定正确运行基准测试了吗? 我得到的结果非常不一致。完全信任,x64,发布配置,没有调试器,它们都同样快(小于 1% 的差异;波动),而你的堆栈确实快得多。我需要进一步测试! 编辑:你的应该抛出堆栈溢出异常。您只需为数组分配足够的空间。 O_o 是的,我知道,它很接近。您需要像我一样重复几次基准测试,也许尝试平均运行 5 次左右。 @Voo 对我来说,第一次运行所花费的时间与任何测试的第 100 次运行一样多。根据我的经验,这个 Java JIT 东西根本不适用于 .NET。 .NET 所做的唯一“预热”是在第一次使用时加载类和程序集。 @Voo 测试我的基准测试以及他在对此答案的评论中添加的要点。将代码组装在一起并运行数百个测试。然后回来报告你的结论。我已经非常彻底地完成了我的测试,并且我非常清楚我所说的 .NET 不会像 Java 那样解释任何字节码,它会立即对其进行 JIT。【参考方案3】:

我发现处理速度提高了 530%!

这是迄今为止我要说的最大危险。您的基准测试存在严重问题,表现出这种不可预知的代码通常在某处隐藏了一个令人讨厌的错误。

除了过度递归之外,在 .NET 程序中消耗大量堆栈空间是非常非常困难的。托管方法的堆栈框架的大小是一成不变的。只是方法的参数和方法中的局部变量的总和。减去可以存储在 CPU 寄存器中的那些,你可以忽略它,因为它们太少了。

增加堆栈大小并不能完成任何事情,您只会保留一堆永远不会使用的地址空间。当然,没有任何机制可以解释不使用内存带来的性能提升。

这与本地程序不同,尤其是用 C 编写的程序,它还可以为堆栈帧上的数组保留空间。堆栈缓冲区溢出背后的基本恶意软件攻击向量。在 C# 中也可能,您必须使用 stackalloc 关键字。如果您这样做,那么明显的危险是必须编写容易受到此类攻击以及随机堆栈帧损坏的不安全代码。很难诊断错误。在以后的抖动中对此有一种对策,我认为从 .NET 4.0 开始,抖动会生成代码以将“cookie”放在堆栈帧上,并在方法返回时检查它是否仍然完好无损。如果发生这种情况,桌面会立即崩溃,无法拦截或报告事故。这……对用户的精神状态很危险。

您的程序的主线程,即操作系统启动的主线程,默认情况下将有一个 1 MB 堆栈,当您编译面向 x64 的程序时为 4 MB。增加它需要在生成后事件中使用 /STACK 选项运行 Editbin.exe。在程序在 32 位模式下运行时无法启动之前,您通常可以请求最多 500 MB。线程也可以,当然要容易得多,对于 32 位程序,危险区域通常徘徊在 90 MB 左右。当您的程序运行了很长时间并且地址空间从以前的分配中碎片化时触发。要获得此故障模式,总地址空间使用量必须已经很高,超过一个 gig。

三重检查您的代码,有一些非常错误的地方。除非您明确编写代码以利用它,否则您无法通过更大的堆栈获得 x5 加速。这总是需要不安全的代码。在 C# 中使用指针总是具有创建更快代码的诀窍,它不受数组边界检查的影响。

【讨论】:

报告的 5 倍加速是从 float[] 移动到 float*。大堆栈就是如何完成的。在某些情况下,5 倍的加速对于这种变化是完全合理的。 好的,我开始回答问题时还没有sn-p代码。仍然足够接近。【参考方案4】:

使用 JIT 和 GC 的微基准测试语言(如 Java 或 C#)可能有点复杂,因此使用现有框架通常是个好主意 - Java 提供了非常出色的 mhf 或 Caliper,遗憾的是据我所知 C# 没有不提供任何接近这些的东西。 Jon Skeet 在这里写了this,我会盲目地假设它负责最重要的事情(Jon 知道他在那个领域做什么;当然,不用担心我确实检查过)。我稍微调整了时间,因为热身后每次测试 30 秒对我的耐心来说太长了(应该是 5 秒)。

首先是结果,Windows 7 x64 下的 .NET 4.5.1 - 数字表示它可以在 5 秒内运行的迭代,因此越高越好。

x64 JIT:

Standard       10,589.00  (1.00)
UnsafeStandard 10,612.00  (1.00)
Stackalloc     12,088.00  (1.14)
FixedStandard  10,715.00  (1.01)
GlobalAlloc    12,547.00  (1.18)

x86 JIT(是的,这还是有点难过):

Standard       14,787.00   (1.02)
UnsafeStandard 14,549.00   (1.00)
Stackalloc     15,830.00   (1.09)
FixedStandard  14,824.00   (1.02)
GlobalAlloc    18,744.00   (1.29)

这提供了最多 14% 的更合理的加速(并且大部分开销是由于 GC 必须运行,实际上认为这是最坏的情况)。不过 x86 的结果很有趣 - 并不完全清楚那里发生了什么。

这是代码:

public static float Standard(int size) 
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) 
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    
    return samples[size - 1];


public static unsafe float UnsafeStandard(int size) 
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) 
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    
    return samples[size - 1];


public static unsafe float Stackalloc(int size) 
    float* samples = stackalloc float[size];
    for (var ii = 0; ii < size; ii++) 
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    
    return samples[size - 1];


public static unsafe float FixedStandard(int size) 
    float[] prev = new float[size];
    fixed (float* samples = &prev[0]) 
        for (var ii = 0; ii < size; ii++) 
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        
        return samples[size - 1];
    


public static unsafe float GlobalAlloc(int size) 
    var ptr = Marshal.AllocHGlobal(size * sizeof(float));
    try 
        float* samples = (float*)ptr;
        for (var ii = 0; ii < size; ii++) 
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        
        return samples[size - 1];
     finally 
        Marshal.FreeHGlobal(ptr);
    


static void Main(string[] args) 
    int inputSize = 100000;
    var results = TestSuite.Create("Tests", inputSize, Standard(inputSize)).
        Add(Standard).
        Add(UnsafeStandard).
        Add(Stackalloc).
        Add(FixedStandard).
        Add(GlobalAlloc).
        RunTests();
    results.Display(ResultColumns.NameAndIterations);

【讨论】:

一个有趣的观察,我将不得不再次检查我的基准。虽然这仍然不能真正回答我的问题,“...将堆栈增加到如此大的大小有什么危险...”。即使我的结果不正确,问题仍然有效;不过,我很感激你的努力。 @Sam 当使用12500000 作为大小时,我实际上得到了一个*** 异常。但主要是关于拒绝使用堆栈分配代码快几个数量级的基本前提。否则,我们在这里做的工作量几乎是最少的,差异已经只有 10-15% 左右——实际上它会更低。我认为这肯定会改变整个讨论。【参考方案5】:

编辑:(代码和测量的微小变化会导致结果发生很大变化)

首先,我在调试器 (F5) 中运行优化代码,但这是错误的。它应该在没有调试器的情况下运行 (Ctrl+F5)。其次,代码可能会被彻底优化,因此我们必须使其复杂化,以免优化器干扰我们的测量。我让所有方法都返回数组中的最后一项,并且数组的填充方式不同。在 OP 的 TestMethod2 中还有一个额外的零,总是让它慢十倍。

除了您提供的两种方法外,我还尝试了其他一些方法。方法 3 的代码与方法 2 相同,但函数声明为 unsafe。方法 4 是使用指针访问定期创建的数组。方法 5 使用指针访问非托管内存,如 Marc Gravell 所述。 所有五种方法的运行时间都非常相似。 M5 最快(M1 紧随其后)。最快和最慢之间的差异大约是 5%,这不是我关心的问题。

    public static unsafe float TestMethod3()
    
        float[] samples = new float[5000000];

        for (var ii = 0; ii < 5000000; ii++)
        
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        

        return samples[5000000 - 1];
    

    public static unsafe float TestMethod4()
    
        float[] prev = new float[5000000];
        fixed (float* samples = &prev[0])
        
            for (var ii = 0; ii < 5000000; ii++)
            
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            

            return samples[5000000 - 1];
        
    

    public static unsafe float TestMethod5()
    
        var ptr = Marshal.AllocHGlobal(5000000 * sizeof(float));
        try
        
            float* samples = (float*)ptr;

            for (var ii = 0; ii < 5000000; ii++)
            
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            

            return samples[5000000 - 1];
        
        finally
        
            Marshal.FreeHGlobal(ptr);
        
    

【讨论】:

那么 M3 和 M2 一样,只是标有“不安全”?相当怀疑它会更快......你确定吗? @romkyns 我刚刚运行了一个基准测试(M2 与 M3),令人惊讶的是,M3 实际上比 M2 快 2.14%。 "结论是不需要使用堆栈。" 在分配大块时,例如我在帖子中给出的,我同意,但是,在刚刚完成了一些之后基准测试M1 vs M2(两种方法都使用PFM's idea)我当然不同意,因为 M1 现在比 M2 快 135%。 @Sam 但是您仍在将指针访问与数组访问进行比较! THAT 主要是使它更快。 TestMethod4 vs TestMethod1stackalloc 的一个更好的比较。 @romkyns 是的,好点,我忘了;我重新运行了benchmarks,现在只有 8% 的差异(M1 是两者中更快的)。【参考方案6】:

我会在那里有所保留,我根本不知道如何预测它 - 权限、GC(需要扫描堆栈)等 - 所有这些都可能受到影响。我很想改用非托管内存:

var ptr = Marshal.AllocHGlobal(sizeBytes);
try

    float* x = (float*)ptr;
    DoWork(x);

finally

    Marshal.FreeHGlobal(ptr);

【讨论】:

附带问题:为什么 GC 需要扫描堆栈? stackalloc 分配的内存不受垃圾回收的影响。 @dcastro 它需要扫描堆栈以检查仅存在于堆栈中的引用。我根本不知道当它达到如此巨大的stackalloc 时它会做什么——它有点需要跳过它,你希望它会毫不费力地做到这一点——但我想说的是它引入了不必要的并发症/问题。 IMO,stackalloc 非常适合作为暂存缓冲区,但对于专用工作区,更希望在某处分配一块内存,而不是滥用/混淆堆栈,【参考方案7】:

高性能数组可能可以像普通 C# 一样访问,但这可能是麻烦的开始:考虑以下代码:

float[] someArray = new float[100]
someArray[200] = 10.0;

您期望一个超出范围的异常,这完全有道理,因为您正在尝试访问元素 200,但允许的最大值是 99。如果您转到 stackalloc 路由,那么将没有对象包裹在您的数组周围以进行绑定检查,以下不会显示任何异常:

Float* pFloat =  stackalloc float[100];
fFloat[200]= 10.0;

上面您分配了足够的内存来容纳 100 个浮点数,并且您正在设置 sizeof(float) 内存位置,该位置从该内存开始的位置开始 + 200*sizeof(float) 用于保存您的浮点值 10。不出所料,这个内存位于为浮点分配的内存之外,没有人会知道该地址中可以存储什么。如果幸运的话,您可能已经使用了一些当前未使用的内存,但同时您可能会覆盖一些用于存储其他变量的位置。总结:不可预测的运行时行为。

【讨论】:

其实错了。运行时和编译器测试仍然存在。 @TomTom 呃,不;答案是有道理的;该问题涉及stackalloc,在这种情况下,我们谈论的是float* 等-没有相同的检查。它被称为unsafe 是有充分理由的。我个人非常乐意在有充分理由的情况下使用unsafe,但苏格拉底提出了一些合理的观点。 @Marc 对于显示的代码(在运行 JIT 之后),不再有边界检查,因为编译器很容易推断所有访问都是边界内的。总的来说,尽管这肯定会有所作为。【参考方案8】:

可能出错的一件事是您可能没有获得这样做的许可。除非在完全信任模式下运行,否则框架将忽略更大堆栈大小的请求(请参阅 MSDN Thread Constructor (ParameterizedThreadStart, Int32)

我建议不要将系统堆栈大小增加到如此庞大的数字,而是重写您的代码,以便它使用迭代和堆上的手动堆栈实现。

【讨论】:

好主意,我将改为迭代。除此之外,我的代码在完全信任模式下运行,所以还有什么我需要注意的吗?

以上是关于创建堆栈大小为默认值 50 倍的线程时有啥危险?的主要内容,如果未能解决你的问题,请参考以下文章

最大线程堆栈大小.NET?

实时监控 Java 中的总线程堆栈大小

线程的堆栈

测量 Linux 多线程应用程序的堆栈使用情况

增加主程序的堆栈大小或为递归代码块创建一个具有更大堆栈大小的新线程?

为我的堆栈分配 2 倍的内存(仅在需要时)C