应用程序在退出时在SysUtils->DoneMonitorSupport中挂起

16

我正在编写一个非常线程密集的应用程序,在退出时出现了挂起的情况。

我已经追踪到系统单元,并找到了程序进入无限循环的位置。它在SysUtils行19868 -> DoneMonitorSupport -> CleanEventList中:

repeat until InterlockedCompareExchange(EventCache[I].Lock, 1, 0) = 0;

我在网上搜索了解决方案,并找到了几份QC报告:

不幸的是,这些似乎与我的情况无关,因为我既不使用TThreadList,也不使用TMonitor

我非常确定所有线程都已经完成并被销毁,因为它们都继承自一个保持创建/销毁计数的基本线程。

有人遇到过类似的行为吗?你知道发现根本原因的任何策略吗?


@CosminPrund - 是的,即使我从未明确地使用它,TMonitor也会被频繁调用。 - norgepaul
5
TMonitor实在是太糟糕了,不好意思。不禁要问它是如何通过质量保证的。我有一个60行的演示控制台应用程序,在退出时会挂起,如果你查看代码,就会发现我甚至没有做任何奇怪的事情:我基本上只是按照显而易见的方法使用它。 - Cosmin Prund
2
在 Delphi 2010 和 Delphi XE2 中,我的代码挂在与 OP 的代码挂在的地方完全相同。在 Delphi XE3 中,似乎代码已经被重新编写了,但是错误还没有被修复。我没有 XE3 可以自己看看。我的代码非常简单,这是质量保证应该用来测试的众多事项之一。 - Cosmin Prund
2
http://qc.embarcadero.com/wc/qcmain.aspx?d=111795 - Cosmin Prund
我最近遇到了这个问题。在我的情况下,一个组件拥有一个线程列表,而我没有释放该组件。最终,这导致了相同的问题,因为TThreadList在其锁定中内部使用TMonitor。正如Cosmin所说,不释放对象的惩罚绝对不应该那么高。这也不是最容易追踪的事情。 - Graymatter
显示剩余20条评论
4个回答

15

我一直在研究TMonitor锁的实现方式,最终发现了一个有趣的事情。为了增加戏剧效果,我先告诉你锁是如何工作的。

当你对任何一个TObject调用TMonitor函数时,会创建一个TMonitor记录的新实例,并将该实例分配给对象本身内部的MonitorFld。这个分配是以线程安全的方式进行的,使用InterlockedCompareExchangePointer。由于这个技巧,TObject仅包含一个指针大小的数据来支持TMonitor,它并不包含完整的TMonitor结构。而这是一件好事。

这个TMonitor结构包含许多记录。我们先从FLockCount: Integer字段开始。当第一个线程在任何对象上使用TMonitor.Enter()时,这个组合锁计数器字段的值将为零。再次使用InterlockedCompareExchange方法获取锁并启动计数器。对于调用线程没有锁定和上下文切换,因为这全部都是在进程中完成的。

当第二个线程尝试TMonitor.Enter()相同的对象时,它的第一次尝试将失败。当这种情况发生时,Delphi遵循两种策略:

  • 如果开发人员使用TMonitor.SetSpinCount()设置了一定数量的“自旋”,那么Delphi将执行一个繁忙等待循环,旋转给定的次数。对于小锁来说这非常好,因为它允许在不进行上下文切换的情况下获取锁。
  • 如果自旋计数到期(或没有自旋计数,默认自旋计数为零),TMonitor.Enter()会在TMonitor.GetEvent()返回的事件上启动等待。换句话说,它不会忙等待浪费CPU周期。请记住TMonitor.GetEvent(),因为这非常重要。

假设我们有一个线程获得了锁,另一个线程尝试获取锁,但现在正在等待由TMonitor.GetEvent()返回的事件。当第一个线程调用TMonitor.Exit()时,它会注意到(通过FLockCount字段)至少有一个其他线程正在阻塞。因此,它立即脉冲通常应该分配的事件(调用TMonitor.GetEvent())。但是,由于调用TMonitor.Exit()和调用TMonitor.Enter()的两个线程实际上可能同时调用TMonitor.GetEvent(),因此,在TMonitor.GetEvent()内部还有几个技巧来确保只分配一个事件,而不考虑操作的顺序。

为了更好玩地探究一下,我们现在将深入了解 TMonitor.GetEvent() 的工作方式。这个东西位于 System 单元中(你知道的,那个我们不能重新编译以进行玩耍的单元),但是事实证明它委托通过 System.MonitorSupport 指针为 Event 分配职责给另一个单元。指针指向一个类型为 TMonitorSupport 的记录,该记录声明了 5 个函数指针:

  • NewSyncObject - 为同步目的分配一个新的 Event
  • FreeSyncObject - 释放为同步目的分配的 Event
  • NewWaitObject - 为等待操作分配一个新的 Event
  • FreeWaitObject - 释放分配的 Wait event
  • WaitAndOrSignalObject - 等待或发信号。

此外,事实证明,NewXYZ 函数返回的对象可以是任何东西,因为它们仅用于调用 WaitXYZ 以及对应的 FreeXyzObject 调用。 SysUtils 中实现这些函数的方式旨在为这些锁提供最少量的锁定和上下文切换;因此,这些对象本身(由 NewSyncObjectNewWaitObject 返回)直接不是由 CreateEvent() 返回的 Events,而是指向 SyncEventCacheArray 中记录的指针。更进一步,实际的 Windows Events 是直到需要时才会创建的。因此,SyncEventCacheArray 中的记录包含几个记录:

  • TSyncEventItem.Lock - 这告诉 Delphi 锁当前是否被使用
  • TSyncEventItem.Event - 如果需要同步等待,则其中保存将用于同步的实际 Event。

当应用程序终止时,SysUtils.DoneMonitorSupport 遍历 SyncEventCacheArray 中的所有记录,并等待 Lock 变为零,即等待锁停止被任何东西使用。理论上,只要该锁不为零,至少有一个线程可能正在使用该锁,因此明智的做法是等待,以避免引起访问冲突错误。现在我们终于得到了我们当前的问题:为什么应用程序可能会在 SysUtils.DoneMonitorSupport 中挂起,即使它的所有线程都正确终止了?

为什么应用程序可能会在 SysUtils.DoneMonitorSupport 中挂起,即使它的所有线程都正确终止了?

由于使用NewSyncObjectNewWaitObject分配的至少一个事件没有使用相应的FreeSyncObjectFreeWaitObject进行释放,因此我们回到TMonitor.GetEvent()程序。它分配的事件保存在与用于TMonitor.Enter()的对象对应的TMonitor记录中。该记录的指针仅保留在该对象实例数据中,并在整个应用程序生命周期内一直保留在那里。通过搜索字段名FLockEvent,我们可以在System.pas文件中找到它:

procedure TMonitor.Destroy;
begin
  if (MonitorSupport <> nil) and (FLockEvent <> nil) then
    MonitorSupport.FreeSyncObject(FLockEvent);
  Dispose(@Self);
end;

在这里调用了记录析构函数:procedure TObject.CleanupInstance

换句话说,只有在用于同步的对象被释放时,最终的同步事件才会被释放!

问题的答案:

应用程序挂起是因为至少有一个被用于TMonitor.Enter()的对象没有被释放。

可能的解决方案:

不幸的是,我不喜欢这样做。这不对,我的意思是未释放的小对象应该只导致小内存泄漏,而不是导致应用程序挂起!这对于服务应用程序尤其糟糕,因为服务可能永远挂起,无法完全关闭,也无法响应任何请求。

对于Delphi团队的解决方案?无论如何,他们都不应该在SysUtils单元的终止代码中挂起。他们应该忽略Lock并转到关闭事件句柄。在这个阶段(SysUtils单元的终止),如果仍然在一些线程中运行代码,则处于真正糟糕的状态,因为大多数单位已经终止,它不是在设计运行环境中运行的。

对于Delphi用户?我们可以用自己的版本替换MonitorSupport,一个不会在终止时间进行这些广泛测试的版本。


1
Cosmin,你的回答非常好。至少现在我知道我正在寻找什么了。我想无法确定哪个对象没有从DoneMonitorSupport中释放。 - norgepaul
1
哇,真是个惊喜——Delphi线程支持中的一个问题。似乎有一种普遍的(不仅仅是Delphi的)愿望,即在应用程序关闭时尝试显式地清理绝对所有东西的复杂且错误的关机代码。这在非平凡的多线程应用程序上很难做到正确,并且在过去的约30年中,我一直在呼喊“别做这件事——让操作系统来清理”。同步、等待、OnTerminate等似乎会共同导致关闭问题,现在加入TMonitor了,<叹气>。客户不在意干净的valgrind转储文件,他们只希望应用程序在被告知后能够关闭。 - Martin James
2
换句话说 - 有人在 Embarcadero 开枪打了前 Java 开发者。 - Martin James
1
@MartinJames 如果你的模块在进程终止之前卸载了怎么办?例如运行时链接的DLL。 - David Heffernan
你可以钩取DoneMonitorSupport并将其内容复制到钩取函数中。这比重新编译要容易得多。我的库中有一个钩取单元,它叫做Cromis.Detours。它非常容易使用。请查看:http://www.cromis.net/blog/downloads/ - Runner
显示剩余4条评论

1
我已经通过以下方式解决了这个错误:
System.SysUtilsInterlockedAPIs.incEncodingData.inc 复制到我的应用程序目录,并修改 System.SysUtils 中的以下代码:
  procedure CleanEventList(var EventCache: array of TSyncEventItem);
  var
    I: Integer;
  begin
    for I := Low(EventCache) to High(EventCache) do
    begin
      if InterlockedCompareExchange(EventCache[I].Lock, 1, 0) = 0 then
         DeleteSyncWaitObj(EventCache[I].Event);
      //repeat until InterlockedCompareExchange(EventCache[I].Lock, 1, 0) = 0;
      //DeleteSyncWaitObj(EventCache[I].Event);
    end;
  end;

我还在 System.SysUtils 的顶部添加了此检查,以提醒自己在更改 Delphi 版本时更新 System.SysUtils 文件:

{$IFNDEF VER230}
!!!!!!!!!!!!!!!!
You need to update this unit to fix the bug at line 19868
See https://dev59.com/zGYq5IYBdhLWcg3wxTSq
!!!!!!!!!!!!!!!!
{$ENDIF}

在这些更改之后,我的应用程序可以正确关闭。

注意:我尝试添加LU RD建议的"ReportMemoryLeaksOnShutdown",但在关闭时,我的应用程序进入了竞争状态,弹出了大量的运行时错误对话框。当我尝试使用EurekaLog的内存泄漏功能时,类似的情况也会发生。


1
为了找到一个更长期的解决方案,您可能想要在NewSyncObj和FreeSyncObj的版本中添加一些日志记录。您可以使用它来确定哪些对象被锁定而没有释放。 - Graymatter

1
在Delphi XE5中,Embarcadero通过在CleanEventList的repeat until循环中添加(Now - Start > 1 / MSecsPerDay) or,解决了这个问题,以便在1毫秒后放弃。然后,它将删除事件,无论Lock是否为0。

1

我使用 Cosmin 提供的示例可以重现您的问题。在所有线程完成后,我只需释放 SyncObj 就可以解决该问题。

由于我无法访问您的代码,因此无法说得更多,但可能是某个被 TMonitor 使用的对象实例未被释放。


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