Bài viết kỹ thuật

Inflate ZIP đồng thời trong Delphi: Read Gate của HotXLS

HotXLS, thư viện component Excel bản địa cho Delphi và C++Builder, inflate nhiều worksheet XLSX cùng lúc từ một gói ZIP đang mở duy nhất. Cơ chế này là TZipReadGate, một lớp nhỏ trong lxZipArchive.pas giữ stream gói cộng với một critical section và phơi bày đúng một phương thức. Nó tuần tự hóa cặp seek-và-read. Mọi thứ bên trên cặp đó chạy đồng thời

Vấn đề buộc phải có thiết kế này là điều mọi lập trình viên Delphi từng mở một workbook lớn đều gặp phải. Một xlsx 80 MB là 80 MB XML đã deflate, và các phần worksheet bên trong nó mở rộng ra khoảng năm đến mười lần. Nếu đường mở của bạn trích xuất mỗi worksheet vào một memory stream trước khi phân tích nó, bạn phải trả giá cho các byte đã inflate cộng thêm vào workbook bạn đang xây dựng, và đỉnh điểm đến trước khi một ô nào được tạo ra. Bài này nói về tính đồng thời ở mức gói loại bỏ bước tạm giữ đó. Trần bộ cấp phát nằm bên trên nó được trình bày trong bài về phân tích XLSX song song và trình quản lý bộ nhớ, và API đọc-một-lần, không-bao-giờ-vật-chất-hóa được trình bày trong bài về trình đọc trực tiếp dạng stream

Vì sao đường mở cũ tạm giữ mọi worksheet trong RAM

Đường mở song song ban đầu trong HotXLS là một pipeline ba giai đoạn, và giai đoạn giữa là giai đoạn duy nhất chạy trên các worker. Giai đoạn A duyệt danh sách sheet tuần tự, tạo mỗi worksheet, đọc phần quan hệ của nó, và copy toàn bộ XML worksheet đã inflate vào một TMemoryStream riêng. Giai đoạn B tỏa ParseWorksheetXml ra trên pool. Giai đoạn C quay lại kho lưu trữ trên luồng gọi cho các phần vệ tinh nhỏ: comment, threaded comment, drawing, chart, table. Hình dạng đó được chọn vì một lý do đã nêu rõ. Comment header trên lxParallelParse.pas từng nói, gần như nguyên văn, rằng kho lưu trữ zip và trạng thái inflate của nó không an toàn cho đa luồng, và ghi chú nội bộ còn nói xa hơn: đừng bận tâm khóa kho lưu trữ, vì một khi trạng thái máy inflate được tuần tự hóa theo từng mục thì cái khóa không mua được gì cả. Giai đoạn A tồn tại để giữ mọi lần chạm vào kho lưu trữ trên một luồng duy nhất. Cái giá là một workbook có tám sheet bận rộn giữ tám buffer XML worksheet đã inflate hoàn toàn trong bộ nhớ cùng lúc, và những buffer đó là các đối tượng tạm thời lớn nhất trong toàn bộ đường mở

Hai luồng có thể inflate từ một stream ZIP không?

Có, và phán đoán cũ sai theo một cách cụ thể, có thể xác định được: nó gộp hai mảnh trạng thái khác nhau vào một câu duy nhất. Trạng thái inflate thực sự không thể chia sẻ. Một z_stream của zlib mang cửa sổ trượt, các bảng Huffman và vị trí bit cho một thành viên nén, và hai luồng đẩy byte qua cùng một cái sẽ tạo ra rác. Nguồn byte bên dưới là một câu hỏi hoàn toàn khác, và câu trả lời ở đó là một file stream có đúng một mảnh trạng thái chia sẻ có thể thay đổi đáng để bảo vệ, con trỏ vị trí của nó

Container ZIP khiến sự tách biệt đó hợp pháp. Mỗi thành viên trong một kho lưu trữ ZIP được nén độc lập: local file header riêng của nó, luồng bit deflate riêng của nó tại DataOffset riêng của nó, CRC32 và kích thước riêng của nó trong central directory. Không có từ điển chia sẻ trải qua các thành viên theo cách một khối 7z rắn có, nên mục N có thể được inflate mà không chạm vào mục M. Cho mỗi worker z_stream riêng của nó trên dải byte riêng của nó và thứ duy nhất chúng va chạm là seek. Va chạm đó chính là thứ TZipReadGate loại bỏ, và toàn bộ lớp này đủ ngắn để đọc trong một màn hình

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;

TZipReadGate bảo vệ điều gì và cố tình không bảo vệ điều gì

TZipReadGate.ReadAt canh gác một thao tác không thể chia nhỏ duy nhất, định vị stream dùng chung và đọc từ nó, và không gì khác. TZipArchive.OpenArchive xây gate trên FInputStream ngay khi central directory đã phân tích thành công, và TZipArchive.Close giải phóng nó. Các kho lưu trữ mở để ghi không bao giờ có một cái. Mỗi lần đọc mà một worker thực hiện trên gói do đó đều đi qua một critical section duy nhất được giữ trong suốt thời gian của một lần đọc có đệm

Mọi thứ khác nằm ngoài khóa vì chúng đã riêng tư hoặc đã bất biến rồi. TZipSubStream giữ FPosition riêng của nó, nên mỗi worker theo dõi vị trí riêng của nó trong mục riêng của nó. TZLibStreamTZipEntry.GetStream xây trên sub-stream đó là riêng cho từng mục, được tạo với windowBits là -15 cho deflate thô, và không bao giờ được chia sẻ. Central directory được phân tích hoàn toàn trước khi bất kỳ worker nào bắt đầu, bao gồm mọi local header, nên GetEntryByName chỉ là một tra cứu hash chỉ-đọc vào lúc tính đồng thời bắt đầu. Bản thân việc định tuyến chỉ là ba dòng trong TZipSubStream.Read, và nhánh không-gate là thứ giữ mọi bên gọi đơn luồng hiện có trên đường code cũ

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;

Gate tốn bao nhiêu dưới tranh chấp?

Ít hơn cụm từ "khóa toàn cục trên kho lưu trữ" gợi ý, vì độ hạt mà TZLibStream tình cờ sử dụng. Buffer đầu vào của nó là BufferSize, được định nghĩa là $4000, nên ReadInputBuffer kéo 16 KB byte nén cho mỗi lần nạp lại và đưa chúng cho zng_inflate. Một lần giữ khóa do đó bao phủ 16 KB đầu vào deflate, với XML worksheet mở rộng ra khoảng 100 KB markup mà worker sau đó giải mã và phân tích mà không giữ gì cả. Khóa được giữ trong một lần đọc định vị đối chiếu với cache hệ điều hành; công việc mà nó canh gác được đo bằng mili giây

Ranh giới trung thực là nơi tỷ lệ đó đảo ngược. Các mục được lưu trữ thay vì deflate đọc qua gate một-một không có công việc inflate nào để che giấu độ trễ, nên một gói đầy các thành viên được lưu trữ sẽ tuần tự hóa cứng hơn nhiều. Một file lạnh trên phương tiện chậm mở rộng critical section, vì lần đọc bên trong nó giờ là một lần truyền đĩa thật thay vì một lần trúng cache. Và vượt qua một vài worker thì gate không phải thứ bạn chạm phải đầu tiên: việc phân tích worksheet nặng về cấp phát, và trình quản lý bộ nhớ Delphi tuần tự hóa các phép cấp phát qua các luồng từ lâu trước khi read gate trở thành ràng buộc. Đó là lý do TXLSXWorkbook.ParallelParseThreads mặc định về một trần tự động thay vì một luồng cho mỗi lõi

Thân worker, và vòng lặp rút cạn dễ bị quên

Với gate tại chỗ, HotXLS đã xóa hẳn việc tạm giữ ở Giai đoạn A. Worker giờ mở stream mục riêng của nó và đưa thẳng nó cho trình phân tích. Hai trường tạm thời mang đầu vào: FParZip giữ kho lưu trữ trong suốt giai đoạn song song, FParSheetPartNames giữ tên các phần, và cả hai đều được xóa trong khối finally để không con trỏ cũ nào sống sót sau một lần mở thất bại. Stream trả về từ TZipArchive.OpenFile là một TZipVerifiedStream bọc một TZLibStream bọc một TZipSubStream, và giải phóng cái ngoài cùng giải phóng cả chuỗi

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;

Vòng lặp rút cạn là chi tiết mà một bản port thẳng từ code cũ sẽ đánh rơi, và đánh rơi nó âm thầm vô hiệu hóa việc kiểm tra tính toàn vẹn. TZipVerifiedStream tích lũy một CRC32 đang chạy khi byte đi qua và chỉ gọi VerifyComplete khi vị trí của nó đạt đến kích thước chưa nén được ghi trong central directory; đó là nơi các exception lệch kích thước và lệch CRC32 đến từ, cộng với một lần đọc thăm dò một byte bắt được một mục dài hơn khai báo. Một trình đọc XML dừng lại ở phần tử đóng và thường để lại một dòng mới hoặc vài byte khoảng trắng thừa chưa đọc, nên không có bước rút cạn, vị trí không bao giờ đạt tới kích thước khai báo và các phép kiểm tra không bao giờ kích hoạt. Đọc phần còn lại vào một buffer nháp không tốn gì và khôi phục chúng. Khi các stream tạm giữ còn tồn tại, XlsxCopyStreamAll đã làm việc này một cách tình cờ

Điều gì vẫn chạy tuần tự, và cờ tắt tất cả

Giai đoạn A sống sót, trừ phần trích xuất. Nó vẫn tạo mỗi worksheet và đọc quan hệ của nó trên luồng gọi, đó là điều để lại mọi map dùng chung bất biến ngay khi các worker bắt đầu. Giai đoạn C vẫn duyệt các sheet tuần tự sau đó cho comment, drawing, chart và table, và điều kiện canh gác của nó đổi từ một phép kiểm tra null trên mảng tạm giữ cũ thành zip.Exists đối chiếu với tên phần. Các đầu vào chỉ-đọc dùng chung mà các worker chạm vào, bảng chuỗi dùng chung và các map cellXf, đã hoàn chỉnh trước khi Giai đoạn B bắt đầu và không bao giờ bị ghi trong suốt giai đoạn đó

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;

Đặt ParallelParse thành False trước Open gửi cùng thủ tục job với số luồng là một, và RunParallelJobs suy biến thành một vòng lặp trơn trên luồng gọi. Điều đó đáng biết vì hai lý do: đó là câu trả lời một dòng nếu một mối lo về đa luồng từng xuất hiện trong thực tế, và nó có nghĩa đường tuần tự và đường song song chia sẻ một thân phân tích duy nhất thay vì phân kỳ. Các exception của worker được bắt lại, chỉ số job thấp nhất thắng, và lỗi được re-raise trên luồng gọi sau khi mọi worker join, nên một worksheet hỏng vẫn hiện ra như một exception duy nhất ở đúng nơi mong đợi. Việc tinh chỉnh chung của đường mở xung quanh được trình bày trong hướng dẫn về hiệu năng workbook lớn trong Delphi

Read gate, giai đoạn mở song song và truy cập mục dạng stream mô tả ở đây đi kèm trong HotXLS Excel component tiêu chuẩn cho Delphi và C++Builder, với đầy đủ mã nguồn; trang sản phẩm mang tài liệu tham chiếu TXLSXWorkbook hoàn chỉnh bao gồm các thuộc tính mở song song