기술 문서

Delphi에서 동시 ZIP 압축 해제: HotXLS 읽기 게이트

스레드 둘이 .xlsx 패키지 위의 하나의 TFileStream을 공유합니다. 스레드 A가 Position을 sheet1.xml의 첫 압축 바이트로 놓고, 잠시 후 스레드 B가 그것을 sheet2.xml의 첫 바이트로 놓으면, A의 Read는 다른 멤버의 버퍼를 반환합니다. A의 인플레이터는 이제 남의 deflate 스트림 한가운데를 디코딩하는 중입니다. zlib은 보통 몇 블록 안에 Z_DATA_ERROR로 멈추고, 우연히 출력을 만들어도 그 멤버의 CRC32는 맞지 않습니다. 여기서 Deflate는 문제가 아닙니다. 경쟁은 전적으로 스트림의 위치 커서에 살고, seek-and-read 쌍을 감싼 락 하나면 같은 패키지의 멤버들을 여러 워커가 동시에 압축 해제할 수 있습니다

그것을 원하는 이유는 메모리입니다. 워크북이 디스크에서 80MB이고 워크시트 XML이 압축 해제 시 5~10배로 팽창한다고 칩시다. 파싱 전에 각 워크시트를 버퍼로 추출하는 열기 경로는 만들고 있는 워크북 위에 그 팽창된 바이트를 추가로 치르고, 피크는 셀 하나 존재하기 전에 도래합니다. 이 글은 추출 단계를 없애는 패키지 수준 동시성을 다룹니다. 그 위의 할당자 천장은 병렬 XLSX 파싱과 메모리 관리자 글이, 워크북을 아예 만들지 않는 한 번 읽기 API는 스트리밍 다이렉트 리더 워크스루가 다룹니다

스테이징 설계와 그것이 메모리에 쥐는 것

XLSX 열기를 병렬화하는 신중한 방법은 중간 단계만 워커에서 도는 3단계 파이프라인입니다. Phase A는 호출 스레드에서 시트 목록을 걷고, 각 워크시트를 만들고, 관계부를 읽고, 팽창된 워크시트 XML 전체를 비공개 TMemoryStream으로 복사합니다. Phase B는 XML 파싱을 스레드 풀로 펼칩니다. Phase C는 여전히 호출 스레드에서 아카이브로 돌아와 작은 위성부들을 읽습니다. 코멘트, 스레드 코멘트, 드로잉, 차트, 테이블입니다

그 형태를 위한 논거는 틈이 없어 보입니다. ZIP 아카이브와 그 inflate 상태는 스레드 안전하지 않으니, 모든 아카이브 접촉을 한 스레드에 두고 락을 아예 생략하자는 것입니다. 청구서는 여덟 개의 바쁜 시트를 지닌 워크북이 같은 순간 여덟 개의 완전히 팽창된 워크시트 버퍼를 쥐고 있고, 그 버퍼들이 열기 경로 어디에서나 가장 큰 과도 오브젝트라는 것입니다. 위에서 가정한 80MB 패키지로는 파서가 한 번 읽으라고만 존재하는 수백 MB의 XML 텍스트입니다

레거시 HotXLS 열기 경로 다이어그램: Delphi에서 Phase A가 팽창된 모든 워크시트 XML을 메모리에 스테이징한 뒤 Phase B가 시트를 병렬 파싱
Phase A는 각 압축 해제된 워크시트를 한 스레드에서 비공개 메모리 스트림으로 복사했으므로, 바쁜 시트 여덟 개를 지닌 워크북은 피크에 큰 버퍼 여덟 개를 쥐고 있었습니다

HotXLS는 Phase A와 C를 유지하되 복사를 떨어뜨립니다. Phase B의 각 워커는 공유 패키지 위에 자신만의 엔트리 스트림을 열어 XML 리더에 곧장 넘기므로, 팽창된 워크시트는 하나의 덩어리로 존재하지 않습니다. "스레드 안전하지 않다"는 논거가 사실은 서로 다른 두 가지에 관한 것이었기에 가능한 일입니다

두 스레드가 하나의 ZIP 스트림에서 압축 해제할 수 있는가

가능합니다. 각 스레드가 자기 인플레이터를 소유하고 공유 바이트 원천이 읽기 지점에서 직렬화되면요. 신중한 논거는 두 종류의 상태를 한 문장으로 접었습니다. Inflate 상태는 정말 공유할 수 없습니다. zlib z_stream은 RFC 1951이 정의하는 32KB 슬라이딩 윈도우와 현재 허프먼 테이블, 하나의 압축 멤버 안의 비트 위치를 나르고, 같은 것을 통해 바이트를 밀어 넣는 두 스레드는 쓰레기를 만듭니다. 바이트 원천은 다른 문제입니다. 파일 스트림에는 보호할 만한 가변 상태가 정확히 하나, 그 위치뿐입니다

ZIP 컨테이너가 그 분할을 합법화합니다. PKWARE의 APPNOTE(section 4.3)는 패키지를 독립 멤버들로 늘어놓습니다. 각 멤버는 자신의 로컬 파일 헤더와 자신의 오프셋에 놓인 자신의 압축 데이터, 그리고 중앙 디렉터리에 기록된 자신의 CRC32와 크기를 갖습니다. 솔리드 7z 블록처럼 멤버들에 걸친 사전은 없으므로, 엔트리 N은 엔트리 M의 바이트를 하나도 읽지 않고 압축 해제될 수 있습니다. 모든 워커에게 자신의 바이트 범위 위의 비공개 인플레이터를 주면 남는 충돌은 seek뿐입니다. HotXLS에서 그 충돌은 lxZipArchive.pas의 TZipReadGate가 처리하며, 클래스 전체가 한 화면에 들어갑니다

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TXLSCriticalSection;   // critical section 위의 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은 하나의 불가분 연산, 즉 공유 스트림의 위치 지정과 읽기를 지킵니다. TZipArchive.OpenArchive는 중앙 디렉터리가 파싱된 뒤 게이트를 만드는데, 파일 이름 오버로드와 TStream 오버로드 모두입니다. 아카이브를 해제하면 게이트도 해제됩니다. 쓰기 위해 만든 아카이브는 게이트를 얻지 않습니다. 동시에 그것을 읽는 것은 없기 때문입니다

나머지는 전부 이미 비공개이거나 이미 얼어 있으므로 락 밖에 남습니다. TZipArchive.OpenFile이 반환하는 각 엔트리 스트림은 자기 멤버 안에 자신의 위치를 유지하고 ReadAt을 호출하는 순간에만 절대 패키지 오프셋으로 바꿉니다. 그것을 감싼 인플레이터는 엔트리마다 만들어지며, 윈도우 크기 -15를 갖습니다. zlib은 이것을 zlib 헤더 없는 날 deflate로 읽습니다. 그리고 결코 다른 스레드에 넘겨지지 않습니다. 중앙 디렉터리는 로컬 헤더 포함, 워커가 시작하기 전에 완전히 파싱되므로, 이름으로 엔트리를 찾는 것은 동시성이 시작되는 시점에 이미 읽기 전용 해시 조회입니다. 조회는 대소문자를 구분하지 않고 .. 세그먼트를 담은 이름은 거부합니다

HotXLS read gate 아키텍처 다이어그램: Delphi에서 동시 워크시트 워커가 전용 바이트 범위를 팽창하는 동안 하나의 critical section이 공유 스트림 seek과 read만 직렬화
각 워커는 자기 서브스트림 위치와 z_stream을 소유하므로, 공유 상태는 패키지 커서뿐이고 TZipReadGate가 단일 위치 지정 읽기를 위해 락을 쥡니다

여러분 스트림으로 TXLSXWorkbook.Open(Stream)을 호출할 때 하나의 귀결이 중요합니다. 게이트는 그 스트림을 감싸고, 그 락은 라이브러리의 워커들을 서로 조율합니다. 여러분 코드에 대해서는 아무것도 모릅니다. Open이 도는 동안 애플리케이션의 다른 스레드가 같은 TStream을 읽거나 위치를 옮기면 첫 단락의 커서 경쟁이 돌아옵니다. 스트림을 넘긴 뒤 Open이 반환될 때까지 손대지 마세요

경쟁 아래에서 락 하나는 얼마나 드나

"아카이브에 전역 락 하나"가 암시하는 것보다 쌉니다. 획득 하나가 커버하는 일이 많기 때문입니다. 인플레이터는 입력을 64KB짜리 압축 바이트 청크로 채우므로, 게이트 한 번 왕복은 이후 아무것도 잡지 않고 디코딩하고 파싱하는 워커에게 deflate 데이터 64KB를 먹입니다. 앞서 가정한 5~10배 팽창으로 그 리필은 대략 320~640KB의 워크시트 마크업이 됩니다. 락 자체는 통상 운영체제 파일 캐시에서 충족되는 위치 지정 읽기를 커버할 뿐입니다

비율은 세 곳에서 뒤집힙니다. 전부 입력이나 머신에 관한 것입니다. 압축 없이 저장된 멤버는 획득 사이에 inflate 작업 없이 읽기 호출마다 게이트를 통과합니다. 대부분의 라이터는 워크시트부를 deflate하지만 모든 것을 store하는 도구가 만든 패키지는 더 심하게 직렬화됩니다. 느리거나 네트워크인 저장소의 차가운 파일은 임계 구역을 넓힙니다. 그 안의 읽기가 진짜 전송이 되기 때문입니다. 그리고 소수의 워커를 넘어서면 게이트는 어차피 첫 벽이 아닙니다. 워크시트 파싱은 할당을 많이 하고, Delphi 메모리 관리자는 읽기 게이트가 포화되기 훨씬 전에 스레드 간 할당을 직렬화합니다

마지막 지점이 스레드 수를 결정합니다. TXLSXWorkbook.ParallelParseThreads의 기본값은 0으로, 논리 프로세서당 워커 하나, 시트 수를 넘지 않게 시작합니다. 논리 프로세서 4~8개의 노트북에서 그 기본값이면 충분합니다. 수십 개 시트의 워크북을 여는 32스레드 빌드 서버에서는 4 같은 명시적 작은 상한을 두세요. 추가 워커는 대개 할당자에 줄 서고 각자 자기 인플레이터와 버퍼를 쥡니다. 여러분 파일에 맞는 수를 찾으려면 대표 워크북 하나를 속성을 1, 2, 4, 8로 놓고 루프로 열어 벽시계 시간을 작업 관리자나 Process Explorer의 피크 작업 집합과 비교하세요

엔트리를 끝까지 읽는 것이 CRC 검사를 발화합니다

아카이브가 반환하는 모든 엔트리 스트림은 자체 검사를 하지만 끝에서만입니다. 바이트가 지날 때 누적 CRC32를 쌓고, 위치가 중앙 디렉터리에 기록된 비압축 크기에 닿으면 두 값을 비교하고 한 바이트 더 살짝 찔러 봅니다. 실패는 문자 그대로의 메시지를 지닌 예외로 표면화합니다. '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의 각 워커는 스트림을 해제하기 전에 파서가 남긴 것을 32KB 스크래치 버퍼로 배수(drain)하고, 가장 바깥 스트림을 해제하는 것이 인플레이터와 그 아래 서브스트림을 해제합니다

공유 패키지에서 read gate를 거쳐 검증된 스트림에 이르는 Delphi의 HotXLS 워커 스트림 체인 다이어그램과, 크기 및 CRC32 검사를 발화시키는 drain 루프
drain 루프가 위치를 선언된 엔트리 크기까지 밀어 올려 크기와 CRC32 비교가 돌게 하며, XML 리더가 읽지 않고 남겨 둘 손상을 잡아냅니다

같은 규칙이 여러분 코드가 lxZipArchive의 TZipArchive를 쓸 때, 예컨대 워크북 로더에 넘기기 전 들어오는 패키지를 건전성 검사할 때도 적용됩니다. 각 부의 처음 몇 킬로바이트만 읽어 루트 엘리먼트를 훔쳐보는 함수는 무결성에 대해 아무것도 증명하지 못합니다. 끝까지 읽으세요:

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;

그 코드의 두 번째 함정은 되감기입니다. 엔트리 스트림은 순방향 전용입니다. Size를 읽는 것은 선언된 크기를 미리 알므로 동작하지만, 훔쳐본 뒤 Position := 0은 'ZIP entry stream is forward-only'를 일으킵니다. 컴포넌트가 seek을 고집한다면 OpenFile으로 엔트리를 두 번째 여세요. 값싸고 오프셋 0의 새 스트림을 줍니다. 읽기용으로 연 하나의 아카이브에 여러 스레드가 각자 OpenFile을 호출할 수 있습니다. 워크북 로더가 하는 것이 정확히 그것이며, 스트림들이 살아 있는 동안 아무도 아카이브를 해제하지 않으면 됩니다

여전히 직렬로 도는 것과 그것을 끄는 스위치

Phase A는 추출을 뺀 채로 여전히 돕니다. 호출 스레드에서 모든 워크시트를 만들고 관계를 읽는데, 워커가 시작되면 공유 맵이 불변으로 남는 이유가 바로 이것입니다. 공유 문자열 테이블과 셀 서식 테이블은 Phase B가 시작되기 전에 완성되고 그 동안 결코 쓰이지 않습니다. Phase C는 이후에도 순서대로 시트를 걸으며 코멘트, 드로잉, 차트, 테이블을 읽고, 패키지에 부가 없는 시트는 건너뜁니다

uses
  SysUtils, lxHandleX;

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;        // 기본값; False는 한 번에 한 시트를 파싱합니다
    Wb.ParallelParseThreads := 4;    // 0 = 논리 프로세서당 워커 하나
    Wb.SelectedSheets.Add('Q3 Detail');  // 선택: 이 시트의 셀만 파싱합니다
    if Wb.Open('quarterly-consolidation.xlsx') <> 1 then
      raise Exception.Create('Workbook open failed');
    // 로드된 워크북은 어느 경로가 파싱했든 동일합니다
  finally
    Wb.Free;
  end;
end;

Open 전에 ParallelParse를 False로 두면 같은 시트별 작업을 스레드 수 1로 돌리고 풀이 호출 스레드의 평범한 루프로 퇴화합니다. 워크시트 하나짜리 워크북은 어느 쪽이든 그 경로를 탑니다. 열기 문제를 진단할 때 이 스위치를 기억하세요. 한 줄로 스레딩을 그림에서 지워 주고, 두 경로가 같은 파싱 코드를 돌므로 돌아오는 워크북은 설정에 의존하지 않습니다

오류도 직렬 형태를 유지합니다. 워커가 예외를 던지면 더 이상 시트가 파견되지 않고, 모든 워커가 조인하며, 가장 낮은 시트 인덱스의 예외가 호출 스레드에서 다시 던져집니다. 깨진 sheet3.xml이 여전히 Open에서 예외 하나로 표면화하는 식입니다. RecoveryMode가 True면 파싱에 실패한 워크시트는 대신 건너뛰어져 워크북 진단에 경고로 기록됩니다. SelectedSheets에서 빠진 시트는 엔트리 스트림을 아예 열지 않고, MetadataOnly는 병렬 단계를 완전히 건너뜁니다. 열기 경로의 더 넓은 튜닝은 Delphi 대형 워크북 성능 가이드가 다룹니다

여기서 보인 읽기 게이트, 병렬 열기 단계, 엔트리 수준 ZIP 접근은 전체 소스와 함께 제공되는 Delphi 및 C++Builder용 HotXLS Delphi Excel component의 일부입니다. 제품 페이지에서 기술한 TXLSXWorkbook 열기 속성과 나란히 lxZipArchive.pas를 읽을 수 있습니다