技術記事

HotXLSの読み取りリースと書き込みガード(Delphi)

バックグラウンドスレッドが4万行のレポートをエクスポートしている最中に、UIスレッドが1つのセルを設定しました。ディスクに落ちたファイルは、これまで存在したことのないどのブックとも一致しません。HotXLSはその種のバグをlxWorkbookView.pasで処理します。そこではIXLSWorkbookViewCoreがO(1)の読み取りリースとフェイルファストな書き込みガードを発行します。リースが開いている間、すべての変更エントリポイントは書き込む代わりに例外を投げます

スタックトレースなしで到着する障害

ブックの読み取りは決して1つのアトミック操作ではありません。レポートのウォークは数秒にわたって広がる数万回の個別セル読み取りであり、その2つの間に1回のSetValueが着地するだけで、ウォークの残りが見るものが変わります。クラシックエンジンはこれを具体的にします。TXLSCellRef.SetValueFSST.Removeを呼んで共有文字列エントリを落とし、FValueTypeをリセットし、数式キャッシュ状態を無効化できます。それらの構造をちょうど逆参照している最中のスレッドの下で。その場では何もクラッシュしません。手に入るのは合計が合わないレポート、あるいは今や別の場所を指す文字列インデックスを静かに読んだエクスポートです

HotXLSはこれをライタを待たせることで解決することを意図的にしません。リーダはブックを数秒間保持し得ますし、VCLアプリケーションではライタはしばしばUIコールバックかメインスレッドのイベントハンドラです。バックグラウンドのエクスポートが終わるまでそのスレッドを塞ぐのは、編集を失敗させるより悪い帰結です。だから調整コアは、1つのフィールドにも触れられる前に、開いているリースへの書き込みが試みられた瞬間にEXLSWorkbookWriteGuardUnavailableを投げ、キューに入れるか再試行するかユーザーに伝えるかを呼び出し側が決めます。衝突はフェイルファストであり、キューイングではありません

HotXLSの調整マトリクス。読み取りリースは自由に共存し、開いているリースへの書き込みはEXLSWorkbookWriteGuardUnavailableを投げ、書き込みトランザクション内で要求されたリースはEXLSWorkbookReadLeaseUnavailableを投げ、2つのライタスレッドは決して互いを排除しない
リーダは共存し、ライタはリースに対してフェイルファストです。しかしコアは1つのライタスレッドを別のライタスレッドから排除することは決してありません

ブックは2スレッドから読んでも安全か

安全です。ただし両リーダがリースを保持し、誰も書き込まないことが条件です。IXLSWorkbookViewCore.AcquireReadLeaseTCriticalSectionを取り、カウンタをインクリメントし、現在の世代をスナップショットし、IXLSWorkbookReadLeaseを返します。ブックが千セルでも百万セルでも定数時間です。任意の数のリースが共存し、任意の順序で解放でき、各リースは自分のインターフェース参照でコアを生かし続けます。リースが作成オブジェクトより長生きしても、ダングリングポインタではなく安全です。両エンジンが参加します。lxHandle.pasTXLSWorkbooklxHandleX.pasTXLSXWorkbookはそれぞれコンストラクタでコアを構築し、_AcquireReadLease_AcquireWriteGuardを露出します

同じくらい重要なのは、リースが読み取りパスに付け加えないものです。クリティカルセクションがカバーするのはリース取得、リース解放、書き込みトランザクション境界だけです。通常のセルごとの読み取りはロックにもモニタにもアトミックカウンタにも入りません。リースの保持は走査全体で取得1回と解放1回であり、セルごとではありません。並列XLSXパースとメモリアロケータの取り組みの背後にあるのと同じ設計の直感です。調整のコストは境界で払い、内側のループでは決して払わない。対称の規則も成り立ちます。WriteDepthがゼロでないときはいつでもAcquireReadLeaseEXLSWorkbookReadLeaseUnavailableを投げます。書き込みトランザクションの内部から、書き込みスレッド上でさえ、リースを開くことはできません

HotXLSは走査の境界で調整のコストを払う。クリティカルセクションがカバーするのはリース取得、解放、書き込みトランザクション境界のみ。書き込みガードはTXLSCellRef.SetValueの内部で取得されるため、その上のすべての利便APIが1回ゲートされる
取得1回と解放1回が5万セルの走査をカバーし、TXLSCellRef.SetValue内の1つのガードがその上のすべての公開書き込みパスをカバーします
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // 書き込みが飛行中なら EXLSWorkbookReadLeaseUnavailable を投げる
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // ここでリースがスコープを抜ける。参照カウントがゼロに落ち
  // ReleaseReadLease が走り、ライタが再び可能になる
end;

書き込みガードは実際にどこに座るか

最下層の可変レイヤーです。その上の利便APIではありません。_AcquireWriteGuardTXLSCellRef.SetValue自身の内部から呼ばれます。つまりそこへ合流するすべての公開パス——Range.Value、ワークシートテキスト代入、セルごとのコピー、貼り付け——は1回ゲートされ、未来のラッパが忘れるであろう検査を各ラッパが繰り返すことはありません。カバレッジは意図的に広いです。コアを導入したバッチの時点で、lxHandle.pasに55か所、lxHandleX.pasに37か所のガード取得があります

ゲートされる面はセル値とセル書式、TXLSWorkbook.Open、コピーと貼り付け、定義名(Add、名前変更、RefersToVisibleIsMacroCommentDelete)、ワークシートメタデータ(NameZoomVisibleStandardHeightFreezePanesProtectActivate)、ページ設定、ページブレーク、Calculateに及びます。配置こそすべてです。ガードは最初のフィールドが書かれる前に取得され、後から通知フックで検証されるのではないので、拒否された変更はモデルをバイト単位で無変更のままにします。回帰スイートはまさにそれを表明します。拒否された呼び出しのたびに、シート名、ズーム、可視性、標準高さ、マージン、向き、ページブレーク数を再読み込みします。ロードパスは1層下で同じ扱いを受けます。パッケージ形式では、ZIP読み取りゲートが並列inflateを調整する仕組みが対応します

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // 最初のフィールドに触れる前に取得する。決して後ではない
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // 完了した最外殻ガードだけが世代を前進させる
  WriteGuard.Complete;
end;

ネストした書き込みが世代を1回だけ前進させる理由

書き込みトランザクションは各ガードではなく、スレッド上の最外殻ガードによって定義されるからです。コアはスレッドid、深さ、完了フラグを保持するスレッドごとのライタ状態を持ちます。同じスレッド上の2番目のAcquireWriteGuardはその状態を見つけ、新しいトランザクションを作る代わりにDepthをインクリメントします。Depthがゼロへ戻り——最外殻ガードがCompleteと印された後で——はじめてFGenerationが前進します。だからCalculateOpenのような高レベル操作が下で10個のガード付きプリミティブを呼んでも、1つの変更として登録されます。内側のComplete呼び出しは記録されますが、それ自身でカウンタを動かしません。ガードは順不同で解放されても会計は壊れません

失敗の方向も同じく明示です。ガードがCompleteなしで解放された場合——インターフェース参照を巻き戻す例外のありふれた帰結——世代は前進しません。書き込みトランザクションは成功を主張しなかったからです。それが何を意味するかは冷静に理解してください。HotXLSは部分的な編集をロールバックしません。カウンタは成功したトランザクションが完了しなかったことを記録します。これはまさにキャッシュが必要とする信号ですが、モデルを以前の状態へ復元することは、参照カウントのガードにはできません。トランザクション途中の失敗が出荷できない形をブックに残し得るなら、メモリ上のオブジェクトを信用するのではなく、ソースファイルを保持して開き直してください

2つのHotXLS書き込みトランザクションのタイムラインの比較。1つのスレッド上のネストしたガードは深さを上げ、最外殻ガードが完了したときにだけ世代カウンタを前進させる。Completeなしでガードを巻き戻す例外は世代を変えず部分的な編集をその場に残す
深さはネストを追跡しますが、完了した最外殻トランザクションだけが世代を前進させます。中断されたものはカウンタも部分的な編集もちょうど元の位置に残します

世代カウンタが買ってくれるもの

走査なしの安価な陳腐化検出です。Generationは1から始まりラップアラウンド時に0を飛ばすUInt64なので、0はコアが決して発行しない値であり、信頼できる「未観測」の番兵として働きます。2つの不変条件がこれを有用にします。読み取りリースが存在する間は世代が動かないこと、そして成功した書き込みトランザクションごとにちょうど1回インクリメントされること。したがってIXLSWorkbookReadLease.Generationはリースの生涯を通じて一定のスナップショットであり、IXLSWorkbookWriteGuard.StartGenerationはトランザクションが開いたときのモデルの姿をライタに伝えます。グリッド、印刷プレビュー、派生インデックスは、行の差分を取る代わりに1つの整数を比較できます

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration は 0 から始まる。コアが決して発行しない値なので
  // 最初のパスは必ず再構築になる
end;

この調整が約束しないこと

3つの限界は率直に述べる価値があります。そう仮定することが機構の誤用の仕方だからです。第一に、書き込みガードはライタ間の相互排他ではありません。コアが排除するのはリーダ対ライタであり、2つの異なるスレッドが同時にそれぞれ書き込みガードを保持し、それぞれ独立に世代を前進させ得ます。この振る舞いを表明する回帰テストがあります。自分のライタスレッドを直列化するのは依然あなたの仕事です。第二に、ここにあるものはファイルロックでもプロセス間ミューテックスでもありません。1つのプロセス内のスレッドを1つのブックインスタンスに対して調整するものであり、同じ.xlsxを開く2つのプロセスは互いを何も知りません。第三に、保証は実際にリースを取る呼び出し側までしか届きません。リースなしの読み取りは依然ロックなしのホットパスを歩きます。速く、そして完全に無防備です。これは調整コアであって、トランザクションデータベースではありません

その範囲内で使えば、これは小さく正直なプリミティブです。9個の専用回帰テストが、複数リーダ、両方向の衝突、再入、順不同解放、中断されたトランザクション、スレッド間の読み書きと書き書きの競合をカバーし、Win32とWin64上で通る1,328テストのスイートに含まれています。クラッシュセーフな段階的テンポラリファイル保存と組み合わせれば、バックグラウンドのエクスポートは端から端まで推論できるものになります。読んでいる間は一貫し、書くときはアトミックです。読み取りリース、書き込みガード、世代カウンタは、DelphiとC++Builder向けHotXLS Delphi Componentのクラシックおよびパッケージ両エンジンの一部として出荷されます。有効化に設定は一切要りません