技術記事

同時ZIPインフレートとHotXLSのリードゲート

2つのスレッドが、.xlsx パッケージの上で1つの TFileStream を共有しています。スレッド A が Position を sheet1.xml の最初の圧縮バイトへ設定し、その少し後、スレッド B がそれを sheet2.xml の最初のバイトへ設定すると、A の Read は、他方のメンバーに属するバッファを返します。A のインフレータは今や、他人の deflate ストリームの中ほどをデコードしています。zlib はたいてい、いくつかブロック進んだところで Z_DATA_ERROR で止まり、万が一出力を生んでも、そのメンバーの CRC32 は合いません。ここで問題なのは deflate ではありません。競合は完全にストリームの位置カーソルに住んでおり、シークとリードのペアを1つのロックで包めば、多くのワーカーが同じパッケージのメンバーを同時にインフレートできます

それを求める理由はメモリです。ワークブックがディスク上で 80 MB、そのワークシート XML がインフレート時に5倍から10倍に膨らむとしましょう。パースの前に各ワークシートをバッファへ抽出するオープン経路は、組み立て中のワークブックの上に、それらのインフレート済みバイトを支払います。そしてピークは、単一のセルが存在する前にやってきます。この記事は、抽出ステップを取り除くパッケージレベルの同時実行を扱います。その上のアロケータの天井は並列XLSXパースとメモリマネージャの記事が、ワークブックをまったく組み立てない読み切り API はストリーミングダイレクトリーダーのウォークスルーが扱います

ステージング設計と、メモリに保持するもの

XLSX のオープンを並列化する慎重なやり方は、3フェーズのパイプラインで、中央のフェーズだけをワーカーで走らせるものです。フェーズ A は呼び出しスレッドでシートリストを歩き、各ワークシートを生成し、リレーションシップパートを読み、インフレート済みのワークシート XML まるごとをプライベートな TMemoryStream へコピーします。フェーズ B は XML パースをスレッドプールへ広げます。フェーズ C は、やはり呼び出しスレッドで、アーカイブへ戻り、小さな衛星パートを処理します。コメント、スレッド化コメント、描画、チャート、テーブルです

この形への議論は、一見滴水不通に聞こえます。ZIP アーカイブとそのインフレート状態はスレッドセーフではないため、アーカイブへの接触をすべて1つのスレッドに留め、ロックを丸ごと省く、と。その請求書は、8枚の忙しいシートを持つワークブックが、8つ分の完全にインフレートされたワークシートバッファを同じ瞬間に握る、というものです。そしてそれらのバッファは、オープン経路のどこにある一時オブジェクトよりも大きい。上で仮定した 80 MB のパッケージなら、パーサが一度読むためだけに存在する、数百メガバイトの XML テキストです

Delphi のステージング式 XLSX オープン経路の図。フェーズ A がすべてのインフレート済みワークシート XML をメモリへコピーしてから、フェーズ B がシートを並列にパースする
フェーズ A は各インフレート済みワークシートを1つのスレッド上でプライベートなメモリストリームへコピーするため、8枚の忙しいシートを持つワークブックはピーク時に8つの大きなバッファを握ります

HotXLS はフェーズ A と C を保ちつつ、コピーを落とします。フェーズ B の各ワーカーは、共有パッケージの上に自分専用のエントリストリームを開き、それを XML リーダーへまっすぐ渡すため、インフレート済みのワークシートは1つの塊として存在しません。これが機能するのは、"スレッドセーフではない" という論法が、実は2つの異なるものについての話だったと分かるからです

2つのスレッドは1つのZIPストリームからインフレートできるか

はい。各スレッドが自分のインフレータを所有し、共有バイトソースがリードの時点で直列化されていれば、です。慎重な議論は、2種類の状態を1つの文へ折り畳んでいました。インフレート状態は本当に共有できません。zlib の z_stream は、RFC 1951 が定義する 32 KB のスライディングウィンドウ、現在のハフマンテーブル、1つの圧縮メンバー内のビット位置を運んでおり、同じ1つへ2つのスレッドがバイトを押し込めばゴミが生まれます。バイトソースは別の話です。ファイルストリームの保護する価値のある可変状態は、位置そのもの、ただ1つです

ZIP コンテナがこの分離を合法にします。PKWARE の APPNOTE(セクション 4.3)は、パッケージを独立したメンバーとして敷きます。各メンバーは固有のローカルファイルヘッダーを持ち、固有のオフセットに固有の圧縮データを置き、中央ディレクトリに固有の CRC32 とサイズを記録します。ソリッドな 7z ブロックのような、メンバーにまたがる辞書はありません。したがってエントリ N は、エントリ M の1バイトも読まずにインフレートできます。各ワーカーに、自分のバイト範囲の上のプライベートなインフレータを与えれば、残る衝突はシークだけです。HotXLS では、その衝突を lxZipArchive.pas の TZipReadGate が処理します。クラス全体が1画面に収まります

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TXLSCriticalSection;   // クリティカルセッションの lxPlatform ラッパー
  public
    constructor Create(ABaseStream: TStream);
    destructor Destroy; override;
    function ReadAt(AOffset: Int64; var Buffer; Count: Longint): Longint;
  end;

function TZipReadGate.ReadAt(AOffset: Int64; var Buffer;
  Count: Longint): Longint;
begin
  if Count <= 0 then
  begin
    Result := 0;
    Exit;
  end;
  XLSEnterCriticalSection(FLock);
  try
    FBaseStream.Position := AOffset;
    Result := FBaseStream.Read(Buffer, Count);
  finally
    XLSLeaveCriticalSection(FLock);
  end;
end;

リードゲートがロックするものと、外に置かれるもの

TZipReadGate.ReadAt は、1つの分割不能な操作、つまり共有ストリームの位置決めとそこからの読み出しを守ります。TZipArchive.OpenArchive は、中央ディレクトリのパースの後、ファイル名と TStream の両オーバーロードでゲートを一度構築し、アーカイブの解放がゲートを解放します。書き込み用に作られたアーカイブはゲートを得ません。同時に読む者がいないからです

その他のすべては、すでにプライベートか、すでに凍結済みのため、ロックの外にとどまります。TZipArchive.OpenFile が返す各エントリストリームは、自分のメンバー内の自分の位置を保持し、ReadAt を呼ぶその瞬間にだけ、それを絶対的なパッケージオフセットへ変換します。その周りを包むインフレータは、エントリごとにウィンドウサイズ -15 で生成されます。zlib はこれを、zlib ヘッダーのない素の deflate と読みます。そして他のスレッドへ渡されることはありません。中央ディレクトリは、ローカルヘッダーも含め、どのワーカーが始まるよりも前に完全にパースされるため、名前でエントリを引き当てるのは、並行が始まる時点で、読み取り専用のハッシュ引き当てです。引き当ては大文字小文字を区別せず、.. セグメントを含む名前を拒否します

Delphi の HotXLS リードゲートのアーキテクチャ図。並列のワークシートワーカーがプライベートなバイト範囲をインフレートするあいだ、1つのクリティカルセッションが、共有ストリームのシークとリードだけを直列化する
各ワーカーは自分のサブストリーム位置と z_stream を所有するため、共有状態はパッケージカーソルだけで、TZipReadGate が、1回の位置指定リードのあいだロックを握ります

TXLSXWorkbook.Open(Stream) を自分のストリームで呼ぶとき、1つの帰結が効きます。ゲートはそのストリームを包み、そのロックは、ライブラリのワーカー同士を調整します。あなたのコードについては何も知りません。Open が走っているあいだに、アプリケーションの別のスレッドが同じ TStream を読んだり再位置決めしたりすると、冒頭の段落のカーソル競合が戻ってきます。ストリームを渡したら、Open が返るまで放っておいてください

競合のもとで1つのロックはいくらかかるか

"アーカイブへのグローバルロック" という言葉が示唆するほどではありません。1回の獲得がどれだけの仕事を覆うか次第です。インフレータは入力を 64 KB の圧縮バイトの塊で補充するため、ゲートを通る1回の旅が、64 KB の deflate データをワーカーへ運び、ワーカーはその後、何も握らずにそれをデコードし、パースします。先に仮定した5倍から10倍の膨張では、その補充はおおよそ 320 から 640 KB のワークシートマークアップになります。ロック自体が覆うのは、たいていオペレーティングシステムのファイルキャッシュから供給される、1回の位置指定リードです

比率は3つの場所で逆転します。すべて入力かマシンについての話です。圧縮なしで保存されたメンバーは、読み出し呼び出しごとにゲートを通りますが、獲得と獲得のあいだにインフレートの仕事がありません。ほとんどのライターはワークシートパートを deflate しますが、すべてを store するツールが作ったパッケージは、より強く直列化されます。遅いストレージやネットワークストレージ上の冷たいファイルは、クリティカルセッションを広げます。中のリードが本物の転送になるからです。そしてワーカーが一握りを超えたところで、ゲートはどのみち最初の壁ではありません。ワークシートのパースは大量にアロケートし、Delphi のメモリマネージャは、リードゲートが飽和するずっと前に、スレッドにまたがるアロケーションを直列化します

最後の点がスレッド数を決めます。TXLSXWorkbook.ParallelParseThreads の既定は 0 で、論理プロセッサごとに1ワーカーを起動します。ただしシート数より多くは決してなりません。4から8の論理プロセッサを持つラップトップでは、その既定で足ります。何十枚ものシートを持つワークブックを開く32スレッドのビルドサーバーでは、4のような明示的な小さな上限を設定してください。余分なワーカーはたいていアロケータで行列を作り、それぞれが自分のインフレータとバッファを握るだけです。自分のファイルに合う数を見つけるには、プロパティを 1、2、4、8 に設定して1つの代表的なワークブックをループで開き、経過時間を、タスクマネージャーや Process Explorer のピークワーキングセットと比べてください

エントリを終わりまで読むことがCRCチェックを発火させる

アーカイブが返すすべてのエントリストリームは自分を検査しますが、それは最後に限られます。バイトが通るにつれて走る CRC32 を累積し、位置が中央ディレクトリに記録された非圧縮サイズへ届くと、両値を比較し、もう1バイトを探ります。失敗はリテラルなメッセージ付きの例外として現れます。'ZIP entry uncompressed size mismatch'、'ZIP entry CRC32 mismatch'、'ZIP entry exceeds its declared size'、そして早く尽きたメンバーに対する 'ZIP entry ended before its declared size' です

XML リーダーは閉じる </worksheet> タグで止まります。その後のもの、たいていは末尾の改行だけですが、読まれずに残り、位置は宣言されたサイズへ決して届かず、比較は決して走りません。ステージング設計はこのチェックをただで得ていました。エントリをバッファへコピーすることは、すべてのバイトを読むことだからです。エントリストリームを直接読むパーサーはそうではありません。したがって HotXLS の各ワーカーは、ストリームを解放する前に、パーサが残したものを 32 KB のスクラッチバッファで排水し、最も外側のストリームを解放することが、その下のインフレータとサブストリームを解放します

Delphi の HotXLS ワーカーストリームチェーンの図。共有パッケージから、リードゲートを経て、検証付きストリームへ。サイズと CRC32 のチェックを発火させる排水ループ付き
排水ループは位置を宣言されたエントリサイズまで押し進めるため、サイズと CRC32 の比較が走れます。XML リーダーが読まずに残す破損を捕捉するのです

同じ規則は、lxZipArchive の TZipArchive を自分のコードが使うとき、たとえばワークブックローダーへ渡す前に入ってきたパッケージを健全性検査するときにも当てはまります。ルート要素を嗅ぐために各パートの最初の数 KB だけを読む関数は、完全性について何も証明しません。終わりまで読んでください

uses
  Classes, SysUtils, lxZipArchive;

// パッケージのパートを宣言された終わりまで読み、サイズと CRC32 の
// チェックを走らせる。PartIsIntact は自作のヘルパーで、ライブラリの一部ではない
function PartIsIntact(Zip: TZipArchive; const PartName: WideString;
  out Problem: string): Boolean;
var
  Stream: TStream;
  Buffer: array [0..32767] of Byte;
begin
  Result := False;
  Problem := '';
  Stream := Zip.OpenFile(PartName);   // パートが存在しないときは nil
  if Stream = nil then
  begin
    Problem := 'part not found';
    Exit;
  end;
  try
    try
      while Stream.Read(Buffer, SizeOf(Buffer)) > 0 do
        ;
      Result := True;
    except
      on E: Exception do
        Problem := E.Message;          // 例: 'ZIP entry CRC32 mismatch'
    end;
  finally
    Stream.Free;
  end;
end;

var
  Zip: TZipArchive;
  Problem: string;
begin
  Zip := TZipArchive.Create;
  try
    Zip.OpenArchive('quarterly-consolidation.xlsx');
    if not PartIsIntact(Zip, 'xl/worksheets/sheet1.xml', Problem) then
      Writeln('sheet1.xml: ', Problem);
  finally
    Zip.Free;
  end;
end;

あのコードの2つ目の罠は巻き戻しです。エントリストリームは前進専用です。Size の読み出しは働きます。宣言されたサイズは前もって知られているからです。しかし覗き見の後の Position := 0 は 'ZIP entry stream is forward-only' を上げます。シークを強硬に要求するコンポーネントには、OpenFile でエントリをもう一度開いてください。安価で、オフセットゼロの新しいストリームをくれます。読み込み用に開かれた1つのアーカイブに対し、複数のスレッドがそれぞれ OpenFile を呼べます。ワークブックローダーがやっているのがまさにそれだからです。ただし、各自のストリームが生きているあいだ、誰もアーカイブを解放しないことが条件です

依然として直列に走るものと、それを切るスイッチ

フェーズ A は、抽出を抜いて、まだ走ります。呼び出しスレッドですべてのワークシートを生成し、リレーションシップを読むのは、ワーカーが始まってからすべての共有マップが不変でいられる理由です。共有文字列テーブルとセル書式のテーブルは、フェーズ B が始まる前に完成し、そのあいだ書かれることはありません。フェーズ C は、その後もコメント、描画、チャート、テーブルのためにシートを順に歩きますが、パッケージにパートのないシートは飛ばします

uses
  SysUtils, lxHandleX;

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;        // 既定。False はシートを1枚ずつパースする
    Wb.ParallelParseThreads := 4;    // 0 = 論理プロセッサごとに1ワーカー
    Wb.SelectedSheets.Add('Q3 Detail');  // 任意: このシートだけセルをパースする
    if Wb.Open('quarterly-consolidation.xlsx') <> 1 then
      raise Exception.Create('Workbook open failed');
    // どちらの経路がパースしても、ロードされるワークブックは同一
  finally
    Wb.Free;
  end;
end;

ParallelParse を False にして Open を走らせると、同じシートごとの仕事がスレッド数 1 で走り、プールは呼び出しスレッド上の素のループへ退化します。ワークシートが1枚のワークブックは、かかわらずその経路を通ります。オープンの問題を診るときは、このスイッチを思い出してください。1行でスレッドを絵から消し、両方の経路が同じパースコードを走るため、返ってくるワークブックは設定に依存しません

エラーも直列の形を保ちます。ワーカーが上げると、それ以上のシートは発送されず、すべてのワーカーが合流し、最も小さいシートインデックスの例外が呼び出しスレッドで再送出されるため、壊れた sheet3.xml は、やはり Open からの1つの例外として現れます。RecoveryMode が True に設定されていると、パースに失敗したワークシートは代わりにスキップされ、ワークブックの診断に警告として記録されます。SelectedSheets の外に置かれたシートは、エントリストリームをそもそも開きません。MetadataOnly は並列フェーズを丸ごと飛ばします。オープン経路のより広い調整は、Delphiにおける大型ワークブックのパフォーマンスガイドで扱っています

ここで見せたリードゲート、並列オープンフェーズ、エントリレベルの ZIP アクセスは、Delphi と C++Builder 向けの HotXLS Delphi Excel コンポーネントの一部です。フルソース付きで出荷されるため、製品ページで述べた TXLSXWorkbook のオープンプロパティと並べて、lxZipArchive.pas を読めます