Artikel Teknis

Concurrent ZIP Inflate di Delphi: Read Gate HotXLS

HotXLS, pustaka komponen Excel native untuk Delphi dan C++Builder, meng-inflate beberapa worksheet XLSX sekaligus dari satu paket ZIP terbuka. Mekanismenya adalah TZipReadGate, kelas kecil di lxZipArchive.pas yang memegang stream paket plus satu critical section dan mengekspos tepat satu metode. Ia menyerialkan pasangan seek-dan-read. Segala sesuatu di atas pasangan itu berjalan konkuren

Masalah yang memaksa desain ini adalah masalah yang pernah ditemui setiap developer Delphi yang membuka workbook besar. xlsx 80 MB adalah 80 MB XML terdeflasi, dan bagian worksheet di dalamnya mengembang kira-kira lima hingga sepuluh kali. Jika jalur buka Anda mengekstrak setiap worksheet ke memory stream sebelum mem-parsingnya, Anda membayar byte yang sudah ter-inflate di atas workbook yang sedang Anda bangun, dan puncaknya tiba sebelum satu sel pun dibuat. Artikel ini membahas konkurensi level-paket yang menghilangkan langkah staging itu. Ceiling allocator yang duduk di atasnya dibahas di artikel tentang parallel XLSX parsing dan memory manager, dan API baca-sekali, tidak-pernah-mewujudkan dibahas di ulasan streaming direct reader

Kenapa jalur buka lama men-staging setiap worksheet di RAM

Pembukaan paralel asli di HotXLS adalah pipeline tiga fase, dan fase tengah adalah satu-satunya yang berjalan pada worker. Fase A menelusuri daftar sheet secara serial, membuat setiap worksheet, membaca bagian relationship-nya, dan menyalin seluruh XML worksheet yang sudah ter-inflate ke TMemoryStream privat. Fase B mengalirkan ParseWorksheetXml ke seluruh pool. Fase C kembali ke arsip pada thread pemanggil untuk bagian satelit kecil: komentar, threaded comment, gambar, chart, tabel. Bentuk itu dipilih karena alasan yang dinyatakan. Komentar header pada lxParallelParse.pas dulu berkata, dengan kata sebanyak itu, bahwa arsip zip dan state inflate-nya tidak thread-safe, dan catatan internalnya lebih jauh lagi: jangan repot-repot mengunci arsipnya, karena begitu state machine inflate diserialkan per entri, kunci itu tidak membeli apa pun. Fase A ada untuk menjaga setiap sentuhan arsip pada satu thread. Biayanya adalah workbook dengan delapan sheet sibuk menahan delapan buffer XML worksheet yang sepenuhnya ter-inflate di memori secara bersamaan, dan buffer-buffer itu adalah objek transien terbesar di seluruh jalur buka

Bisakah dua thread meng-inflate dari satu stream ZIP?

Ya, dan penilaian lama itu salah dengan cara yang spesifik dan bisa dilacak: ia melipat dua bagian state berbeda ke dalam satu kalimat. State inflate sungguh tidak bisa dibagi. z_stream zlib membawa sliding window, tabel Huffman dan posisi bit untuk satu anggota terkompresi, dan dua thread yang mendorong byte lewat yang sama menghasilkan sampah. Sumber byte yang mendasarinya adalah pertanyaan yang sama sekali berbeda, dan jawabannya di sana adalah file stream punya persis satu bagian state mutable bersama yang layak dilindungi, cursor posisinya

Kontainer ZIP membuat pemisahan ini sah. Setiap anggota dalam arsip ZIP dikompresi secara independen: local file header-nya sendiri, bit stream deflate-nya sendiri pada DataOffset-nya sendiri, CRC32 dan ukurannya sendiri di central directory. Tidak ada kamus bersama yang membentang antar anggota sebagaimana blok solid 7z, jadi entri N bisa di-inflate tanpa menyentuh entri M. Berikan setiap worker z_stream-nya sendiri atas rentang byte-nya sendiri dan satu-satunya yang mereka rebutkan adalah seek-nya. Tabrakan itulah yang dihilangkan TZipReadGate, dan seluruh kelasnya cukup pendek untuk dibaca dalam satu layar

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TRTLCriticalSection;
  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;
  EnterCriticalSection(FLock);
  try
    FBaseStream.Position := AOffset;
    Result := FBaseStream.Read(Buffer, Count);
  finally
    LeaveCriticalSection(FLock);
  end;
end;

Apa yang dilindungi TZipReadGate dan apa yang sengaja tidak

TZipReadGate.ReadAt menjaga satu operasi tak terbagi, memposisikan stream bersama dan membaca darinya, dan tidak ada lagi. TZipArchive.OpenArchive membangun gate itu atas FInputStream begitu central directory berhasil di-parse, dan TZipArchive.Close membebaskannya. Arsip yang dibuka untuk menulis tidak pernah mendapatkannya. Setiap pembacaan yang dilakukan worker pada paket itu karena itu mengalir lewat satu critical section yang dipegang selama durasi satu pembacaan buffered

Semua yang lain tetap di luar kunci karena sudah privat atau sudah immutable. TZipSubStream menyimpan FPosition-nya sendiri, jadi setiap worker melacak posisinya sendiri di entrinya sendiri. TZLibStream yang dibangun TZipEntry.GetStream atas sub-stream itu adalah per-entri, dibuat dengan windowBits -15 untuk raw deflate, dan tidak pernah dibagi. Central directory sepenuhnya di-parse sebelum worker mana pun mulai, termasuk setiap local header, jadi GetEntryByName adalah pencarian hash read-only pada saat konkurensi dimulai. Routing-nya sendiri tiga baris di TZipSubStream.Read, dan cabang tanpa-gate itulah yang menjaga setiap pemanggil single-threaded yang sudah ada tetap di jalur kode lama

function TZipSubStream.Read(var buffer; Count: longint): longint;
var
  rest: Int64;
  rc: longint;
begin
  rest := FSize - FPosition;
  if (Count > rest) then
    Count := rest;
  if FReadGate <> nil then
    rc := FReadGate.ReadAt(FOffset + FPosition, buffer, Count)
  else
  begin
    FBaseStream.Position := FOffset + FPosition;
    rc := FBaseStream.Read(buffer, Count);
  end;
  FPosition := FPosition + rc;
  Result := rc;
end;

Berapa besar biaya gate itu di bawah kontensi?

Lebih kecil dari yang disarankan frasa "kunci global pada arsip", karena granularitas yang kebetulan dipakai TZLibStream. Buffer inputnya adalah BufferSize, didefinisikan sebagai $4000, jadi ReadInputBuffer menarik 16 KB byte terkompresi per pengisian ulang dan menyerahkannya ke zng_inflate. Satu akuisisi kunci karena itu mencakup 16 KB input deflate, yang untuk XML worksheet mengembang menjadi kira-kira 100 KB markup yang lalu di-decode dan di-parse worker tanpa memegang apa pun. Kuncinya dipegang untuk pembacaan berposisi terhadap cache sistem operasi; pekerjaan yang digerbanginya diukur dalam milidetik

Batas yang jujur adalah di mana rasio itu berbalik. Entri yang disimpan alih-alih dideflasi dibaca lewat gate satu-ke-satu tanpa pekerjaan inflate untuk menyembunyikan latensinya, jadi paket penuh anggota tersimpan akan menyerialkan jauh lebih keras. File dingin pada media lambat memperlebar critical section, karena pembacaan di dalamnya sekarang adalah transfer disk sungguhan alih-alih cache hit. Dan melewati beberapa worker, gate-nya bukan yang pertama Anda kenai lagi: parsing worksheet berat alokasi, dan memory manager Delphi menyerialkan alokasi lintas thread jauh sebelum read gate menjadi batasannya. Itulah kenapa TXLSXWorkbook.ParallelParseThreads default ke cap otomatis alih-alih satu thread per core

Body worker, dan loop drain yang mudah dilupakan

Dengan gate terpasang, HotXLS menghapus staging Fase A sepenuhnya. Worker sekarang membuka stream entrinya sendiri dan langsung menyalurkannya ke parser. Dua field transien membawa inputnya: FParZip memegang arsip selama fase paralel, FParSheetPartNames memegang nama bagian, dan keduanya dikosongkan di blok finally sehingga tidak ada pointer basi yang bertahan dari pembukaan yang gagal. Stream yang kembali dari TZipArchive.OpenFile adalah TZipVerifiedStream yang membungkus TZLibStream yang membungkus TZipSubStream, dan membebaskan yang terluar membebaskan seluruh rantai

procedure TXLSXWorkbook.ParseSheetJob(AIndex: Integer);
var
  Stream: TStream;
  DrainBuffer: array [0..32767] of Byte;
  PartName: WideString;
begin
  PartName := WideString(FParSheetPartNames[AIndex]);
  Stream := FParZip.OpenFile(PartName);
  if Stream = nil then
    Exit;
  try
    ParseWorksheetXml(Stream, FSheets.ByPos[AIndex], FParSst,
      FParRels[AIndex], FParFontMap, FParFillMap, FParBorderMap,
      FParNumFmtMap, FParAlignMap, FParProtMap, FParDateMap);
    // Consume any trailing bytes so the ZIP entry size and CRC are verified.
    while Stream.Read(DrainBuffer, SizeOf(DrainBuffer)) > 0 do
      ;
  finally
    Stream.Free;
  end;
end;

Loop drain adalah detail yang akan hilang dari port lurus kode lama, dan menghilangkannya diam-diam menonaktifkan pemeriksaan integritas. TZipVerifiedStream mengakumulasi CRC32 berjalan seiring byte melewatinya dan memanggil VerifyComplete hanya saat posisinya mencapai ukuran tak-terkompresi yang tercatat di central directory; itulah asal exception size mismatch dan CRC32 mismatch, ditambah pembacaan probe satu-byte yang menangkap entri lebih panjang dari yang dideklarasikan. Pembaca XML berhenti pada elemen penutup dan biasanya meninggalkan newline atau beberapa byte whitespace penutup tidak terbaca, jadi tanpa drain itu, posisinya tidak pernah mencapai ukuran terdeklarasi dan pemeriksaannya tidak pernah terpicu. Membaca sisanya ke buffer sementara tidak berbiaya apa pun dan memulihkannya. Saat stream staging masih ada, XlsxCopyStreamAll melakukan ini secara kebetulan

Apa yang masih berjalan serial, dan flag yang mematikan semuanya

Fase A bertahan, minus ekstraksinya. Ia masih membuat setiap worksheet dan membaca relationship-nya pada thread pemanggil, yang itulah yang membuat setiap map bersama immutable begitu worker mulai. Fase C masih menelusuri sheet secara serial sesudahnya untuk komentar, gambar, chart dan tabel, dan penjaganya berubah dari pemeriksaan null pada array staging lama menjadi zip.Exists terhadap nama bagian. Input read-only bersama yang disentuh worker, tabel string bersama dan map cellXf, sudah lengkap sebelum Fase B mulai dan tidak pernah ditulis selama fase itu

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;      // default; False forces one sheet at a time
    Wb.ParallelParseThreads := 4;  // 0 selects the automatic cap
    Wb.Open('quarterly-consolidation.xlsx');
    // ... workbook is identical either way ...
  finally
    Wb.Free;
  end;
end;

Mengatur ParallelParse ke False sebelum Open mengirim prosedur job yang sama dengan jumlah thread satu, dan RunParallelJobs menurun menjadi loop biasa pada thread pemanggil. Itu layak diketahui karena dua alasan: itu jawaban satu-baris jika kekhawatiran threading pernah muncul di lapangan, dan itu berarti jalur serial dan paralel berbagi satu badan kode parsing alih-alih menyimpang. Exception worker ditangkap, indeks job terendah menang, dan errornya dipicu ulang pada thread pemanggil setelah setiap worker bergabung, jadi worksheet yang korup tetap muncul sebagai satu exception di tempat yang diharapkan. Tuning umum atas jalur buka di sekitarnya dibahas di panduan performa workbook besar di Delphi

Read gate, fase buka paralel dan akses entri streaming yang dijelaskan di sini hadir sebagai bagian dari komponen Excel HotXLS standar untuk Delphi dan C++Builder, dengan source lengkap; halaman produknya membawa referensi TXLSXWorkbook lengkap termasuk properti buka paralelnya