在 while 循环中调试 .NET 内存泄漏调用 StringBuilder.ToString()
Debugging .NET Memory leak calling StringBuilder.ToString() inside while loop
背景:
我正在下载一个大型 (>500mb) 文本文件,其中包含大量 SQL 语句,我需要 运行 跨数据库。为此,我逐行处理文件,直到找到完整的查询然后执行它。当 运行 运行此应用程序时,while 循环内的逻辑使用的内存比预期的多。
我已经删除了运行查询数据库的代码 - 调试后似乎不是导致问题的原因。
代码:
下面是一些要演示的示例代码 - 显然这不是完整的程序,但这是我将问题缩小到的地方。请注意 sr
是 StreamReader which has been initialised to read from my 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 任务管理器中看到,作为应用程序 运行s,分配给该应用程序的内存几乎在 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;
}
}
仅出于调试目的,我在第一个代码片段 currentQueryString = "";
下添加了 GC.Collect()
,这完全解决了问题(在 Visual Studio 诊断工具和任务管理器中都观察到了),并且我我试图理解为什么会这样,以及我如何才能最好地解决这个问题,因为我的目标是 运行 这是一个无服务器应用程序,它将分配有限的内存。
仅仅增加内存使用量并不表示内存泄漏,垃圾收集器会运行根据自己的规则,例如当内存不足时。如果插入 GC.Collect
可以解决问题,则可能从一开始就没有泄漏。
只要存在潜在的内存问题,我都会建议使用 memory profiler。这应该允许您随意触发 GC,收集所有已分配对象的快照,并比较它们以查看某种对象计数是否在稳步增加。
也就是说,我建议将 query = new StringBuilder();
更改为 query.Clear()
。当您已经有可用的缓冲区时,无需重新分配一堆内存。
您或许可以通过尽可能使用 Span<char>
/Memory<char>
而不是字符串来进一步降低分配率。这应该让您在更大的缓冲区中引用特定的字符序列,而无需进行任何复制或分配。这是 Span<>
的主要原因,因为在进行大量 xml/json 反序列化和 html 处理时,复制字符串的效率有点低。
补充一下来自 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
}
如果您在纸上画出某些执行周期的内存分配情况:
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)
内存分配逻辑每次都会寻找下一个足够大的可用空闲块来容纳请求的大小。如果它找不到这么大的区域,它将从 OS 中请求一个新块。如果无法获取,它将调用 GC,这将真正释放所有标记为空闲的块,如果仍然没有足够大的块,它将重新排列占用的块以合并空闲块。 GC 不会在分配大于 88kb(我记得)的大对象堆上合并块。这些 GC 步骤正在影响您的性能。
因此,如果在步骤 1-108 期间发生的分配大小有增长模式(sb 分配是一个潜在的嫌疑人),那么您将看到内存使用量不断增长(因为消耗内存比上面的 GC 步骤快得多) .如果这种增长导致移动到大对象堆,那么您可能会在某个时候遇到 OutOfMemoryException。如果您对这种情况进行转储,您可能会看到数 GB 的空闲进程内存,但仍然收到 OOM! (这发生在我身上)
这只是技术上的解释,其他的解决方案都是正确的。
始终尝试重用已分配的内存,尤其是当您在循环中执行某些操作时,尤其是当这些循环具有未定义的重复计数时:
- 使用 ReadLineAsync retval 进行“GO”检查而不是新的 currentQueryString 分配
- 不要指望编译器优化你的循环中的 $"..." 字符串。
- 为最长的预期内容预分配足够大的 StringBuilder
- 重用 StringBuilder(清除它)而不是实例化新的
背景:
我正在下载一个大型 (>500mb) 文本文件,其中包含大量 SQL 语句,我需要 运行 跨数据库。为此,我逐行处理文件,直到找到完整的查询然后执行它。当 运行 运行此应用程序时,while 循环内的逻辑使用的内存比预期的多。
我已经删除了运行查询数据库的代码 - 调试后似乎不是导致问题的原因。
代码:
下面是一些要演示的示例代码 - 显然这不是完整的程序,但这是我将问题缩小到的地方。请注意 sr
是 StreamReader which has been initialised to read from my 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 任务管理器中看到,作为应用程序 运行s,分配给该应用程序的内存几乎在 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;
}
}
仅出于调试目的,我在第一个代码片段 currentQueryString = "";
下添加了 GC.Collect()
,这完全解决了问题(在 Visual Studio 诊断工具和任务管理器中都观察到了),并且我我试图理解为什么会这样,以及我如何才能最好地解决这个问题,因为我的目标是 运行 这是一个无服务器应用程序,它将分配有限的内存。
仅仅增加内存使用量并不表示内存泄漏,垃圾收集器会运行根据自己的规则,例如当内存不足时。如果插入 GC.Collect
可以解决问题,则可能从一开始就没有泄漏。
只要存在潜在的内存问题,我都会建议使用 memory profiler。这应该允许您随意触发 GC,收集所有已分配对象的快照,并比较它们以查看某种对象计数是否在稳步增加。
也就是说,我建议将 query = new StringBuilder();
更改为 query.Clear()
。当您已经有可用的缓冲区时,无需重新分配一堆内存。
您或许可以通过尽可能使用 Span<char>
/Memory<char>
而不是字符串来进一步降低分配率。这应该让您在更大的缓冲区中引用特定的字符序列,而无需进行任何复制或分配。这是 Span<>
的主要原因,因为在进行大量 xml/json 反序列化和 html 处理时,复制字符串的效率有点低。
补充一下来自 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
}
如果您在纸上画出某些执行周期的内存分配情况:
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)
内存分配逻辑每次都会寻找下一个足够大的可用空闲块来容纳请求的大小。如果它找不到这么大的区域,它将从 OS 中请求一个新块。如果无法获取,它将调用 GC,这将真正释放所有标记为空闲的块,如果仍然没有足够大的块,它将重新排列占用的块以合并空闲块。 GC 不会在分配大于 88kb(我记得)的大对象堆上合并块。这些 GC 步骤正在影响您的性能。
因此,如果在步骤 1-108 期间发生的分配大小有增长模式(sb 分配是一个潜在的嫌疑人),那么您将看到内存使用量不断增长(因为消耗内存比上面的 GC 步骤快得多) .如果这种增长导致移动到大对象堆,那么您可能会在某个时候遇到 OutOfMemoryException。如果您对这种情况进行转储,您可能会看到数 GB 的空闲进程内存,但仍然收到 OOM! (这发生在我身上)
这只是技术上的解释,其他的解决方案都是正确的。 始终尝试重用已分配的内存,尤其是当您在循环中执行某些操作时,尤其是当这些循环具有未定义的重复计数时:
- 使用 ReadLineAsync retval 进行“GO”检查而不是新的 currentQueryString 分配
- 不要指望编译器优化你的循环中的 $"..." 字符串。
- 为最长的预期内容预分配足够大的 StringBuilder
- 重用 StringBuilder(清除它)而不是实例化新的