【问题标题】:Strange EOutOfMemory exception using TStringList使用 TStringList 的奇怪 EOutOfMemory 异常
【发布时间】:2018-04-05 07:52:46
【问题描述】:

我有一个系统,它加载一些压缩到“.log”文件中的文本文件,然后使用多个线程解析成信息类,每个线程处理不同的文件并将解析的对象添加到列表中。 该文件是使用 TStringList 加载的,因为它是我测试过的最快的方法。

文本文件的数量是可变的,但通常我必须在一次入侵中处理 5 到 8 个文件,范围从 50Mb 到 120Mb。

我的问题:用户可以根据需要多次加载 .log 文件,并且在尝试使用 TStringList.LoadFromFile 时,在其中一些进程之后,我收到了 EOutOfMemory 异常。当然,任何使用过 StringList 的人首先想到的是在处理大文本文件时不应该使用它,但是这个异常是随机发生的,并且在进程已经成功完成至少一次之后(对象在新的解析开始之前被销毁,因此除了一些轻微的泄漏之外,内存被正确检索)

我尝试过使用 Textile 和 TStreamReader,但它不如 TStringList 快,并且该过程的持续时间是此功能最关心的问题。

我使用的是 10.1 Berlin,解析过程是一个简单的迭代,通过不同长度的线条列表和基于线条信息的对象构造。

基本上,我的问题是,这是什么原因造成的,我该如何解决。我可能会使用其他方式来加载文件并读取其内容,但它必须与 TStringList 方法一样快(或更好)。

加载线程执行代码:

TThreadFactory= class(TThread)
  protected
     // Class that holds the list of Commands already parsed, is owned outside of the thread
    _logFile: TLogFile;
    _criticalSection: TCriticalSection;
    _error: string;

    procedure Execute; override;
    destructor Destroy; override;

  public
    constructor Create(AFile: TLogFile; ASection: TCriticalSection); overload;

    property Error: string read _error;

  end;

implementation


{ TThreadFactory}

    constructor TThreadFactory.Create(AFile: TLogFile; ASection: TCriticalSection);
    begin
      inherited Create(True);
      _logFile := AFile;

      _criticalSection := ASection;
    end;


    procedure TThreadFactory.Execute;
        var
          tmpLogFile: TStringList;
          tmpConvertedList: TList<TLogCommand>;
          tmpCommand: TLogCommand;
          tmpLine: string;
          i: Integer;
        begin
          try
            try
              tmpConvertedList:= TList<TLogCommand>.Create;       

                if (_path <> '') and not(Terminated) then
                begin

                  try
                    logFile:= TStringList.Create;
                    logFile.LoadFromFile(tmpCaminho);

                    for tmpLine in logFile do
                    begin
                      if Terminated then
                        Break;

                      if (tmpLine <> '') then
                      begin
                        // the logic here was simplified that's just that 
                        tmpConvertedList.Add(TLogCommand.Create(tmpLine)); 
                      end;
                    end;
                  finally
                    logFile.Free;
                  end;

                end;


              _cricticalSection.Acquire;

              _logFile.AddCommands(tmpConvertedList);
            finally
              _cricticalSection.Release;

              FreeAndNil(tmpConvertedList);    
            end;
          Except
            on e: Exception do
              _error := e.Message;
          end;
        end;

    end.     

补充:感谢您的所有反馈。我将解决一些已经讨论过但我在最初的问题中没有提及的问题。

  • .log 文件内部有多个 .txt 文件实例,但也可以有多个 .log 文件,每个文件代表一天的日志记录或用户选择的时间段,因为解压需要很多时间每次找到 .txt 时都会启动一个线程,这样我就可以立即开始解析,这缩短了用户的明显等待时间

  • ReportMemoryLeaksOnShutdown 和其他方法(如 TStreamReader)不显示“轻微泄漏”,从而避免了此问题

  • 命令列表由 TLogFile 保存。任何时候都只有一个此类的实例,并且在用户想要加载 .log 文件时被销毁。 所有线程都向同一个对象添加命令,这就是临界区的原因。

  • 无法详细说明解析过程,因为它会泄露一些敏感信息,但这是从字符串和 TCommand 中收集的简单信息

  • 从一开始我就知道碎片,但我从未找到具体证据证明 TStringList 仅通过多次加载才会导致碎片,如果可以确认这一点,我将非常高兴

感谢您的关注。我最终使用了一个外部库,它能够以与TStringList 相同的速度读取行和加载文件,而无需将整个文件加载到内存中

https://github.com/d-mozulyov/CachedTexts/tree/master/lib

【问题讨论】:

  • 解决方案很明显。不要将整个文件读入内存。
  • 我怀疑文件本身不是问题,而是该文件做了什么......特别是“将 tmpConvertedList 插入命令列表”但是没有足够的信息可以确定。上面的代码没有提供正确的minimal reproducible example
  • 另外,虽然有人声称对象被正确销毁,但我们无法确认......并且“除了一些轻微的泄漏”会导致内存碎片并阻止对大量数据的进一步处理。
  • TStreamReader 性能不是很好,IIRC。使用更好的行阅读器实现。令人失望的是,您在帖子中没有提到任何这些。闻起来很像碎片。但是,如果您的上级要求您继续使用TSrtingList,我认为您可能需要花一些时间处理您的简历。
  • 抛开你的问题,我建议你回顾一下 try-finally 的正确使用...

标签: delphi text-files delphi-10.1-berlin tstringlist


【解决方案1】:
  1. TStringList 本身就是慢速类。它有很多花里胡哨的额外特性和功能,使它陷入困境。更快的容器将是TList&lt;String&gt; 或普通的旧动态array of string。见System.IOUTils.TFile.ReadAllLines函数。

  2. 了解堆内存碎片,例如http://en.wikipedia.org/Heap_fragmentation

即使没有内存泄漏,它也可能发生并破坏您的应用程序。 但既然你说有很多小泄漏 - 这就是最有可能发生的事情。通过避免将整个文件读入内存并使用较小的块进行操作,您可以或多或少地延迟崩溃。但降级仍会继续,甚至更慢,最终你的程序会再次崩溃。

  1. 有很多临时类库,通过缓冲、预取等功能逐个读取大文件。 http://github.com/d-mozulyov/CachedTexts 是此类针对文本的库之一,还有其他类型的库。

PS。一般说明。

我认为您的团队应该重新考虑您对多线程的需求。 坦率地说,我没有看到。 您正在从 HDD 加载文件,并且可能将处理和转换的文件写入相同的(最好是另一个)HDD。 这意味着,您的程序速度受磁盘速度的限制。而且这个速度远低于 CPU 和 RAM 的速度。 通过引入多线程,您似乎只会使您的程序更加复杂和脆弱。错误更难检测,众所周知的库可能会在 MT 模式下突然出现异常等。而且您可能不会获得性能提升,因为瓶颈在于磁盘 I/O 速度。

如果您仍然想要多线程,那么也许可以查看 OmniThreading 库。它旨在简化开发“数据流”类型的 MT 应用程序。阅读教程和示例。

我绝对建议您消除所有那些“一些小漏洞”,并作为其中的一部分来修复所有编译警告。我知道,当您不是项目中唯一的程序员并且其他人不关心时,这很难。 仍然“轻微泄漏”意味着您的团队中没有人知道程序的实际行为或行为方式。多线程环境中的非确定性随机行为很容易产生大量随机的 Shroeden 错误,您永远无法重现和修复这些错误。

你的try-finally 模式真的坏了。 您在finally 块中清理的变量应该在try 块之前分配,而不是在其中!

o := TObject.Create;
try
  ....
finally
  o.Destroy;
end;

这是正确的方法:

  • 要么创建对象失败 - 则不会输入 try-block,也不会进入 finally-block。
  • 或者对象被成功创建 - 然后会进入 try-block 和 finally-block

所以,有时候,

o := nil;
try
  o := TObject.Create;
  ....
finally
  o.Free;
end;

这也是正确的。该变量设置为nil 紧接在进入try-block之前。如果对象创建失败,那么当 finally-blocks 调用 Free 方法时,变量已经被分配,并且 TObject.Free(但不是 TObject.Destroy)被设计为能够处理 nil 对象引用。其本身只是对第一个修改的嘈杂、过于冗长的修改,但它可以作为更多衍生品的基础。

当你不知道你是否会创建一个对象时,可以使用该模式。

o := nil;
try
  ...
  if SomeConditionCheck() 
     then o := TObject.Create;  // but maybe not
  ....
finally
  o.Free;
end;

或者当对象创建被延迟时,因为您需要为其创建计算一些数据,或者因为对象非常重(例如全局阻止访问某个文件),所以您努力使其生命周期尽可能短。

o := nil;
try
  ...some code that may raise errors
  o := TObject.Create; 
  ....
finally
  o.Free;
end;

该代码虽然询问为什么所说的“...一些代码”没有移到外面和 try 块之前。通常它可以而且应该是。一种相当罕见的模式。

在创建多个对象时使用该模式的另一个派生词;

o1 := nil;
o2 := nil;
o3 := nil;
try
  o2 := TObject.Create;
  o3 := TObject.Create;
  o1 := TObject.Create;
  ....
finally
  o3.Free;
  o2.Free;
  o1.Free;
end;

目标是,例如,如果 o3 对象创建失败,则 o1 将被释放并且 o2 未被创建,并且 finally-block 中的 Free 调用会知道它。

这是半正确的。假设破坏对象永远不会引发它自己的异常。通常这种假设是正确的,但并非总是如此。 无论如何,这种模式可以让您将几个 try-finally 块融合为一个,这使得源代码更短(更易于阅读和推理)并且执行速度更快一些。通常这也是相当安全的,但并非总是如此。

现在两种典型的模式误用:

o := TObject.Create;
..... some extra code here
try
  ....
finally
  o.Destroy;
end;

如果在对象创建和 try-block 之间的代码引发了一些错误 - 那么没有人可以释放该对象。你刚刚发生了内存泄漏。

当您阅读 Delphi 源代码时,您可能会看到类似的模式

with TObject.Create do
try
  ....some very short code
finally
  Destroy;
end;

由于反对使用with 构造的所有广泛热情,这种模式排除了在对象创建和尝试保护之间添加额外代码。典型的with 缺点 - 可能的命名空间冲突和无法将此匿名对象作为参数传递给其他函数 - 都包括在内。

还有一个不幸的修改:

o := nil;
..... some extra code here
..... that does never change o value
..... and our fortuneteller warrants never it would become
..... we know it for sure
try
  ....
  o := TObject.Create;
  ....
finally
  o.Free;
end;

这种模式在技术上是正确的,但在这方面相当脆弱。 您不会立即看到o := nil 行和try-block 之间的链接。 当您将来开发程序时,您可能很容易忘记它并引入错误:例如将try-block复制粘贴/移动到另一个函数中并忘记了nil-initializing。或者扩展中间代码并使其使用(从而更改)o 的值。有一种情况我有时会使用它,但它非常罕见并且有风险。

现在,

...some random code here that does not
...initialize o variable, so the o contains
...random memory garbage here
try
  o := TObject.Create;
  ....
finally
  o.Destroy; // or o.Free
end;

这是你写了很多,没有考虑 try-finally 是如何工作的以及它为什么被发明的原因。 问题很简单:当您输入 try-block 时,您的 o 变量是一个带有随机垃圾的容器。现在,当您尝试创建对象时,您可能会遇到一些错误。然后怎样呢?然后你进入 finally 块并调用(random-garbage).Free - 它应该做什么?它会做随机垃圾。

所以,重复以上所有内容。

  1. try-finally 用于保证对象释放或任何其他变量清理(关闭文件、关闭窗口等),因此:
  2. 用于跟踪该资源的变量(例如对象引用)在进入 try 块的入口处应该具有众所周知的值,它应该在 try 关键字之前分配(初始化)。如果您保护文件 - 然后在 try 之前立即打开它。如果您防止内存泄漏 - 在try 之前创建对象。等等。不要在 try 运算符之后进行我们的第一次初始化 - 在 try-block 中 - 为时已晚。
  3. 您最好将代码设计得尽可能简单(不言而喻),当您忘记今天留在脑海中的非显式隐藏假设并将跨越它们时,消除引入未来错误的可能性。见Who wrote this programing saying? "Always code as if the guy who ends up maintaining your code will be a violent psychopath who knows where you live."。这意味着,在块开始之前立即初始化(分配)由 try-block 保护的变量,就在 try 关键字的正上方。更好的是,在该分配之前插入一个空行。让你(或任何其他读者)意识到这个变量和这个尝试是相互依赖的,永远不应该分开。

【讨论】:

  • 非常好的答案,感谢您的时间和对我们应用程序的关注,正如我所说,泄漏已经消失了,因为一开始它们是由于TStringList 的一些滥用。另外,感谢try-finally 上的课程
  • @TioGuedes 我们在 try-finally 的某些地方也遇到了类似的问题,当我们迁移到 XE2 / XE7 和 64 位时,我们遇到了问题。由于本地对象引用不再神奇地使用nil 进行初始化。
  • local object references weren't magically initialized with nil anymore - 他们有过吗?只有全局引用被归零。还有自动引用类型(接口、动态数组、字符串)。但是普通的对象引用?我认为直到 LLVM,@nil
  • 当时我没有处理这个问题,但我们在团队中讨论过它。但是在阅读您的评论后很好奇,Arioch 'The,我可以在我们的错误跟踪系统中找到票证,并且可以在昨天调试时看到它发生。我无法制作 MCVE,它甚至不会在同一个项目中“无处不在”发生。我认为这是一些奇怪的事故,具体取决于寄存器/堆栈中的内容。但问题是代码的编写依赖于这种怪异的行为,这几乎是您的条件创建示例的一个坏例子,在try-block 之前缺少显式赋值。
  • @nil 我最近在使用未初始化的本地布尔变量取消该过程时发现并压制了一个 shroeden-bug。当其他一些 BPL 为 Release 编译时 - 变量为零(256 中的 1 次机会)并且错误没有表现出来。当我在内部部署测试版本时,调试编译 - 然后变量是其他一些垃圾,非零。该变量是注册表映射的,因此该错误取决于其他程序(使用的对象方法)在 CPU 中留下的垃圾。但从 asm 黑盒子外面看,它真的看起来像 boolan local var 在 Release 中被归零
猜你喜欢
  • 2011-10-02
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多