在C#中检查堆栈大小

26

有没有一种方法可以在C#中检查线程的堆栈大小?


据我所知,你不能这样做。至少没有使用本地方法的方式可以实现。 - Propeng
我想知道在某个时间点堆栈的使用情况。比如说,如果我调用一个递归方法10次,我想知道在那个时间点堆栈使用了多少(或剩余了多少)。 - Gjorgji
使用性能分析器来完成这个任务,不要试图自己去做。你的程序会对这些信息进行处理吗? - John Saunders
有时候了解堆栈大小会很有用。我正在研究这个问题,因为我考虑嵌入一个作为编译代码运行的脚本语言,并且我想插入代码到编译后的脚本中来监控和限制其自身的内存使用。 - John B. Lambe
2个回答

23
这是一个如果你不得不问,那么你买不起它的案例(Raymond Chen首先说了这句话)。如果代码依赖于有足够的堆栈空间以至于必须先进行检查,那么重构代码以使用显式的Stack<T>对象可能是值得的。 John的评论中有使用性能分析器的价值。
话虽如此,事实证明有一种方法可以估算剩余的堆栈空间。它不是精确的,但对于评估距离底部有多近是足够有用的。以下内容在很大程度上基于Joe Duffy的excellent article
我们知道(或将做出假设):
1. 堆栈内存是在连续块中分配的。 2. 堆栈向下生长,从较高地址向较低地址生长。 3. 系统需要靠近分配的堆栈空间底部的一些空间来允许优雅地处理堆栈溢出异常。我们不知道确切的保留空间,但我们将尝试保守地限制它。
假设这些条件成立,我们可以使用pinvoke VirtualQuery获取已分配的堆栈的起始地址,并从一些在unsafe代码中获得的堆栈分配变量的地址中减去它。进一步减去我们估计系统在堆栈底部需要的空间,就能得出可用空间的估计值。
以下代码演示了这一点,它调用递归函数并按顺序输出剩余的估计堆栈空间(以字节为单位):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Runtime.InteropServices;

namespace ConsoleApplication1 {
    class Program {
        private struct MEMORY_BASIC_INFORMATION {
            public uint BaseAddress;
            public uint AllocationBase;
            public uint AllocationProtect;
            public uint RegionSize;
            public uint State;
            public uint Protect;
            public uint Type;
        }

        private const uint STACK_RESERVED_SPACE = 4096 * 16;

        [DllImport("kernel32.dll")]
        private static extern int VirtualQuery(
            IntPtr                          lpAddress,
            ref MEMORY_BASIC_INFORMATION    lpBuffer,
            int                             dwLength);


        private unsafe static uint EstimatedRemainingStackBytes() {
            MEMORY_BASIC_INFORMATION    stackInfo   = new MEMORY_BASIC_INFORMATION();
            IntPtr                      currentAddr = new IntPtr((uint) &stackInfo - 4096);

            VirtualQuery(currentAddr, ref stackInfo, sizeof(MEMORY_BASIC_INFORMATION));
            return (uint) currentAddr.ToInt64() - stackInfo.AllocationBase - STACK_RESERVED_SPACE;
        }

        static void SampleRecursiveMethod(int remainingIterations) {
            if (remainingIterations <= 0) { return; }

            Console.WriteLine(EstimatedRemainingStackBytes());

            SampleRecursiveMethod(remainingIterations - 1);
        }

        static void Main(string[] args) {
            SampleRecursiveMethod(100);
            Console.ReadLine();
        }
    }
}

以下是输出的前10行(intel x64,.NET 4.0,debug)。考虑到默认的1MB堆栈大小,计数似乎是合理的。

969332
969256
969180
969104
969028
968952
968876
968800
968724
968648

为简洁起见,上述代码假定页面大小为4K。虽然对于x86和x64是正确的,但对于其他支持的CLR架构可能不正确。您可以调用GetSystemInfo以获取计算机的页面大小(SYSTEM_INFO结构体的dwPageSize)。
请注意,此技术并不特别可移植,也不具备未来性。使用pinvoke限制了此方法在Windows主机上的实用性。有关CLR堆栈连续性和增长方向的假设可能对目前的Microsoft实现成立。然而,我(可能有限的)阅读CLI标准(公共语言基础结构,PDF,需要长时间阅读)似乎并不要求线程堆栈这么多。就CLI而言,每个方法调用都需要一个堆栈帧;但它并不关心堆栈是否向上增长,本地变量堆栈是否与返回值堆栈分开,或者堆栈帧是否在堆上分配。

2
如果有人询问一个常数,即“程序可以安全使用多少堆栈”,我会赞同“IYHTA,YCAI”的哲学。另一方面,如果有人正在编写类似解析器的东西,在输入中可以使用递归来处理任何预期的嵌套结构级别,那么保持递归检查剩余堆栈空间并在不足时调用抛出“嵌套太深”异常似乎更加清晰,而不是对嵌套施加任意限制。 - supercat
3
这个检查在调试中也可能很有用,可以在你遇到堆栈溢出的情况下设置断点。一个断点将允许你跳转到调用栈的开头并检查每个变量。一旦StackOverflowException被抛出,Visual Studio就不能再读取变量了,为时已晚。 - ygoe

11

我在这里添加这个答案以便将来参考。

Oren的答案回答了SO的问题(通过评论细化),但它没有表明栈实际上分配了多少内存。要获得该答案,您可以使用Michael Ganß的答案,我已经更新了一些更新的C#语法。

public static class Extensions
{
    public static void StartAndJoin(this Thread thread, string header)
    {
        thread.Start(header);
        thread.Join();
    }
}

class Program
{
    [DllImport("kernel32.dll")]
    static extern void GetCurrentThreadStackLimits(out uint lowLimit, out uint highLimit);

    static void WriteAllocatedStackSize(object header)
    {
        GetCurrentThreadStackLimits(out var low, out var high);
        Console.WriteLine($"{header,-19}:  {((high - low) / 1024),4} KB");
    }

    static void Main(string[] args)
    {
        WriteAllocatedStackSize("Main    Stack Size");

        new Thread(WriteAllocatedStackSize, 1024 *    0).StartAndJoin("Default Stack Size");
        new Thread(WriteAllocatedStackSize, 1024 *  128).StartAndJoin(" 128 KB Stack Size");
        new Thread(WriteAllocatedStackSize, 1024 *  256).StartAndJoin(" 256 KB Stack Size");
        new Thread(WriteAllocatedStackSize, 1024 *  512).StartAndJoin(" 512 KB Stack Size");
        new Thread(WriteAllocatedStackSize, 1024 * 1024).StartAndJoin("   1 MB Stack Size");
        new Thread(WriteAllocatedStackSize, 1024 * 2048).StartAndJoin("   2 MB Stack Size");
        new Thread(WriteAllocatedStackSize, 1024 * 4096).StartAndJoin("   4 MB Stack Size");
        new Thread(WriteAllocatedStackSize, 1024 * 8192).StartAndJoin("   8 MB Stack Size");
    }
}

有趣的是(也是我发布这篇文章的原因),在使用不同配置运行时的输出结果。供参考,我使用的是 Windows 10 企业版(版本号为 1709)64 位操作系统,并且使用 .NET Framework 4.7.2 运行(如果有影响的话)。

Release|Any CPU(偏好选择32位选项 已选):

Release|Any CPU(偏好选择32位选项 未选):

Release|x86:

Main    Stack Size :  1024 KB
Default Stack Size :  1024 KB // default stack size =   1 MB
 128 KB Stack Size :   256 KB // minimum stack size = 256 KB
 256 KB Stack Size :   256 KB
 512 KB Stack Size :   512 KB
   1 MB Stack Size :  1024 KB
   2 MB Stack Size :  2048 KB
   4 MB Stack Size :  4096 KB
   8 MB Stack Size :  8192 KB

发布版|64位:

Main    Stack Size :  4096 KB
Default Stack Size :  4096 KB // default stack size =   4 MB
 128 KB Stack Size :   256 KB // minimum stack size = 256 KB
 256 KB Stack Size :   256 KB
 512 KB Stack Size :   512 KB
   1 MB Stack Size :  1024 KB
   2 MB Stack Size :  2048 KB
   4 MB Stack Size :  4096 KB
   8 MB Stack Size :  8192 KB
这些结果并不特别令人震惊,因为它们与文档一致。然而,有点令人惊讶的是,在Release | Any CPU配置下,默认堆栈大小为1 MB,并且未选中Prefer 32位选项,则在64位操作系统上作为64位进程运行。我本来以为在这种情况下,默认堆栈大小应该是4 MB,就像Release | x64配置一样。

无论如何,我希望这对那些想了解.NET线程的堆栈大小的人有所帮助,就像我一样。


感谢您的发现,我也对 Any CPU(未选中首选 32 位选项)仅占用 1MB 感到震惊。因此,即使 Environment.Is64BitProcess 为 true,它也只占用 1MB。 - gtdev
2
对于 <TargetFramework>net5.0</TargetFramework>(以及早期版本的 .NET Core),main 函数的输出为 "Main Stack Size : 1536 KB"。因此,.NET Core 的堆栈大小增加了 50%。然而,当我将配置更改为 Release|x64 时,该输出没有发生变化,这是出乎意料的。我使用 Visual Studio 中的 Configuration Manager 进行了实验。 - Scott Hutchinson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接