我一直在研究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
中实现这些函数的方式旨在为这些锁提供最少量的锁定和上下文切换;因此,这些对象本身(由 NewSyncObject
和 NewWaitObject
返回)直接不是由 CreateEvent()
返回的 Events,而是指向 SyncEventCacheArray
中记录的指针。更进一步,实际的 Windows Events 是直到需要时才会创建的。因此,SyncEventCacheArray
中的记录包含几个记录:
TSyncEventItem.Lock
- 这告诉 Delphi 锁当前是否被使用
TSyncEventItem.Event
- 如果需要同步等待,则其中保存将用于同步的实际 Event。
当应用程序终止时,SysUtils.DoneMonitorSupport
遍历 SyncEventCacheArray
中的所有记录,并等待 Lock 变为零,即等待锁停止被任何东西使用。理论上,只要该锁不为零,至少有一个线程可能正在使用该锁,因此明智的做法是等待,以避免引起访问冲突错误。现在我们终于得到了我们当前的问题:为什么应用程序可能会在 SysUtils.DoneMonitorSupport
中挂起,即使它的所有线程都正确终止了?
为什么应用程序可能会在 SysUtils.DoneMonitorSupport 中挂起,即使它的所有线程都正确终止了?
由于使用NewSyncObject
或NewWaitObject
分配的至少一个事件没有使用相应的FreeSyncObject
或FreeWaitObject
进行释放,因此我们回到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
,一个不会在终止时间进行这些广泛测试的版本。
TMonitor
实在是太糟糕了,不好意思。不禁要问它是如何通过质量保证的。我有一个60行的演示控制台应用程序,在退出时会挂起,如果你查看代码,就会发现我甚至没有做任何奇怪的事情:我基本上只是按照显而易见的方法使用它。 - Cosmin Prund