Delphi와 C++Builder용 네이티브 Excel 컴포넌트 라이브러리인 HotXLS는 하나의 열린 ZIP 패키지에서 여러 XLSX 워크시트를 동시에 압축 해제합니다. 이 메커니즘은 lxZipArchive.pas에 있는 작은 클래스 TZipReadGate로, 패키지 스트림 하나와 크리티컬 섹션 하나를 보관하며 메서드는 딱 하나만 노출합니다. seek-and-read 쌍을 직렬화하는 것이 전부입니다. 그 쌍보다 위의 모든 것은 동시에 실행됩니다
이 설계를 강제한 문제는 큰 워크북을 열어본 모든 Delphi 개발자가 맞닥뜨린 것입니다. 80MB짜리 xlsx는 80MB의 deflate된 XML이고, 그 안의 워크시트 파트들은 대략 5배에서 10배로 팽창합니다. open 경로가 각 워크시트를 파싱하기 전에 메모리 스트림으로 추출한다면, 여러분은 만들고 있는 워크북 위에 팽창된 바이트 비용을 추가로 지불하게 되고, 그 피크는 셀이 하나도 만들어지기 전에 찾아옵니다. 이 글은 그 스테이징 단계를 제거하는 패키지 수준 동시성을 다룹니다. 그 위에 있는 할당자 상한은 병렬 XLSX 파싱과 메모리 관리자에 관한 글에서, 한 번만 읽고 절대 실체화하지 않는 API는 스트리밍 다이렉트 리더 안내에서 다룹니다
예전 open 경로는 왜 모든 워크시트를 RAM에 스테이징했는가
HotXLS의 원래 병렬 open은 3단계 파이프라인이었고, 중간 단계만 워커 위에서 실행되었습니다. A 단계는 시트 목록을 순차적으로 순회하며, 각 워크시트를 만들고, 관계 파트를 읽고, 압축 해제된 워크시트 XML 전체를 전용 TMemoryStream으로 복사했습니다. B 단계는 ParseWorksheetXml을 풀 전체에 부채꼴로 분산시켰습니다. C 단계는 작은 부속 파트들, 즉 댓글, 스레드 댓글, 그림, 차트, 표를 위해 호출 스레드에서 다시 아카이브로 돌아갔습니다. 이 형태에는 명시된 이유가 있었습니다. lxParallelParse.pas의 헤더 주석은 예전에 zip 아카이브와 그 압축 해제 상태가 스레드 안전하지 않다고 그대로 적어두었고, 내부 노트는 한발 더 나아가 아카이브를 잠그는 데 신경 쓰지 말라고 했습니다. 압축 해제 상태 머신이 엔트리별로 직렬화된 이상 잠금은 아무 이득도 없다는 것이었습니다. A 단계는 모든 아카이브 접근을 하나의 스레드에 묶어두기 위해 존재했습니다. 그 대가는 여덟 개의 바쁜 시트를 가진 워크북이 여덟 개의 완전히 압축 해제된 워크시트 XML 버퍼를 동시에 메모리에 담아두는 것이었고, 그 버퍼들이 전체 open 경로에서 가장 큰 임시 객체였습니다
두 스레드가 하나의 ZIP 스트림에서 압축 해제할 수 있는가
가능하며, 예전 판단은 특정한, 짚을 수 있는 방식으로 틀렸습니다. 두 개의 다른 상태 조각을 하나의 문장으로 뭉뚱그렸던 것입니다. 압축 해제 상태는 정말로 공유될 수 없습니다. zlib z_stream은 하나의 압축된 멤버에 대해 슬라이딩 윈도우, 허프만 테이블, 비트 위치를 담고 있으며, 두 스레드가 같은 것에 바이트를 밀어 넣으면 쓰레기가 나옵니다. 그 아래의 바이트 소스는 완전히 다른 문제이며, 그 답은 파일 스트림이 보호할 가치가 있는 가변 공유 상태를 딱 하나, 즉 위치 커서만 가진다는 것입니다
ZIP 컨테이너가 이 분리를 정당하게 만듭니다. ZIP 아카이브의 각 멤버는 독립적으로 압축됩니다. 자신만의 로컬 파일 헤더, 자신만의 DataOffset에 있는 자신만의 deflate 비트 스트림, 중앙 디렉터리에 있는 자신만의 CRC32와 크기를 가집니다. 솔리드 7z 블록처럼 멤버들에 걸쳐 있는 공유 사전이 없으므로, N번 항목은 M번 항목을 건드리지 않고 압축 해제될 수 있습니다. 각 워커에게 자기만의 바이트 범위에 대한 자기만의 z_stream을 주면, 그들이 충돌하는 유일한 지점은 seek뿐입니다. 그 충돌을 TZipReadGate가 제거하며, 이 클래스 전체는 한 화면에 다 들어올 정도로 짧습니다
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가 보호하는 것과 일부러 보호하지 않는 것
TZipReadGate.ReadAt은 공유 스트림을 포지셔닝하고 거기서 읽는, 나눌 수 없는 연산 하나만 보호하고 그 이상은 하지 않습니다. TZipArchive.OpenArchive는 중앙 디렉터리가 성공적으로 파싱된 뒤 FInputStream 위에 게이트를 구성하고, TZipArchive.Close가 그것을 해제합니다. 쓰기용으로 열린 아카이브는 게이트를 절대 갖지 않습니다. 그래서 워커가 패키지에 대해 수행하는 모든 읽기는 결국 하나의 버퍼링된 읽기가 지속되는 동안 유지되는 단일 크리티컬 섹션을 통과합니다
그 밖의 모든 것은 이미 사적이거나 이미 불변이므로 잠금 밖에 머뭅니다. TZipSubStream은 자기만의 FPosition을 보관하므로, 각 워커는 자기만의 항목 안에서 자기만의 위치를 추적합니다. TZipEntry.GetStream이 그 서브스트림 위에 만드는 TZLibStream은 항목별로, raw deflate를 위한 windowBits -15로 생성되며 절대 공유되지 않습니다. 중앙 디렉터리는 어떤 워커가 시작하기 전에 모든 로컬 헤더를 포함해 완전히 파싱되므로, 동시성이 시작될 즈음에는 GetEntryByName이 읽기 전용 해시 조회가 됩니다. 라우팅 자체는 TZipSubStream.Read의 세 줄이며, 게이트 없는 분기는 기존의 모든 단일 스레드 호출자를 예전 코드 경로에 그대로 남겨둡니다
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;
경합 상태에서 게이트는 얼마나 비용이 드는가
"아카이브에 대한 전역 잠금"이라는 표현이 암시하는 것보다 적습니다. TZLibStream이 마침 사용하는 세분성 덕분입니다. 입력 버퍼는 BufferSize로 정의되며 $4000이므로, ReadInputBuffer는 리필당 16KB의 압축된 바이트를 끌어와 zng_inflate에 넘깁니다. 즉 잠금을 한 번 획득하면 16KB의 deflate 입력을 처리하고, 워크시트 XML의 경우 이는 워커가 아무것도 잡지 않은 채로 디코드하고 파싱하는 약 100KB 분량의 마크업으로 확장됩니다. 잠금은 운영체제 캐시를 상대로 하는 포지션된 읽기 동안만 유지되며, 그것이 게이트하는 작업은 밀리초 단위로 측정됩니다
이 비율이 뒤집히는 지점이 정직한 경계입니다. deflate 대신 저장(stored)된 항목은 숨겨줄 압축 해제 작업 없이 게이트를 1대1로 통과하므로, 저장된 멤버로 가득한 패키지는 훨씬 더 강하게 직렬화될 것입니다. 느린 미디어의 콜드 파일은 크리티컬 섹션을 넓히는데, 그 안의 읽기가 이제 캐시 히트가 아니라 실제 디스크 전송이기 때문입니다. 그리고 워커가 몇 개를 넘어서면 어차피 처음 부딪히는 것은 게이트가 아닙니다. 워크시트 파싱은 할당이 많은 작업이며, Delphi 메모리 관리자는 읽기 게이트가 제약이 되기 훨씬 전에 이미 스레드 간 할당을 직렬화합니다. 그래서 TXLSXWorkbook.ParallelParseThreads는 코어당 스레드 하나 대신 자동 상한을 기본값으로 삼습니다
워커 본체, 그리고 잊기 쉬운 드레인 루프
게이트가 자리 잡으면서 HotXLS는 A 단계 스테이징을 완전히 삭제했습니다. 이제 워커는 자기만의 항목 스트림을 열어 곧바로 파서에 넘깁니다. 두 개의 임시 필드가 입력을 나릅니다. FParZip은 병렬 단계 동안 아카이브를 보관하고, FParSheetPartNames는 파트 이름을 보관하며, 둘 다 finally 블록에서 지워지므로 실패한 open이 낡은 포인터를 남기지 않습니다. TZipArchive.OpenFile에서 돌아오는 스트림은 TZLibStream을 감싸는 TZipVerifiedStream이고, 그것은 다시 TZipSubStream을 감싸며, 가장 바깥 것을 해제하면 체인 전체가 해제됩니다
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;
드레인 루프는 예전 코드를 단순 포팅했다면 빠뜨렸을 세부 사항이며, 이를 빠뜨리면 무결성 검사가 조용히 비활성화됩니다. TZipVerifiedStream은 바이트가 통과할 때마다 진행 중인 CRC32를 누적하고, 자신의 위치가 중앙 디렉터리에 기록된 압축 해제 크기에 도달했을 때만 VerifyComplete를 호출합니다. 크기 불일치와 CRC32 불일치 예외, 그리고 선언보다 긴 항목을 잡아내는 1바이트 프로브 읽기가 모두 거기서 나옵니다. XML 리더는 닫는 요소에서 멈추고 보통 개행이나 몇 바이트의 후행 공백을 읽지 않은 채로 남기므로, 드레인이 없으면 위치는 절대 선언된 크기에 도달하지 못하고 검사는 절대 발동하지 않습니다. 스크래치 버퍼로 나머지를 읽어들이는 비용은 거의 없고 이 검사를 복원해줍니다. 스테이징 스트림이 존재하던 시절에는 XlsxCopyStreamAll이 우연히 이 일을 하고 있었습니다
여전히 순차적으로 실행되는 것, 그리고 모든 것을 끄는 플래그
A 단계는 추출 부분만 빠진 채 살아남았습니다. 여전히 호출 스레드에서 각 워크시트를 만들고 그 관계를 읽는데, 이것이 워커가 시작될 때 모든 공유 맵이 불변인 이유입니다. C 단계는 여전히 그 뒤에 시트를 순차적으로 순회하며 댓글, 그림, 차트, 표를 처리하고, 그 가드는 예전 스테이징 배열에 대한 null 검사에서 파트 이름에 대한 zip.Exists로 바뀌었습니다. 워커가 건드리는 공유 읽기 전용 입력들, 즉 공유 문자열 테이블과 cellXf 맵들은 B 단계가 시작되기 전에 완성되어 있고 그 동안에는 절대 쓰이지 않습니다
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;
Open 이전에 ParallelParse를 False로 설정하면 같은 작업 프로시저를 스레드 수 1로 디스패치하고, RunParallelJobs는 호출 스레드에서 단순한 루프로 격하됩니다. 이는 두 가지 이유로 알아둘 가치가 있습니다. 현장에서 스레딩 관련 우려가 나올 때 이것이 한 줄짜리 답이라는 점, 그리고 직렬 경로와 병렬 경로가 갈라진 두 코드 본체가 아니라 하나의 파싱 코드 본체를 공유한다는 점입니다. 워커 예외는 포착되어 가장 낮은 작업 인덱스가 우선하며, 모든 워커가 join된 뒤 호출 스레드에서 다시 발생하므로, 손상된 워크시트는 여전히 예상된 위치에서 하나의 예외로 나타납니다. 주변 open 경로의 일반적인 튜닝은 Delphi에서 대용량 워크북 성능 안내에서 다룹니다
여기서 설명한 읽기 게이트, 병렬 open 단계, 스트리밍 항목 접근은 전체 소스와 함께 Delphi와 C++Builder용 표준 HotXLS Excel 컴포넌트의 일부로 제공됩니다. 제품 페이지에는 병렬 open 속성을 포함한 전체 TXLSXWorkbook 레퍼런스가 실려 있습니다