PDFium은 모듈 수준에서 스레드 안전하지 않으므로, 두 스레드에서 서로 다른 파일을 다루는 두 TPdf 인스턴스도 여전히 서로를 손상시킬 수 있습니다. Delphi용 PDFium Component는 이것을 두 가지 방식으로 다룹니다. v3.125.1부터 ValidatePdfFilesParallel은 모든 네이티브 PDFium 호출을 프로세스 전역 락 하나 뒤에서 직렬화하고, TPdf.RenderPagesParallel은 각 워커에게 PDFium 모듈의 고립된 사본을 하나씩 줍니다. 수정을 강요한 버그는 가장 나쁜 종류의 간헐성이었습니다. 일괄 검증 테스트는 대부분의 시간에 통과하다가, 정상적인 두 파일 중 하나를 실패로 보고하다가, 같은 프로세스의 다음 테스트를 액세스 위반으로 크래시시키다가, 어쩌면 스택 트레이스 대신 종료 코드로 러너 전체를 끌어내리곤 했습니다. 테스트는 아무 잘못이 없었고 어떤 단일 문서도 아무 잘못이 없었습니다. 가정이 틀렸습니다. 스레드당 TPdf 하나는 고립이 아닙니다
스레드당 TPdf 하나로는 왜 부족할까?
스레드당 TPdf 하나로는 부족합니다. PDFium이 안전하지 않은 상태를 문서가 아니라 모듈에 보관하기 때문입니다. 각 TPdf는 자기 FPDF_DOCUMENT 핸들을 소유하지만, 프로세스의 모든 핸들은 같은 로드된 DLL이 서비스하며 그 DLL은 프로세스 전역 싱글턴을 쥐고 있습니다. 폰트 캐시, 페이지 모듈, 그리고 문서 로딩, 파싱, 렌더링이 모두 만지는 다른 전역 구조들이요. 서로 무관한 파일 두 개를 로드하는 두 스레드는 같은 폰트 캐시에 동시에 쓰는 두 스레드입니다. 그 데이터를 Delphi 쪽에서 소유하는 이는 없으므로, Delphi 쪽에서 문서별로 잠글 방법도 없습니다
컴포넌트에는 락이 있고, 그것에서 잘못된 결론을 그리기 쉽습니다. TPdf는 자기 렌더 경로를 내부 크리티컬 섹션(EnterRenderLock / LeaveRenderLock, TPdf의 private 메서드)으로 감쌉니다. 그 락은 인스턴스별입니다. 두 스레드가 같은 TPdf를 동시에 몰고 가는 것, 이건 진짜 위험입니다, 막아 주지만 다른 스레드의 두 번째 인스턴스는 볼 수 없으므로, 인스턴스 간 동시성은 그것을 지나쳐 갑니다. 일반 규칙은 한 줄로 말하기에 충분히 단순합니다. 하나의 로드된 PDFium 모듈 안에서는, 열려 있는 문서가 몇 개든, 어느 순간에도 최대 한 스레드만 PDFium 안에 있을 수 있습니다
문서 간 손상은 Delphi 프로세스에서 어떤 모습일까?
문서 간 손상은 무관한 실패들의 무작위 혼합처럼 보이며, 피해는 그것을 일으킨 코드보다 오래 삽니다. v3.125.1 전의 ValidatePdfFilesParallel은 워커 스레드마다 TPdf 하나를 만들어 Active := True와 preflight 리포트 빌드를 공유 모듈 위에서 동시에 돌렸습니다. Delphi와 Free Pascal 빌드 양쪽에서 본 증상은 범위 전체를 커버했습니다:
- 유효한 파일이 로드에 실패하거나, 통과했어야 하는데 일괄 처리에서 실패로 돌아옵니다
- 액세스 위반이 나중의 무관한 호출에서 표면화됩니다. 흔히 다른 테스트나 다른 문서에서요
- Delphi에서
External exception C000001D가 나타납니다. 그 코드는STATUS_ILLEGAL_INSTRUCTION으로, 불변식이 깨졌을 때 PDFium의 내부CHECK와IMMEDIATE_CRASH매크로가 실행하는ud2명령이 일으킵니다 - 프로세스는
0xC0000409(fail-fast, 스택 버퍼 오버런으로 보고됨)나0xC0000374(힙 손상)로 종료되며, Delphi 예외는 전혀 없습니다
마지막 두 항이 버그를 붙잡기 어렵게 만든 이유입니다. 병렬 검증은 끝났고, 손상된 전역 상태는 뒤에 남았고, 같은 프로세스의 다음 픽스처가 그것에 걸려 넘어졌습니다. 어느 Delphi Win64 회귀 실행에서는 C000001D 실패 물결이 일괄 검증을 전혀 건드리지 않는 테스트를 때렸습니다. 그저 피해 뒤에 PDFium을 쓴 첫 코드였을 뿐입니다. 측정된 숫자는 규모를 명확히 합니다. 같은 샘플을 워커 둘로 돌린 Delphi 탐침은 한 실행에서 160개 문서 중 122개를, 다른 실행에서 138개를 실패시켰고, 그 실행 중 하나는 External exception C000001D를 곧장 일으켰습니다. 8문서, 4워커, 5라운드의 스트레스 사례는 Free Pascal Win64에서 5번 실행 중 5번 실패하거나 크래시됐습니다. 수정 뒤 같은 탐침은 1,200개 문서 중 0개를 실패시켰습니다
v3.125.1부터 ValidatePdfFilesParallel은 어떻게 안전하게 유지되나
ValidatePdfFilesParallel은 이제 각 작업의 네이티브 절반을 직렬화하고 관리되는 절반은 병렬로 유지합니다. 모든 워커는 자기 TPdf를 만들기 전에 유닛 수준 크리티컬 섹션 하나를 쥐고, FileName, Active := True, preflight 리포트 빌드, Free를 통과할 때까지 쥡니다. 생성과 파괴가 락 안에 있는 것은 의도입니다. 문서 닫기도 로딩만큼 모듈로 콜백하기 때문입니다. 워커가 캡처한 TPdfPreflightReport 레코드를 손에 넣으면 락을 놓고 그 레코드에 대해 검증 규칙을 평가하는데, 그것은 PDFium 상태를 만지지 않으므로 한 파일의 규칙 평가가 다음 파일의 PDFium 작업과 겹칩니다
작은 변경 둘이 수정과 함께 왔습니다. 로드 실패는 이제 LastLoadReport.ErrorMessage와 함께 EPdfError를 일으키므로, 항목의 ErrorMessage는 2차적인 "no active document" 오류 대신 실제 파싱 문제를 이름으로 말합니다. 그리고 비용은 정직하게 밝혀집니다. 일괄 처리의 PDFium 부분은 이제 직렬이므로, 파싱과 preflight가 지배하는 일괄 처리에서는 워커를 더 얹어도 얻는 게 적습니다. v3.125.1 이전 버전이라면 WorkerCount를 1로 설정하세요. 동시성과 그에 따른 손상이 함께 사라집니다
uses
System.SysUtils, PDFium, FPdfPreflightReport;
procedure ValidateBatch(const Files: array of string);
var
Registry: TPdfValidationRuleRegistry;
Options: TPdfBatchValidationOptions;
Report: TPdfBatchValidationReport;
I: Integer;
begin
Registry := CreateDefaultPdfValidationRuleRegistry;
try
Options := TPdfBatchValidationOptions.Default;
Options.WorkerCount := 4; // 0 = 프로세서 수, 8으로 제한
Options.Standards := [ppsPdfA];
// 명시적 레지스트리를 넘기면 맞는 프로파일을 직접 고릅니다.
// 빈 Profiles 목록은 등록된 모든 규칙을 돌리고, 미리 preflight하지
// 않은 표준의 규칙은 "did not pass"를 보고합니다
SetLength(Options.ValidationOptions.Profiles, 1);
Options.ValidationOptions.Profiles[0] := 'PDF/A';
Report := ValidatePdfFilesParallel(Files, Registry, Options);
finally
Registry.Free;
end;
for I := 0 to High(Report.Results) do
case Report.Results[I].Status of
pbvisPass: Writeln('PASS ', Report.Results[I].FileName);
pbvisFail: Writeln('FAIL ', Report.Results[I].FileName);
pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
Report.Results[I].ErrorMessage);
else
Writeln('SKIP ', Report.Results[I].FileName); // pbvisCancelled
end;
Writeln(Report.PassedDocumentCount, ' passed, ',
Report.FailedDocumentCount, ' failed, ',
Report.ErrorDocumentCount, ' errors');
end;
nil을 레지스트리로 넘기는 것이 더 짧은 길입니다. ValidatePdfFilesParallel이 그러면 기본 레지스트리를 스스로 만들고, Options.Standards에서 프로파일 목록을 유도하고, 돌아올 때 레지스트리를 해제합니다. 결과는 워커가 어떤 순서로 끝났든 항상 입력 순서로 돌아옵니다. 리포트 형식과 같은 엔진을 감싼 명령줄 래퍼는 PDFium Component CLI로 일괄 PDF preflight 리포트 만들기를, PDF/A 검사 자체가 무엇을 커버하는지는 Delphi에서 PDF/A preflight 검증하기를 참조하세요
RenderPagesParallel은 페이지를 어떻게 진짜로 병렬로 돌리나?
TPdf.RenderPagesParallel은 병렬로 돕니다. 워커들이 PDFium 모듈을 결코 공유하지 않기 때문입니다. 이 메서드는 먼저 호출 스레드에서 활성 문서를 소스 스토어에 저장합니다. 그다음 각 워커는 로드된 PDFium DLL을 temp 디렉터리의 유일한 이름을 가진 파일로 복사하고, 그 사본을 LoadLibrary로 로드하고, 초기화합니다. Windows는 다른 경로에서 로드된 DLL을 다른 모듈로 취급하므로, 각 사본은 자기 전역 변수를 받습니다. 자기 폰트 캐시, 자기 페이지 모듈, 자기 모든 것이요. 워커는 자기 사유 모듈에서 저장된 문서를 열고, 단계 사이에 취소 검사를 넣으며 페이지를 점진적으로 렌더링한 뒤, 라이브러리를 파괴하고 사본을 언로드하고 파일을 지웁니다
고립은 공짜가 아니며 기본값이 그것을 반영합니다. 각 워커는 디스크의 DLL 사본, 메모리의 두 번째 PDFium 전역 집합, 문서의 새 파싱을 지불합니다. MaxWorkers = 0은 최대 4워커를 뜻하고, MaxPixelsPerPage와 MaxTotalOutputBytes는 날 출력을 제한하며, 반전과 야간 이중톤 렌더 옵션은 버퍼가 날(raw) 상태로 돌아오므로 거부됩니다. 결과는 TPdfParallelRenderReport이며, 그 Results 배열은 요청한 페이지마다 위에서 아래로 읽는 32비트 버퍼를 요청 순서로 담습니다
procedure RenderAllPages(Pdf: TPdf);
var
Options: TPdfParallelRenderOptions;
Report: TPdfParallelRenderReport;
Pages: array of Integer;
I: Integer;
begin
SetLength(Pages, Pdf.PageCount);
for I := 0 to High(Pages) do
Pages[I] := I + 1; // 페이지 번호는 1 기반
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// 소스 스냅샷은 공유 모듈에서 찍히므로, 다른 스레드도 TPdf를 쓴다면
// 프로세스 전역 PDFium 락을 쥘 것
PdfiumLock.Acquire;
try
Report := Pdf.RenderPagesParallel(Pages, Options);
finally
PdfiumLock.Release;
end;
for I := 0 to High(Report.Results) do
if Report.Results[I].Status = pprsSucceeded then
SavePageBuffer(Report.Results[I]) // Width, Height, Stride, PixelFormat, Pixels
else
Writeln('Page ', Report.Results[I].PageNumber, ': ',
Report.Results[I].ErrorMessage);
end;
호출 주위의 락에 주목하세요. 워커 모듈은 사유지만, 시작의 스냅샷 단계는 호출 스레드에서 공유 모듈로 SaveAs를 돌립니다. 프로세스의 다른 무엇도 동시에 TPdf를 만지지 않는다면 락을 빼도 되고, 무엇이든 만진다면 스냅샷은 다른 모든 공유 모듈 호출과 같은 보호를 필요로 합니다
| 패턴 | 문서 간 안전 | PDFium 작업이 병렬로 돌아가는가 | 비용 |
|---|---|---|---|
스레드마다 TPdf 하나, 공유 락 없음 | 아니요 | 예, 손상될 때까지 | 간헐적 크래시, 손상된 프로세스 상태 |
| 모든 PDFium 호출을 감싸는 프로세스 전역 락 하나 | 예 | 아니요 | PDFium 부분은 직렬 |
v3.125.1부터의 ValidatePdfFilesParallel | 예 | 아니요, 규칙 평가는 병렬 | 파싱과 preflight는 직렬 |
TPdf.RenderPagesParallel | 예 | 예 | 워커마다 DLL 사본, 메모리, 새 파싱 |
자기 멀티스레드 PDFium 코드는 어떻게 구조화해야 할까?
자기 스레드는 프로세스 전역 락 하나를 공유해 쓰는 모든 TPdf의 전 생애 동안 그것을 쥐거나, 모듈을 대신 고립시켜 주는 컴포넌트 API를 써야 합니다. 락은 스레드별, 폼별, 문서별이 아니라 프로세스 전체를 위한 하나의 오브젝트여야 합니다. 두 스레드가 공유하지 않는 락은 아무것도 보호하지 않습니다. 아래 패턴은 v3.125.1부터 컴포넌트가 내부적으로 하는 일을 그대로 반영합니다. 락 안에서 생성, 로드, 읽기, 해제를 하고, PDFium을 만지지 않는 모든 것은 그 밖에서 합니다
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // 프로세스 전체에 하나의 락
type
TTextExtractThread = class(TThread)
private
FFileName: string;
FText: string;
protected
procedure Execute; override;
public
constructor Create(const AFileName: string);
property ExtractedText: string read FText;
end;
constructor TTextExtractThread.Create(const AFileName: string);
begin
inherited Create(True);
FFileName := AFileName;
end;
procedure TTextExtractThread.Execute;
var
Pdf: TPdf;
Page: Integer;
Raw: TStringBuilder;
begin
Raw := TStringBuilder.Create;
try
PdfiumLock.Acquire;
try
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FFileName;
Pdf.Active := True;
if not Pdf.Active then
raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
for Page := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := Page;
Raw.AppendLine(Pdf.Text);
end;
finally
Pdf.Free; // 문서 닫기도 PDFium 작업임
end;
finally
PdfiumLock.Release;
end;
// 이 줄 아래에는 PDFium이 없으므로 이 부분은 병렬로 돕니다
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
몇 가지 규칙이 이 패턴을 실제 애플리케이션에서 정직하게 유지합니다:
TPdf.Create와Free를 락 안에 넣으세요. 눈에 띄는 호출만이 아닙니다. 로딩, 닫기,PageCount같은 속성 읽기, 페이지 변경, 텍스트 추출, 렌더링, 저장이 모두 모듈 안으로 손을 뻗습니다- 대입한 뒤
Active를 검사하세요. 실패한 로드는Active를False로 두고,LastLoadReport.ErrorMessage가 이유를 말합니다 - 호출별이 아니라 문서별로 락을 쥐세요. 더 잘게 잠그는 것은 원리상 가능하지만, 어떤
TPdf멤버도 그 밖에서 돌지 않는다는 조건이 붙고, 컴포넌트 자신이 의지하는 것은 엉성한 버전입니다 - 느린 비 PDFium 작업, 데이터베이스 쓰기, 인덱싱, 네트워크 호출 같은 것은 락 밖에 두세요. 느린 소비자 하나가 모든 것을 직렬화합니다
- 사유 인스턴스별 렌더 락을 대용으로 다루지 마세요. 그것은 하나의
TPdf를 자기 자신으로부터 지킬 뿐입니다
같은 주의가 여러분이 날 스레드로 쓰지 않은 코드에도 적용됩니다. 백그라운드 퓨처는 긴 렌더를 UI 스레드에서 떼어 놓는 좋은 방법입니다. 취소 가능한 퓨처로 PDF 배경 렌더링하기에서 기술하듯이요. 하지만 퓨처 실행기는 자체 전역 PDFium 락을 얹지 않습니다. 여러 퓨처가 동시에 서로 다른 TPdf 인스턴스를 몰 수 있다면 각 워커 안에서 같은 프로세스 전역 락을 쥐고, 메인 스레드의 뷰어를 공유 모듈의 또 한 명의 클라이언트로 다루세요. 비동기 API를 통한 인스턴스 간 사용은 별도로 감사된 적이 없으므로, 보수적인 가정은 그것이 손으로 쓴 스레드와 같은 직렬화를 필요로 한다는 것입니다. 페이지 렌더링 외의 진짜 PDFium 병렬성이 필요하면, 별도 워커 프로세스가 구조적으로 각 작업에게 자기 모듈을 줍니다
빠른 참조: Delphi를 위한 PDFium 스레딩 규칙
- PDFium의 안전하지 않은 상태는 모듈 전역입니다. 폰트 캐시, 페이지 모듈, 그 밖의 전역 변수가 프로세스의 모든 문서에 의해 공유됩니다
- 스레드당
TPdf하나는 아무것도 고립시키지 않습니다. 두 스레드의 두 인스턴스는 여전히 서로를 손상시킬 수 있습니다 - 전형적 증상은 로드 실패, 이후 코드의 액세스 위반,
External exception C000001D,0xC0000409나0xC0000374로의 종료입니다 - 손상은 프로세스에 남으므로, 실패하는 호출이 흔히 원인이 아닙니다
ValidatePdfFilesParallel은 v3.125.1부터 안전합니다. 구형 버전에서는WorkerCount := 1을 쓰세요TPdf.RenderPagesParallel은 진짜 병렬입니다. 각 워커가 PDFium 모듈의 고립된 사본을 로드하기 때문입니다- 자기 스레드, 태스크, 퓨처는 각
TPdf를Create부터Free까지 커버하는 프로세스 전역 락 하나가 필요합니다
PDFium Component는 Delphi용 PDFium 엔진을 일괄 preflight와 검증, 고립된 병렬 렌더링, 취소 가능한 배경 작업, 상세한 로드 진단과 함께 감쌉니다. 세부와 에디션은 PDFium Component 제품 페이지에 있습니다