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 テキストです
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 と読みます。そして他のスレッドへ渡されることはありません。中央ディレクトリは、ローカルヘッダーも含め、どのワーカーが始まるよりも前に完全にパースされるため、名前でエントリを引き当てるのは、並行が始まる時点で、読み取り専用のハッシュ引き当てです。引き当ては大文字小文字を区別せず、.. セグメントを含む名前を拒否します
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 のスクラッチバッファで排水し、最も外側のストリームを解放することが、その下のインフレータとサブストリームを解放します
同じ規則は、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 を読めます