백그라운드 스레드가 40,000행 보고서를 내보내는 동안 UI 스레드가 셀 하나를 설정했고, 디스크에 내려앉은 파일은 존재한 적 없는 어느 통합 문서와도 맞지 않았습니다. HotXLS는 그 부류의 버그를 lxWorkbookView.pas에서 처리합니다. IXLSWorkbookViewCore가 O(1) 읽기 리스와 fail-fast 쓰기 가드를 발행합니다. 리스가 열려 있는 동안 모든 변경 진입점은 쓰는 대신 예외를 던집니다
스택 트레이스 없이 도착하는 실패
통합 문서 읽기는 결코 하나의 원자적 연산이 아닙니다. 보고서 순회는 수 초에 걸쳐 흩어진 수만 개의 개별 셀 읽기이고, 그 사이에 착지하는 SetValue 하나면 순회의 나머지가 보는 것을 바꾸기에 충분합니다. 클래식 엔진이 이것을 구체적으로 만듭니다. TXLSCellRef.SetValue는 FSST.Remove를 불러 공유 문자열 항목을 없애고, FValueType을 리셋하고, 수식 캐시 상태를 무효화할 수 있습니다. 다른 스레드가 정확히 그 구조들을 역참조하는 한가운데 있는 동안에 말입니다. 그 자리에서 크래시하는 것은 아무것도 없습니다. 소계가 맞지 않는 보고서, 또는 이제 다른 곳을 가리키는 문자열 인덱스를 조용히 읽는 내보내기를 받습니다
HotXLS는 일부러 쓰는 자를 기다리게 해서 이것을 풀지 않습니다. 리더는 통합 문서를 몇 초간 쥘 수 있고, VCL 애플리케이션에서 쓰는 자는 흔히 메인 스레드의 UI 콜백이나 이벤트 핸들러입니다 — 백그라운드 내보내기가 끝날 때까지 그 스레드를 막는 것은 편집을 실패시키는 것보다 나쁜 결과입니다. 그래서 조율 코어는 열린 리스에 쓰기가 시도되는 순간, 필드 하나가 건드려지기도 전에 EXLSWorkbookWriteGuardUnavailable를 던지고, 호출자가 편집을 큐에 넣을지, 재시도할지, 사용자에게 말할지 결정합니다. 큐에 넣은 충돌이 아니라 fail-fast 충돌입니다
통합 문서는 두 스레드에서 읽어도 안전한가
네, 두 리더가 모두 리스를 쥐고 아무도 쓰지 않는다면 안전합니다. IXLSWorkbookViewCore.AcquireReadLease는 TCriticalSection을 잡고, 카운터를 증가시키고, 현재 세대를 스냅샷하고, IXLSWorkbookReadLease를 돌려줍니다 — 통합 문서가 천 개 셀을 쥐든 백만 개를 쥐든 상수 시간입니다. 몇 개의 리스든 공존하고, 어떤 순서로든 놓을 수 있으며, 각자 자기 인터페이스 참조로 코어를 살아 있게 고정하므로, 만든 객체보다 오래 사는 리스는 대롱거리는 포인터가 아니라 안전합니다. 두 엔진이 모두 참여합니다. lxHandle.pas의 TXLSWorkbook과 lxHandleX.pas의 TXLSXWorkbook은 각자 생성자에서 코어를 만들고 _AcquireReadLease와 _AcquireWriteGuard를 노출합니다
똑같이 중요한 것은 리스가 읽기 경로에 더하지 않는 것입니다. 임계 영역은 리스 획득, 리스 해제, 쓰기 트랜잭션 경계만 덮습니다 — 그 외엔 아무것도 아닙니다. 평범한 셀 단위 읽기는 잠금, 모니터, 원자적 카운터에 들어가지 않으므로, 리스 쥐기는 셀마다 하나가 아니라 전체 스캔에 획득 하나와 해제 하나의 비용이 듭니다. 병렬 XLSX 파싱과 메모리 할당기 작업 뒤에 같은 설계 본능이 있습니다. 조율 비용은 경계에서 내고, 안쪽 루프에서는 결코 아닙니다. 대칭 규칙도 성립합니다 — WriteDepth가 0이 아닌 동안 AcquireReadLease는 EXLSWorkbookReadLeaseUnavailable를 던지므로, 쓰기 트랜잭션 안에서 리스를 열 수 없습니다. 쓰는 스레드에서조차
uses
lxHandle, lxWorkbookView;
procedure TReportThread.Execute;
var
Lease: IXLSWorkbookReadLease;
Sheet: TXLSWorksheet;
Row: Integer;
Total: Double;
begin
// 쓰기가 진행 중이면 EXLSWorkbookReadLeaseUnavailable을 던진다
Lease := FWorkbook._AcquireReadLease;
Sheet := FWorkbook.Sheets[1];
Total := 0;
for Row := 1 to 50000 do
Total := Total + Sheet.Cells[Row, 3].Value;
FTotal := Total;
// 리스는 여기서 스코프를 벗어난다. 참조 카운트가 0으로 떨어지고,
// ReleaseReadLease가 돌며 쓰기가 다시 가능해진다
end;
쓰기 가드는 실제로 어디에 앉는가
가장 낮은 변경 가능 계층에, 그 위의 편의 API에 두는 일은 결코 없습니다. _AcquireWriteGuard는 TXLSCellRef.SetValue 자신의 안쪽에서 불리는데, 그것으로 흘러 들어가는 모든 공개 경로 — Range.Value, 워크시트 텍스트 할당, 셀 단위 복사, 붙여넣기 — 가 미래의 래퍼가 잊을 검사를 각 래퍼가 반복하는 대신 한 번 게이트된다는 뜻입니다. 커버리지는 일부러 넓습니다. 코어가 도입된 배치 기준으로 lxHandle.pas에 가드 획득 55곳, lxHandleX.pas에 37곳입니다
게이트된 표면은 셀 값과 셀 서식, TXLSWorkbook.Open, 복사와 붙여넣기, 정의된 이름(Add, 이름 바꾸기, RefersTo, Visible, IsMacro, Comment, Delete), Name, Zoom, Visible, StandardHeight, FreezePanes, Protect, Activate 같은 워크시트 메타데이터, 페이지 설정, 페이지 나누기, Calculate에 걸칩니다. 배치가 전부입니다. 가드는 첫 필드가 쓰이기 전에 획득되지, 나중에 알림 훅으로 검증되지 않습니다. 그래서 거절된 변경은 모델을 바이트 단위로 동일하게 둡니다. 회귀 스위트는 정확히 그것을 단언합니다. 거절된 호출마다 시트 이름, 줌, 가시성, 표준 높이, 여백, 방향, 페이지 나누기 개수를 다시 읽습니다. 적재 경로는 한 계층 아래에서 같은 대우를 받습니다. 패키지 포맷을 위해 ZIP 읽기 게이트가 동시 inflate를 조율하는 곳입니다
procedure TXLSWorksheet.Activate;
var
WriteGuard: IXLSWorkbookWriteGuard;
begin
// 첫 필드가 건드려지기 전에 획득한다, 결코 뒤가 아니다
WriteGuard := FWorkbook._AcquireWriteGuard;
if not FSelected then
begin
FWorkbook.FWorkSheets.Deselect;
FSelected := True;
end;
FWorkbook.FWorkSheets.FActiveSheet := Self;
// 완료된 최외곽 가드만이 세대를 전진시킨다
WriteGuard.Complete;
end;
중첩된 쓰기는 왜 세대를 한 번만 전진시키는가
쓰기 트랜잭션이 각 가드가 아니라 스레드의 최외곽 가드로 정의되기 때문입니다. 코어는 스레드 id와 깊이와 완료 플래그를 담은 스레드별 쓰기 상태를 유지합니다. 같은 스레드의 둘째 AcquireWriteGuard는 그 상태를 찾아 새 트랜잭션을 만드는 대신 Depth를 증가시키고, Depth가 0으로 돌아올 때 — 최외곽 가드가 Complete로 표시된 채로 — 에만 FGeneration이 전진합니다. 이것이 Calculate나 Open 같은 고수준 연산이 아래에서 가드된 프리미티브 열 개를 부르고도 하나의 변경으로 등록되게 하는 것입니다. 안쪽 Complete 호출은 기록되지만 스스로 카운터를 움직이지 않고, 가드는 회계를 깨지 않고 순서대로 놓이지 않아도 됩니다
실패 방향도 똑같이 명시적입니다. 가드가 Complete 없이 놓이면 — 예외가 인터페이스 참조를 풀 때의 평범한 결과입니다 — 세대는 전진하지 않습니다. 쓰기 트랜잭션이 성공을 주장한 적이 없기 때문입니다. 그것이 무슨 뜻인지 똑똑히 보십시오. HotXLS는 부분 편집을 롤백하지 않습니다. 카운터는 성공한 트랜잭션이 완료되지 않았다고 기록하는데, 그건 캐시가 필요로 하는 정확히 그 신호입니다. 하지만 모델을 이전 상태로 복원하는 것은 참조 카운트 가드가 해줄 수 있는 일이 아닙니다. 트랜잭션 한가운데의 실패가 출고할 수 없는 모양으로 통합 문서를 남길 수 있다면, 메모리 객체를 믿기보다 소스 파일을 간직하고 다시 여십시오
세대 카운터가 사다 주는 것
스캔 없는 싼 신선도 검출입니다. Generation은 1에서 시작하고 되돌림에서 0을 건너뛰는 UInt64입니다. 그래서 0은 코어가 발행하는 값이 결코 아니며 믿을 수 있는 "관찰된 적 없음" 보초값으로 일합니다. 두 불변식이 이것을 쓸 만하게 만듭니다. 어떤 읽기 리스가 존재하는 동안 세대는 움직일 수 없고, 성공한 쓰기 트랜잭션은 정확히 한 번 증가시킵니다. 그래서 IXLSWorkbookReadLease.Generation은 리스의 전체 수명 동안 일정하게 유지되는 스냅샷이고, IXLSWorkbookWriteGuard.StartGeneration은 트랜잭션이 열렸을 때 모델이 어땠는지 쓰는 자에게 말해 줍니다. 그리드, 인쇄 미리보기, 파생 인덱스는 행을 디핑하는 대신 정수 하나를 비교할 수 있습니다
var
Lease: IXLSWorkbookReadLease;
begin
Lease := FWorkbook._AcquireReadLease;
if Lease.Generation <> FCachedGeneration then
begin
FCachedGeneration := Lease.Generation;
RebuildRowHeightCache;
end;
PaintVisibleRows;
// FCachedGeneration은 0에서 시작한다. 코어가 결코 발행하지 않는 값이므로
// 첫 패스는 언제나 재구축한다
end;
이 조율이 약속하지 않는 것
세 가지 한계는 똑똑히 말할 가치가 있습니다. 다르게 가정하는 것이 메커니즘이 오용되는 길이기 때문입니다. 첫째, 쓰기 가드는 쓰는 자들 사이의 상호 배제가 아닙니다. 코어는 리더를 쓰는 자에 대해 배제하고, 서로 다른 두 스레드가 동시에 각자 쓰기 가드를 쥘 수 있으며, 각자 독립적으로 세대를 전진시킵니다 — 회귀 테스트가 정확히 이 행동을 단언합니다. 자기 쓰기 스레드를 직렬화하는 것은 여전히 여러분의 일입니다. 둘째, 여기 있는 어떤 것도 파일 잠금이나 프로세스 간 뮤텍스가 아닙니다. 하나의 프로세스 안에서 하나의 통합 문서 인스턴스에 대해 스레드를 조율할 뿐이고, 같은 .xlsx를 여는 두 프로세스는 서로에 대해 아무것도 모릅니다. 셋째, 보장은 실제로 리스를 쥐는 호출자에게만 미칩니다. 리스 없는 읽기는 여전히 잠기지 않은 핫 패스를 걷는데, 빠르고 전적으로 보호받지 못합니다. 이것은 조율 코어지 트랜잭션 데이터베이스가 아닙니다
그 경계 안에서 쓰면 작고 정직한 프리미티브입니다. 전용 회귀 테스트 9개가 여러 리더, 양쪽 충돌 방향, 재진입, 순서 뒤섞인 해제, 중단된 트랜잭션, 스레드 간 읽기/쓰기와 쓰기/쓰기 경쟁을, Win32와 Win64에서 통과하는 1,328개 테스트 스위트 안에서 커버합니다. 크래시 안전 단계적 임시 파일 저장 경로와 짝지으면 백그라운드 내보내기는 끝까지 추론할 수 있는 것이 됩니다 — 읽는 동안 일관되고, 쓸 때 원자적입니다. 읽기 리스, 쓰기 가드, 세대 카운터는 클래식과 패키지 엔진의 일부로 HotXLS Delphi Component에 Delphi와 C++Builder용으로 실려 나가고, 켜는 데 설정이 필요 없습니다