技術文章

HotXLS 檔案監控回呼在 Delphi 中的死結

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——對沒有訊息迴圈的服務或 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。然後才決定怎麼回收執行緒物件

HotXLS 如何解決檔案監控的拆解: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 釋放執行緒物件。身分不吻合時——關閉按鈕、解構式、背景工作——經典的 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 如何守護活頁簿、防並行讀寫

HotXLS 稽核背後的 CSV loader 缺陷:在屬性的支援欄位上倒數 SkipRows,一次剖析後就靜默歸零,所以第二個檔案把橫幅行漏進表格;修正後的模型把 FSkipRows 留作宣告的設定,並在每次 ParseFile 呼叫開始時重設獨立的 FSkipRemaining 計數器
在屬性的支援欄位上倒數,把設定變成暫存狀態:第一次剖析就把 SkipRows 歸零,修正後的模型則保留 FSkipRows 宣告、在每次剖析開始時重設 FSkipRemaining

這個修復給了你什麼、又沒給你什麼

把保證說精確。執行緒歸屬的變更只讓一個先前致命的模式變安全:在 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,全部都不需要額外設定即可啟用