Delphi字符串拼接时,是否会使用隐藏的临时变量来保留对字符串的引用?

10

我正在尝试理解Delphi服务器应用程序中的内存问题:最初我怀疑是明显的泄漏,但现在认为我们看到的内存挂起时间比应该更长,因为编译器在使用+动态拼接字符串时使用了隐藏的临时内存,导致令人痛苦的自由空间内存碎片化。

背景:

这是一套32位Windows服务器应用程序,Delphi版本非常古老,我认为它是7,但肯定是pre-Unicode,并使用Nexus 3内存管理器,在那里我编写了一个DLL来挂钩所有的分配/释放调用(和GB级别的内存跟踪)。

我有应用程序源代码,但没有编译器;我不是这个应用程序的开发人员(甚至不是Delphi开发人员),但创建了广泛的自定义工具来监视、跟踪和分析内存。我一直在IDA Pro反汇编器中逐步解析.EXE文件。

一些示例代码:

我已经试图将其缩减到最少的情况; 这段代码不打算编译:

procedure TaskThread.RunWorkLoop
begin
    while not Terminated do
    begin

      tsk := WaitForWorkToDo();  // this could sit for minutes at a time

      SetThreadName('Working on ' + tsk.Name);

      tsk.Run(); // THIS COULD TAKE A LONG TIME

      SetThreadName('Idle');
   end
end;
SetThreadName()接受一个const字符串参数并将其存储,以便系统的其他部分知道该线程正在做什么。
我的反汇编代码显示编译器已经分配了一个隐藏的本地临时变量来接收“正在处理”和任务名称部分的连接结果,并将其传递给SetThreadName,在那里它也保留了对字符串的引用。
当任务正在运行时-可能是20分钟-我认为该字符串有两个引用。一个在SetThreadName中,另一个在隐藏的临时变量中。
这一切都很好。
然后,当任务完成并且线程名称设置为'Idle'时,SetThreadName()释放原始字符串并将文字Idle赋值给它。
但是:我认为隐藏的本地临时变量仍然保留对该字符串的引用,引用计数为1,因此它将占用空间,直到过程返回或下一个循环覆盖该隐藏的本地临时变量,释放旧值。
在此期间,它不可访问程序,无法被显式释放,也没有任何有用的目的,但仍在消耗内存。
对于大多数过程而言,这并不重要,因为它们开始和结束的时间相对接近,所以一切都会同时释放,但在循环服务器应用程序中,这些可能会存在更久。这导致了内存碎片化。

情况更糟

实际应用程序更多地是这样的:
SetThreadName(tsk.Name + '-' + FormatDateTime('mm/dd/yy hh:nn:ss', Now));
在这种情况下,有两个隐藏的临时变量:一个用于存储FormatDateTime的结果,另一个用于整个拼接结果,实际上相当于运行以下代码:

在这种情况下,有两个隐藏的临时变量:一个用于FormatDateTime函数的结果,另一个用于整个字符串拼接的结果,等效地运行如下代码:

tmp1: String;
tmp2: String;
...
  tmp1 := FormatDateTime('...');
  tmp2 := tsk.Name + '-' + tmp1;
  SetThreadName(tmp2);

我确定我看到的是FormatDateTime字符串结果持续留在内存中,即使任务完成后,我曾经看到它是一个单独的 ~30字节分配,坐落在1兆字节内存区域的中间,并被空闲空间所包围;Nexus3MM使用VirtualAlloc来分配更大的操作系统级别的块。

那个单独的30字节字符串最终会被释放,要么在下一次循环时,要么当过程退出时,因此我确定它不是一个泄漏,但我希望当我们完成它时,那个单独的 30字节分配能从孤立的1兆字节区域中消失,以便整个区域可以释放给操作系统。

但是如果它停留得足够长,内存管理器将从中分配其他内容,而这个内存空洞变得更加永久。

我们有非常详细的繁忙/空闲内存映射,并确信这种碎片化正在摧毁我们(这绝对不是唯一的原因)。

我的问题:

1)我的理解正确吗?

2)如果是,唯一的解决方法是通过使用显式临时变量来省略隐藏的临时变量,例如:

tmp1: String;
tmp2: String;
...
  tmp1 := FormatDateTime('...');
  tmp2 := tsk.Name + '-' + tmp1;
  SetThreadName(tmp2);
  tmp1 := '';  // release the date/time string
  tmp2 := '';  // release the overall thread name string

我相当有信心必须使用FormatDateTime中间结果来完成这个任务(我已经看到了具体的内容),但整个串联还不确定。

这感觉很不对劲。

编辑:几周后的更新。我们重写了中心循环以使用显式临时变量,这实际上在一些关键服务器进程的内存碎片化方面产生了明显(尽管不是很大)的差异。我们仍然有其他事情要处理,但对我来说,这是值得一试的方法。


2
答案 1)是的 2)是的 - David Heffernan
1个回答

5

根据我的经验,它确实像你描述的那样工作。我不确定这是合同约定还是实现方式问题。我猜测,随着最近内联变量声明的添加,现在可能略有不同。但在unicode之前的Delphi中,我认为它的工作原理完全符合您的描述。

所有使用托管类型或包含托管类型的记录的变量(隐式或显式)的例程将在例程中生成一个隐式的try/finally块,其中finally部分清除引用。你的代码实际上是这样做的:

procedure TaskThread.RunWorkLoop
var
  sImplicit : string;
begin
  sImplicit := '';
  try
    while not Terminated do
    begin
      tsk := WaitForWorkToDo();  // this could sit for minutes at a time

      sImplicit := 'Working on ' + tsk.Name;

      SetThreadName(sImplicit);

      tsk.Run(); // THIS COULD TAKE A LONG TIME

      SetThreadName('Idle');
    end;
  finally
    sImplicit := '';
  end;
end;

在你的情况下,由于你从未退出使用隐式变量的例程,它会一直存在于内存中。

至于解决方案,我认为你提出的方法可以行得通。但你也可以将代码移动到另一个方法(或本地过程)中。

procedure TaskThread.RunWorkLoop
  procedure JustKeepWorking;
  begin
    tsk := WaitForWorkToDo();  // this could sit for minutes at a time
    SetThreadName('Working on ' + tsk.Name);
    tsk.Run(); // THIS COULD TAKE A LONG TIME
    SetThreadName('Idle');
  end;
begin
  while not Terminated do
  begin
    JustKeepWorking;
  end
end;

此外,您可能需要查看这个问题以获取更多的见解。


这非常有帮助,谢谢。实际的代码要复杂得多,不太适合分解成本地过程,而我提出的修复方案必须尽可能简单、可靠和低风险。并且加1个链接;我希望我2周前就能找到它。 - Steve Friedl
是的...代码从来都不是那么简单!你也可以在子程序中仅使用SetThreadName,它不需要成为整个过程。如果你只需要tsk作为参数,那也可以。但我猜,再次强调,这并不简单!;) - Ken Bourassa
我的目标并不是完全修复这个特定过程(虽然它是问题的核心),而是解开其中所发生的奥秘;现在我知道了情况,我们就可以将这个单调/丑陋/简单的修复方法应用到所有其他出现这种情况的地方。 - Steve Friedl
@SteveFriedl:不要设置线程名称(尽管在调试会话中可能有用),最好是将日志记录添加到您的应用程序中,这将为您提供更好的了解它正在做什么…… - whosrdaddy
1
@whosrdaddy - 设置线程名称只是一个简单的例子;在执行其他具有相同问题的操作的更大过程的其他部分也存在问题;所讨论的过程有六个隐藏的临时变量。 - Steve Friedl

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