技术文章

Delphi 中 HotXLS 文件监视器回调死锁

HotXLS 用 TXLSFileMonitor 监视磁盘上的工作簿,另一个进程覆盖保存文件时触发 OnFileChanged。在那个 handler 里设置 Monitor.Active := False 过去会永远挂起,因为工作线程还堵在等待它刚刚派发的回调完成上。修复是让线程对象携带它当前正在运行的回调的身份

为什么在自己的 handler 里停掉文件监视器会挂起?

因为停用调用和回调跑在两个不同的线程上,而每一方都在等另一方结束。TXLSFileMonitor 出厂时 SynchronizeEvent 设为 True,这意味着轮询线程不直接调用你的 handler。它调用 TThread.Synchronize,把方法交给主线程,然后阻塞到主线程跑完它并发出完成信号。对重载网格或重绘窗体的 handler 来说这正是你想要的,也正是它让那个显而易见的停用调用变成陷阱

HotXLS 文件监视器死锁的两张脸:SynchronizeEvent 为 True 时主线程阻塞在 Worker.WaitFor,而工作线程阻塞在 TThread.Synchronize;为 False 时 handler 在轮询线程上跑,SetActive False 于是等待线程自身,让 OnFileChanged 无法返回
SynchronizeEvent 开着时主线程在 Worker.WaitFor 里等,工作线程在 Synchronize 里等;关掉时 handler 在轮询线程上跑并等待自己的退出——一个闭合的环,两种派发模式
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // 借助 TThread.Synchronize,我们此时位于主线程
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // 用户只要求重载一次,所以停止监视
  FMonitor.Active := False;   // 过去从不返回
end;

跟踪两个堆栈,环路就显而易见了。主线程在 SetActive(False) 里,它终止工作线程,然后调用 Worker.WaitFor 以确保线程真的退出后才释放对象。与此同时工作线程停在 TThread.Synchronize 里,等着主线程从那个正在调用 SetActive 的 handler 返回。双方都动不了。应用不崩溃、不抛异常,只是停止重绘——这是一个 bug 能有的最糟糕的诊断特征

同一个死锁还有第二张脸

关掉同步并不能避开问题,只是把它换个地方。当 SynchronizeEvent 设为 False——对没有消息循环的服务或控制台工具是合理的选择——TXLSFileMonitor 在轮询线程上直接调用 handler。你的 handler 现在跑在工作线程上,所以从里面调用 Monitor.Active := False 会到达工作线程自身的 Worker.WaitFor。线程不能等待自己的退出,于是它阻塞到外部有人杀掉进程。两个变体是同一个 bug:SetActive(False) 假设它总是从回调外部的某个地方被调用。这个假设对 Close 按钮或窗体析构成立,却在人们第一个伸手去碰的调用点上失败,这就是它存活这么久的原因。任何既拥有线程又从它派发用户代码的组件都继承了同样的隐患,每种情况下的解法也一样——teardown 路径必须能分辨它是否正从回调内部运行,而那个回调正被它即将 join 的线程握着

让工作线程携带回调身份

HotXLS 的解法是在线程对象自身上记录当前正在执行 handler 的 OS 线程。TXLSFileMonitorThread.FireEvent 在调用 handler 之前立即用 GetCurrentThreadId 盖上 FCallbackThreadID,之后在 finally 块里清掉。这一个字段就足以让停用路径区分"从无关代码调用"和"从我自己的回调内部调用",而且两种派发模式下表现一致,因为在同步的情形下 FireEvent 里的 GetCurrentThreadId 报的已经是主线程

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // 停用期间,WaitFor 可能会派发排队的回调
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // 回调处理器可能已销毁 Owner,但本线程仍然存活
    FCallbackThreadID := 0;
  end;
end;

SetActive 的停用分支然后按刻意安排的顺序做三件事。它先清掉 FThread 把线程从监视器上解下来,于是从那一刻起 GetActive 报告 False,同一 handler 内重入的 Active := False 立即退出而不是试着拆两次线程。它调用 Terminate。只有到这时它才决定怎么回收线程对象

HotXLS 如何解决文件监视器 teardown:TXLSFileMonitorThread.FireEvent 在调用 handler 前用 GetCurrentThreadId 盖上 FCallbackThreadID,并在 finally 块里清掉,于是 SetActive 可以解下 FThread、调用 Terminate,在回调内部时选 FreeOnTerminate 自释放,否则用经典的 WaitFor 加 Free
FireEvent 在调用 handler 前盖上 FCallbackThreadID,在 finally 块里清掉;SetActive 随后在身份匹配时选 FreeOnTerminate,调用方无关时选经典的 WaitFor 加 Free
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // 从此刻起监视器处于非活动状态
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // 直接与同步回调都必须先返回,工作线程才能
  // 才能退出;回调返回后它会自行释放
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

身份匹配时 HotXLS 从不等待。它设置 FreeOnTerminate := True 然后返回,让 handler 跑完、Synchronize 调用完成、Execute 观察到 Terminated,RTL 在回调栈完全展开后释放线程对象。身份不匹配时——Close 按钮、析构函数、后台任务——经典的 WaitFor 加 Free 仍然是正确做法,因为它给你一个硬保证:Active := False 返回后不会有任何后续回调再触发。两条退出路径,一个字段来选。有一个后果值得说透:自释放路径上 worker 比 handler 活得更久。FireEvent 的返回路径里任何东西都不能再碰监视器对象,因为 handler 有权已经释放了它。这就是 FireEvent 一开始就把 Owner 和 Handler 缓存进局部变量、在 finally 子句里只碰自己字段的原因。handler 调用后的一个游离 FOwner.Something 会把修好的死锁变成 use-after-free,那是严格更糟的结果

为什么 FireEvent 必须先检查 Terminated 再做任何事?

因为 WaitFor 不是被动等待。在主线程上,TThread.WaitFor 的 RTL 实现在阻塞期间持续排空同步队列,正是为了让停在 Synchronize 里的线程能完成并退出,而不是和关停互相死锁。这是个特性,也意味着你在 Close 按钮里调用 Active := False 前几微秒排队的回调,仍然可能在 teardown 期间被派发——在监视器已经丢掉它的 FThread 引用之后。FireEvent 顶部的守卫让那次派发变成 no-op。没有它,一个在保存落盘到被监视文件时关闭主窗体的应用,会在半处置状态上跑重载 handler,产生的访问冲突看起来和关停顺序问题毫无相似之处。如果你自己的组件通过 Synchronize 派发工作,规则是通用的:每个同步方法都需要把 Terminated 检查当作第一条语句,不是因为线程可能没了,而是因为队列比停止的决定活得久

CSV 导入路径里同形状的 bug

产出监视器修复的那次审计在文本导入器里翻出一个同源缺陷,值得并排读一读,因为底层错误完全一样:一段配置被当作临时状态消费了。TXLSCSVLoader 暴露一个 SkipRows 属性,用于跳过导出报表里真实表头上方的横幅行。解析器处理那些行时需要倒计数,而最初的实现直接在属性后备字段上倒数。导入一个文件,SkipRows 就悄悄变成零;同一 loader 复用于第二个文件时,横幅行就落进网格里了

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // 表头上方的两行横幅
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // 这里 SkipRows 仍是 2:FSkipRemaining 在每次解析时重置
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS 现在把 FSkipRows 当作声明好的设置,在每次解析开始时重置一个独立的 FSkipRemaining 计数器,分隔和定宽两条路径都如此。通用规则应用起来很便宜而且自偿:如果循环会递减它,它就不是属性后备字段。同一批还收紧了 XlsCsvTryParseISODateTime,它过去愿意把一个可解析的前缀当作完整日期。现在它校验整个字段——长度、分隔符位置和每一位数字——所以像订单号这种只是开头长得像日期的值会保持文本,而不是变成错误的 TDateTime。导入时静默的类型强制转换,下游调试成本远比拒绝一个字段贵,这个主题也贯穿HotXLS 如何守卫工作簿免受并发读写

HotXLS 审计背后的 CSV loader 缺陷:在属性后备字段上倒数 SkipRows,一次解析后就悄悄把它清零,于是第二个文件把横幅行泄漏进网格;修复后的模型把 FSkipRows 保持为声明设置,在每次 ParseFile 调用开始时重置独立的 FSkipRemaining 计数器
在属性后备字段上倒数会把配置变成临时状态:第一次解析清零 SkipRows,而修复后的模型保持 FSkipRows 声明不变,在每次解析开始时重置 FSkipRemaining

这次修复给了你什么、没给你什么

对保证要精确。线程所有权改动让恰好一种此前致命的模式变得安全:在 TXLSFileMonitor 自己的 OnFileChanged handler 里停用或释放它,两种派发模式皆可。它不是通用的并发设计,也不会让组件可以从任意线程安全驱动

  • 属性写入仍然不是线程安全的。从单个线程设置 FileName、IntervalMs 和 SynchronizeEvent,通常是创建监视器的那个线程
  • IntervalMs 从下次激活开始生效,worker 以短切片睡眠,这样即使轮询周期很长 Terminate 也能被及时响应
  • 检测依据是最后写入时间加大小,所以同时保住两者的变化是不可见的;保存中途消失的文件不触发,事件在新内容就位后到达
  • SynchronizeEvent 设为 False 时你的 handler 跑在工作线程上,所有关于从后台线程碰 VCL 控件的规则都适用于你在里面做的一切
  • 从 handler 里重载工作簿仍然需要它自己的纪律,尤其是当其他代码持有被替换工作簿的引用时

实践中重载 handler 才是最需要小心的部分,而不是监视器。变化事件通常在写入进程还在收尾时就到了,所以立即打开文件的加载可能撞上只写了一半的保存;短暂延迟后重试一次比信任第一个时间戳更稳。如果写被监视文件的是你自己的代码,HotXLS 如何让每次保存崩溃安全里描述的分阶段临时文件和原子改名,也给了监视器一个干净的单次转换事件可观察,而不是一串中间大小。如果多个监视者喂同一个重载例程,并发 ZIP 解压周围的读门里的串行化问题就是下一个该读的。更广的教训不随具体组件消失。任何拥有线程并回调进用户代码的类,都需要一个能认出自己回调帧的 teardown 路径,因为 handler 是世界上用户最自然地说"停"的地方。记录回调线程 id 只花一个 DWORD,却能把无法解释的挂起变成普通的对象生命周期问题。文件监视、原子保存和文本导入都随 HotXLS Delphi Component 交付,适用于 Delphi 和 C++Builder,启用它们不需要任何额外配置