HotXLS 用 TXLSFileMonitor 监视磁盘上的工作簿,另一个进程覆盖保存文件时触发 OnFileChanged。在那个 handler 里设置 Monitor.Active := False 过去会永远挂起,因为工作线程还堵在等待它刚刚派发的回调完成上。修复是让线程对象携带它当前正在运行的回调的身份
为什么在自己的 handler 里停掉文件监视器会挂起?
因为停用调用和回调跑在两个不同的线程上,而每一方都在等另一方结束。TXLSFileMonitor 出厂时 SynchronizeEvent 设为 True,这意味着轮询线程不直接调用你的 handler。它调用 TThread.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。只有到这时它才决定怎么回收线程对象
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 如何守卫工作簿免受并发读写
这次修复给了你什么、没给你什么
对保证要精确。线程所有权改动让恰好一种此前致命的模式变得安全:在 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,启用它们不需要任何额外配置