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——對沒有訊息迴圈的服務或 console 工具來說這是合理的選擇——時,TXLSFileMonitor 直接在輪詢執行緒上呼叫 handler。你的 handler 現在跑在工作執行緒上,所以從裡面呼叫 Monitor.Active := False,會在執行緒自己身上到達 Worker.WaitFor。執行緒不能等自己結束,於是就阻塞到有外部的東西把程序殺掉。兩種變體是同一個 bug:SetActive(False) 假定自己永遠是從回呼之外的某處被呼叫。這個假定對關閉按鈕或表單解構式成立,卻在大家第一個會去寫的那個呼叫點失敗——這就是它能活這麼久的原因。任何同時擁有執行緒、又從執行緒派發使用者程式碼的元件都繼承了同樣的危險,而解法每個案例都一樣——拆解路徑必須能分辨自己是不是從一條它即將 join 的執行緒仍握住的回呼裡被執行
讓工作執行緒帶著回呼身分
HotXLS 的解法是在執行緒物件自己身上記錄目前是哪條 OS 執行緒正在執行 handler。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 釋放執行緒物件。身分不吻合時——關閉按鈕、解構式、背景工作——經典的 WaitFor 加 Free 仍然正確,因為它給你一個硬保證:Active := False 回傳之後不可能再有回呼觸發。兩條出口路徑,一個欄位做選擇。有一個後果值得說清楚:在自我釋放路徑上,執行緒活得比 handler 久。FireEvent 的回傳路徑上不能有人再碰監控物件,因為 handler 有權利已經把它釋放掉。這就是 FireEvent 先把 Owner 與 Handler 快取進區域變數、finally 子句只碰自己欄位的原因。handler 呼叫之後要是多寫一個 FOwner.Something,就會把修好的死結變成 use-after-free——這是嚴格說來更糟的結局
為什麼 FireEvent 在動手前必須檢查 Terminated?
因為 WaitFor 不是被動的等待。在主執行緒上,TThread.WaitFor 的 RTL 實作在阻塞期間會持續清空同步化佇列,正是為了讓停在 Synchronize 的執行緒能完成並退出,而不是對關機流程死結。這是個特性,也意味著你從關閉按鈕呼叫 Active := False 前幾微秒才排入佇列的回呼,仍然可能在拆解期間被派發——而那時監控已經丟掉它的 FThread 參照了。FireEvent 開頭的守衛讓這種派發變成 no-op。沒有它的話,一個在儲存落在被監看檔案上時關閉主表單的應用程式,會拿半釋放狀態去跑重載 handler,造成的 access violation 看起來完全不像關機順序問題。如果你自己的元件經由 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從下次啟用開始生效,工作執行緒以短片段睡眠,所以即使輪詢週期很長,Terminate也會被迅速響應- 偵測依據是最後寫入時間加大小,所以兩者都不變的變更會看不見;儲存中途消失的檔案不會觸發,事件在新內容就位後才會到
SynchronizeEvent設成False時你的 handler 跑在工作執行緒上,所有「從背景執行緒碰 VCL 控制項」的規則都適用於你在裡面做的任何事- 從 handler 裡重載活頁簿仍然需要自己的紀律,尤其是其他程式碼還握著即將被取代的那本活頁簿的參照時
實際上最需要小心的是重載 handler,不是監控本身。變更事件通常在寫入程序還在收尾時就到達,所以立即開啟檔案的載入可能撞上只提交到一半的儲存;延遲一下再重試一次,比相信第一個時間戳更穩。如果你自己的程式碼就是寫入被監看檔案的那一方,HotXLS 讓每次儲存都當機安全的暫存檔與原子改名,也給監控一個乾淨的單一轉變事件可以觀察,而不是一串中間大小。如果多個 watcher 餵同一個重載常式,並行 ZIP 解壓縮的讀取閘裡的序列化問題就是下一份該讀的東西。更大的教訓超越這個特定元件。任何擁有執行緒、又回呼進使用者程式碼的類別,都需要一條能認出自己回呼框架的拆解路徑,因為 handler 是用戶說「停」時世界上最自然的地方。記錄回呼執行緒 id 只花一個 DWORD,卻能把一個無法解釋的卡住變成普通的物件生命週期問題。檔案監控、原子儲存與文字匯入都隨HotXLS Delphi Component提供,支援 Delphi 與 C++Builder,全部都不需要額外設定即可啟用