【问题标题】:Debugging .NET Memory leak calling StringBuilder.ToString() inside while loop在 while 循环中调用 StringBuilder.ToString() 调试 .NET 内存泄漏
【发布时间】:2021-12-21 22:10:54
【问题描述】:

背景:

我正在下载一个大型 (>500mb) 文本文件,其中包含我需要在数据库中运行的大量 SQL 语句。为此,我逐行处理文件,直到找到完整的查询然后执行它。运行此应用程序时,while 循环内的逻辑使用的内存比预期的要多。

我已经删除了对数据库运行查询的代码 - 调试后似乎不是导致问题的原因。

代码:

下面是一些示例代码来演示 - 显然这不是完整的程序,但这是我将问题缩小到的地方。请注意,sr 是一个 StreamReader,它已初始化为从我的 MemoryStream 读取。

StringBuilder query = new StringBuilder();

while (!sr.EndOfStream)
{
    query.AppendLine(await sr.ReadLineAsync());

    string currentQueryString = query.ToString();

    if (currentQueryString.EndsWith($"{Environment.NewLine}GO{Environment.NewLine}"))
    {
        // Run query against database

        // Clean up StringBuilder so it can be used again
        query = new StringBuilder();
        currentQueryString = "";
    }
}

对于这个例子,假设文件中的每个新行的长度都在 1 到 300 个字符之间。此外,99% 的查询是 INSERT 语句,包含 1,000 条记录(每条记录在一个新行上)。

当我运行应用程序时:

我可以在我的 Windows 任务管理器中看到,随着应用程序的运行,分配给应用程序的内存会增加,看起来几乎每次 while 循环迭代。我在currentQueryString = ""; 上放置了一个断点,每次它被击中(知道我刚刚将文件的另外 1,000 行读入内存)我可以看到应用程序使用的内存增加了(这次是使用诊断工具在 Visual Studio 中)大约从 100mb 到 200mb 不等,但是从每次遇到断点时拍摄快照,我可以看到堆大小几乎没有变化,无论哪种方式都可能有几百 kb。

我认为导致问题的原因:

目前我最好的猜测是string currentQueryString = query.ToString(); 行以某种方式初始化了一个可能在未释放的非托管内存中的变量。原因之一是我使用以下代码进行了测试,该代码删除了对 StringBuilder 的调用 toString(),并且内存使用量大大降低,因为每处理 1,000 行它只会增加大约 1-2mb 左右:

while (timer.Elapsed.TotalMinutes < 14 && !sr.EndOfStream && !killSwitch)
{
    query.AppendLine(await sr.ReadLineAsync());

    currentExeQueryCounter += 1;

    if (currentExeQueryCounter > 1000)
    {
        query = new StringBuilder();
        currentExeQueryCounter = 0;
    }
}

仅出于调试目的,我在第一个代码 sn-p 中的 currentQueryString = ""; 下面添加了 GC.Collect(),它完全解决了问题(在 Visual Studio 诊断工具和任务管理器中都观察到),我试图理解为什么会这样以及如何最好地解决这个问题,因为我的目标是将其作为一个无服务器应用程序运行,该应用程序将分配有限的内存。

【问题讨论】:

  • 如果GC.Collect()“解决”了问题。没有问题..
  • 让我们从基础开始。您是否有实际问题,或者您只是发现它使用的内存比您想象的要多?后者不是实际问题,除非它导致崩溃、减速等。
  • 而不是new StringBuilder 为什么不query.Clear()
  • 关于使用 query.Clear() 的 cmets 我之前实际上正在使用它,但看到另一篇帖子建议尝试将其设置为 null。当我这样做时,老实说,我只是抓着稻草——最后对我来说并没有什么不同,我只是忘了把它改回来。不过,我现在又重新使用query.Clear()

标签: c# .net memory memory-leaks


【解决方案1】:

补充来自 JonasH 的非常明智的回答:拨打query.ToString() 可能会花费你不少钱。这也是检查“GO”行的一种不必要的复杂方式。如果您只是将最近读取的行与“GO”进行比较,则可以减少 ToString() 调用。例如,像这样:

string line = await sr.ReadLineAsync();
query.AppendLine(line);

if (line == "GO")
{
    string currentQueryString = query.ToString();

    // Run query against database

    query.Clear(); // Clean up StringBuilder so it can be used again
}

【讨论】:

  • 谢谢Petter,看来query.ToString() 确实让我付出了很多,而您的回答对解决我的问题最有影响力和帮助!
【解决方案2】:

只是增加内存使用并不表示内存泄漏,垃圾收集器将根据自己的规则运行,例如当内存不足时。如果插入 GC.Collect 可以解决问题,那么可能一开始就没有泄漏。

每当存在潜在的内存问题时,我建议使用memory profiler。这应该允许您随意触发 GC,收集所有已分配对象的快照,并比较它们以查看某种对象计数是否在稳步增加。

也就是说,我建议将query = new StringBuilder(); 更改为query.Clear()。当您已经有可用的缓冲区时,无需重新分配大量内存。

您或许可以通过尽可能使用Span&lt;char&gt;/Memory&lt;char&gt; 而不是字符串来进一步降低分配率。这应该让您可以在更大的缓冲区中引用特定的字符序列,而无需进行任何复制或分配。这是Span&lt;&gt; 的主要原因,因为在进行大量 xml/json 反序列化和 html 处理时,复制字符串的效率有点低。

【讨论】:

    【解决方案3】:

    如果你在一张纸上画出一些执行周期的内存分配是如何发生的:

    1. query sb initial allocation
    2. ReadLineAsync retval allocation
    3. ReadLineAsync retval marked free
    4. currentQueryString allocation
    5. $"..." allocation (maybe compiler optimize this out from cycle)
    6. new query sb initial allocation
    7. "old" query sb marked free
    8. new currentQueryString allocation
    9. "old" currentQueryString marked free
    99. (here starts the Nth cycle)
    100. new ReadLineAsync retval allocation
    101. at some point query sb preallocation become too small, so here comes a bigger query sb allocation and the "old" area marked free
    102. new ReadLineAsync retval marked free
    103. new currentQueryString allocation
    104. $"..." allocation (maybe compiler optimize this out from cycle)
    105. new query sb initial allocation
    106. "old" query sb marked free
    107. new currentQueryString allocation
    108. "old" currentQueryString marked free
    (etc)
    

    内存分配逻辑每次都会寻找下一个足够大以容纳请求大小的可用空闲块。如果它没有找到这么大的区域,它将向操作系统询问一个新块。如果它不能得到,那么它将调用 GC,这将真正释放所有标记为空闲的块,如果仍然没有足够大的块,它将重新排列占用的块以合并空闲块。 GC 不会合并大对象堆上的块,其中分配大于 88kb(我记得)发生。这些 GC 步骤正在影响您的性能。

    因此,如果在步骤 1-108 期间发生的分配大小具有增长模式(sb 分配是一个潜在的嫌疑人),那么您将看到内存使用量持续增长(因为消耗内存比执行上述 GC 步骤快得多)。如果这种增长导致迁移到大对象堆,那么您可能会在某些时候遇到 OutOfMemoryException。如果您对这种情况进行转储,您可能会看到千兆字节的空闲进程内内存,但仍会收到 OOM! (这发生在我身上)

    这只是一个技术解释,其他的解决方案是正确的。 始终尝试重用已分配的内存,尤其是当您在循环中执行某些操作时,尤其是当这些循环具有未定义的重复计数时:

    • 使用 ReadLineAsync retval 进行“GO”检查,而不是新的 currentQueryString 分配
    • 不要指望编译器优化 $"..." 字符串超出你的循环。
    • 为最长的预期内容预分配足够大的 StringBuilder
    • 重用 StringBuilder(清除它)而不是实例化新的

    【讨论】:

      猜你喜欢
      • 2013-12-06
      • 2011-03-13
      • 1970-01-01
      • 2010-12-01
      • 2023-03-21
      • 2021-01-10
      • 2010-11-23
      • 2018-11-22
      • 1970-01-01
      相关资源
      最近更新 更多