HotXLSはTXLSFileMonitorでディスク上のワークブックを監視し、別のプロセスがファイルに上書き保存するとOnFileChangedを発生させます。そのハンドラの中からMonitor.Active := Falseを設定すると、以前は永遠にハングしていました。ワーカースレッドが、まさに自分がディスパッチしたコールバックの完了を待ってブロックしたままだったからです。修正は、スレッドオブジェクトに、現在実行中のコールバックの識別情報を持たせることです
自分のハンドラ内でのファイルモニタ停止がなぜハングするのか
非アクティブ化の呼び出しとコールバックが2つの異なるスレッドで走り、それぞれが相手の完了を待っているからです。TXLSFileMonitorはSynchronizeEventがTrueに設定された状態で出荷され、これはポーリングスレッドがあなたのハンドラを自分で呼ばないことを意味します。TThread.Synchronizeを呼び、メソッドをメインスレッドに渡し、メインスレッドがそれを実行して完了を通知するまでブロックします。グリッドを再読み込みするハンドラやフォームを再描画するハンドラにとって、これはまさに欲しいものです。そして同時に、一見明白な非アクティブ化呼び出しを罠に変えるものでもあります
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
// ここはTThread.Synchronizeのおかげでメインスレッド上
FBook.LoadFromFile(FMonitor.FileName);
RefreshGrid;
// ユーザーは1回の再読み込みだけを望んだので、監視をやめる
FMonitor.Active := False; // かつては決して戻らなかった
end;
2つのスタックを追跡すれば循環は明白です。メインスレッドはSetActive(False)の中にいます。これはワーカーを終了させ、オブジェクトを解放する前にスレッドが本当に終了したことを確認するためWorker.WaitForを呼びます。一方ワーカースレッドはTThread.Synchronizeの中に停車し、SetActiveを呼んでいるまさにそのハンドラからメインスレッドが戻るのを待っています。どちらも動けません。アプリケーションはクラッシュせず、例外も上げず、単に再描画をやめます。これはバグが持ち得る最悪の診断シグネチャです
同じデッドロックが2つ目の顔をまとう
同期をオフにしても問題は避けられません。移動するだけです。SynchronizeEventをFalseに設定すると、メッセージループのないサービスやコンソールツールでは賢明な選択ですが、TXLSFileMonitorはポーリングスレッド上でハンドラを直接呼び出します。あなたのハンドラは今ワーカー上で走るため、その中からのMonitor.Active := False呼び出しは、ワーカースレッド自身の上でWorker.WaitForに到達します。スレッドは自分の終了を待てないため、外部の何かがプロセスを殺すまでブロックします。両方の亜種は同じバグです。SetActive(False)は、常にコールバックの外のどこかから呼ばれると仮定していました。その仮定は閉じるボタンやフォームのデストラクタでは成立し、人々が最初に手を伸ばす唯一の呼び出しサイトで崩れます。だからこそ長く生き残ったのです。スレッドを所有し、そこからユーザーコードをディスパッチするどんなコンポーネントも同じ危険を継承し、解決策はどの場合も同じです。ティアダウン経路は、自分が、まさに参加しようとしているスレッドがまだ保持しているコールバックの内側から実行されているのかを判別できなければなりません
ワーカースレッドにコールバック識別情報を持たせる
HotXLSは、どのOSスレッドが現在ハンドラを実行しているかを、スレッドオブジェクト自身に記録することで解決します。TXLSFileMonitorThread.FireEventは、ハンドラを呼び出す直前の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の非アクティブ化分岐はその後、意図的な順序で3つのことを行います。まずFThreadをクリアしてモニタからスレッドを切り離します。そうすればGetActiveはその瞬間からFalseを報告し、同じハンドラ内の再入的なActive := Falseは、スレッドを2回ティアダウンしようとせずに即座に終了します。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を設定して戻り、ハンドラが終了し、Synchronize呼び出しが完了し、ExecuteがTerminatedを観測し、コールバックスタックが完全に巻き戻った後にRTLがスレッドオブジェクトを解放するのに任せます。識別情報が一致しないとき、つまり閉じるボタン、デストラクタ、バックグラウンドジョブでは、従来のWaitFor+Freeが依然として正しい選択です。Active := Falseが戻った後にコールバックが発火できないという確固たる保証を与えるからです。2つの終了経路と、それらを選ぶ1つのフィールド。1つの帰結を明確に述べる価値があります。自己解放経路では、ワーカーはハンドラより長生きします。FireEventの戻り経路の何も、モニタオブジェクトに再び触れてはいけません。ハンドラがそれを解放している可能性があるからです。だからFireEventはOwnerとHandlerを最初にローカルへキャッシュし、finally節では自分のフィールドにしか触れません。ハンドラ呼び出しの後の迷い込んだFOwner.Somethingは、修正されたデッドロックをuse-after-freeに変えます。それは厳密に悪い結果です
FireEventはなぜ何よりも先にTerminatedを検査しなければならないのか
WaitForは受動的な待機ではないからです。メインスレッド上で、TThread.WaitForのRTL実装はブロック中も同期キューを汲み出し続けます。Synchronizeに停車したスレッドが、シャットダウンに対してデッドロックするのではなく完了して終了できるように、まさにそのためです。これは機能であり、閉じるボタンからActive := Falseを呼ぶ数マイクロ秒前にキューに入ったコールバックが、モニタがすでにFThread参照を落とした後のティアダウン中にディスパッチされ得ることを意味します。FireEventの先頭のガードが、そのディスパッチをノーオペレーションにします。それがなければ、監視対象ファイルに保存が着地している間にメインフォームを閉じるアプリケーションは、半分破棄された状態に対して再読み込みハンドラを走らせ、結果のアクセス違反はシャットダウン順序の問題にはまったく見えません。自分のコンポーネントがSynchronizeで作業をディスパッチするなら、規則は一般化します。すべての同期メソッドは最初の文としてTerminated検査を必要とします。スレッドが消えているかもしれないからではなく、キューが停止の決定より長生きするからです
CSVインポート経路にある同じ形のバグ
モニタ修正を生んだ監査は、テキストインポータに兄弟の欠陥を見つけました。根本の間違いが同一なので、並べて読む価値があります。設定の一部が、あたかもスクラッチ状態であるかのように消費されるのです。TXLSCSVLoaderは、エクスポートされたレポートで実際のヘッダの上に載るバナー行のためのSkipRowsプロパティを公開します。パーサはそれらの行を処理している間、カウントダウンを必要とします。そして当初の実装は、プロパティのバッキングフィールドそのものをカウントダウンしました。1つのファイルをインポートするとSkipRowsは静かにゼロになりました。同じローダーを2つ目のファイルに再利用すると、バナー行がグリッドに載りました
Loader := TXLSCSVLoader.Create;
try
Loader.SkipRows := 2; // ヘッダの上の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がワークブックを並行リーダとライタからどう守るかも貫くテーマです
この修正が与えるものと与えないもの
保証について正確でありましょう。スレッド所有権の変更が安全にするのは、以前は致命的だったちょうど1つのパターンだけです。どちらのディスパッチモードでも、自分のOnFileChangedハンドラの中からTXLSFileMonitorを非アクティブ化または解放することです。これは一般的な並行性設計ではなく、コンポーネントを任意のスレッドから駆動しても安全にすることもありません
- プロパティの書き込みは依然としてスレッドセーフではありません。
FileName、IntervalMs、SynchronizeEventは1つのスレッド、通常はモニタを作成したスレッドから設定してください IntervalMsは次のアクティブ化から適用され、ワーカーは短いスライスでスリープするため、長いポーリング周期でもTerminateが迅速に尊重されます- 検出は最終書き込み時刻とサイズによるため、両方を保つ変更は見えません。保存途中で消えるファイルは発火せず、新しいコンテンツが揃ったところでイベントが届きます
SynchronizeEventをFalseに設定すると、あなたのハンドラはワーカースレッドで走り、バックグラウンドスレッドからVCLコントロールに触れることに関するすべての規則が、その中で行うあらゆることに適用されます- ハンドラからのワークブック再読み込みは依然として独自の規律を必要とします。特に、置き換えられるブックへの参照を他のコードが保持している場合です
実際には、再読み込みハンドラこそ最も注意に値する部分であって、モニタではありません。変更イベントは通常、書き込みプロセスがまだ落ち着いていない間に届くため、ファイルを即座に開く読み込みは、部分的にコミットされた保存に出会うことがあります。短い遅延の後に1回再試行する方が、最初のスタンプを信じるより堅牢です。監視対象ファイルを書いているのが自分のコードなら、HotXLSがすべての保存をクラッシュセーフにする方法で説明されるステージングされた一時ファイルとアトミックリネームは、モニタにも中間サイズの流れではなく、きれいな単一遷移イベントを与えます。複数のウォッチャーが同じ再読み込みルーチンに給餌するなら、並行ZIP展開を巡る読み取りゲートの直列化の懸念が次に読むべきものです。より広い教訓は特定のコンポーネントを生き延びます。スレッドを所有し、ユーザーコードへコールバックするどんなクラスも、自分のコールバックフレームを認識できるティアダウン経路を必要とします。ユーザーが停止と言うのに、ハンドラほど自然な場所はないからです。コールバックスレッドidの記録は1つのDWORDで済み、説明不能なハングを普通のオブジェクトライフタイムの問題に変えます。ファイル監視、アトミックセーブ、テキストインポートはすべて、DelphiとC++Builder向けのHotXLS Delphi Componentに、どれも有効化に追加設定なしで同梱されます