기술 문서

Delphi에서 취소 가능한 Future를 사용한 백그라운드 PDF 렌더링

PDFium에서 페이지를 렌더링하는 작업은 동기식입니다. 라이브러리를 호출하면 전달한 비트맵으로 래스터화되며, 픽셀이 기록된 후에 제어권이 반환됩니다. 단일 줌 레벨의 화면 크기 페이지 하나인 경우 이 작업은 몇 밀리초밖에 걸리지 않아 아무도 알아차리지 못합니다. 하지만 200페이지 문서의 300dpi 내보내기나 모든 페이지를 한 번에 래스터화해야 하는 썸네일 스트립의 경우 동일한 호출에 몇 초가 걸립니다. 메인 스레드에서 이 호출을 수행하면 메시지 루프가 멈추고, 창이 다시 그려지지 않으며, Windows는 제목 표시줄에 악명 높은 "응답 없음"을 표시합니다. 작업 자체는 정확하지만, 그것을 실행한 위치가 잘못된 것입니다

해결책은 오래 걸리는 렌더링을 백그라운드 스레드로 이동시키고, 비트맵을 컨트롤에 전달할 수 있는 메인 스레드로 결과를 가져오는 것입니다. PDFium 자체는 이를 막지 않지만, "작업 스레드에서 실행하고 UI에서 응답하기"와 관련된 버그 발생 가능성이 높고 오류가 간헐적으로 발생하기 때문에 바인딩이 이 전달 과정을 안전하게 만들어야 합니다. PDFiumPas의 FPdfAsync 유닛은 장시간 렌더링이 실제로 작동하는 방식에 맞는 취소 모델과 함께 해당 패턴의 정확한 구현을 하나 제공하기 위해 존재합니다

작업의 형태

렌더링이 한 프레임보다 오래 걸리는 경우는 크게 세 가지 작업이 주를 이룹니다. 일괄 렌더링은 페이지 범위를 순회하며 각 페이지를 보통 디스크로 래스터화합니다. 다중 페이지 내보내기는 동일한 작업을 수행하지만 출력을 하나의 파일로 조립합니다. 백그라운드 페이지 렌더링은 사용자가 아직 캐시에 없는 페이지로 이동할 때 뷰어가 수행하는 작업으로, 비트맵이 메인 스레드 외부에서 생성되어 준비가 되면 표시됩니다. 이 세 가지는 모두 동일한 제약 조건을 공유합니다. UI 스레드가 호스팅할 수 없을 정도로 오래 실행되고, 궁극적으로 UI 스레드가 필요로 하는 결과를 생성하며, 사용자가 작업을 포기할 수 있다는 점입니다. 문서를 닫거나, 페이지를 스크롤하여 지나가거나, 취소를 누르면 사용자가 더 이상 원하지 않는 출력을 기다리게 하는 대신 작업이 중지되어야 합니다

마지막 제약 조건이 바로 설계를 형성하는 핵심입니다. 취소할 수 없는 렌더링은 결과가 더 이상 중요하지 않게 된 후에도 문서를 열어두고 CPU 소모하는 렌더링입니다. 따라서 이 유닛은 결과를 다시 가져오는 Future와 취소 요청을 전달하는 토큰이라는 두 가지 구성 가능한 원시 타입을 중심으로 구축되었습니다

Fire-and-forget Future

TPdfFuture<T>.Run은 작업자(worker), 응답(reply) 및 선택적 취소 토큰을 인수로 사용합니다. 이 메서드는 백그라운드 스레드에서 작업자를 시작하고, 작업자가 완료되면 메인 스레드에서 응답을 전달합니다. 제네릭 매개변수 T는 렌더링이 생성하는 모든 것으로, 종종 비트맵 핸들이나 상태 레코드가 됩니다. 작업자는 메인 스레드 외부에서 실행되며, 응답은 VCL을 터치해도 안전한 위치에서 실행됩니다

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

의도적으로 생략된 것은 어떠한 종류의 Wait도 없다는 점입니다. Future가 완료될 때까지 호출자를 차단하는 메서드는 없으며, 이는 실수로 누락된 것이 아닙니다. 메인 스레드에서 호출된 Wait는 UI 교착 상태(deadlock)를 유발하는 전형적인 방법입니다. 작업자는 Synchronize를 통해 응답을 실행하기 위해 메인 스레드가 필요한데, 메인 스레드는 Wait 내부에 묶여 있어 양쪽 모두 진행할 수 없게 됩니다. 이러한 원시 타입을 제공하지 않음으로써, Future는 이를 직접 작성하려는 사람들이 가장 자주 실패하는 패턴을 원천적으로 배제합니다. 진정으로 차단이 필요한 코드는 일반 TThread를 사용하고 그 결과를 책임져야 합니다. Future는 백그라운드 렌더링의 실제 형태인 fire-and-forget(시작 후 잊기) 경우를 위한 것입니다

결과는 TPdfFutureResult<T>에 래핑되며, 이는 세 가지 상황 중 어떤 일이 발생했는지 응답 측에 알려주는 레코드입니다. IsSuccess는 작업자가 정상적으로 반환되었고 Value에 렌더링 결과가 있음을 의미합니다. IsCancelled는 토큰이 활성화되어 작업자가 취소 지점에서 중단되었음을 의미합니다. IsFailure는 작업자에서 예외가 발생했음을 의미하며, ErrorMessage에 해당 텍스트가 포함됩니다. 응답 측은 반환된 비트맵이 실제인지 여부를 감시값(sentinel value)으로 추측하는 대신, 상태를 한 번 검사하고 분기합니다

응답 전달 방식을 변경한 v1.61.0의 경쟁 상태(Race condition)

이 유닛에서 가장 유익한 부분은 이해하는 데 시간이 꽤 걸렸던 한 줄의 변경 사항입니다. 초기 버전 내내 작업자 스레드는 TThread.Queue를 사용하여 응답을 전달했습니다. Queue는 응답을 메인 스레드의 대기열에 게시하고 즉시 반환하므로, fire-and-forget Future가 원하는 것과 정확히 일치하는 것처럼 보입니다. 하지만 이것은 잘못된 것이었으며, 작성하려는 모든 테스트를 통과하는 종류의 버그이므로 그 이유를 자세히 설명할 가치가 있습니다

작업자 스레드는 FreeOnTerminate := True로 생성됩니다. 즉, Execute가 반환되는 즉시 스레드가 자체적으로 해체되고, TThread.Destroy가 정리 작업의 일부로 RemoveQueuedEvents(Self)를 호출합니다. RemoveQueuedEvents는 소멸하는 스레드를 대상으로 하는 대기 중인 모든 메서드를 제거합니다. 따라서 순서는 다음과 같았습니다. 작업자가 완료되고, 자신에 대해 응답을 대기열에 추가한 다음, Execute가 반환되고, 스레드가 스스로 파괴되며, RemoveQueuedEvents는 메인 스레드가 아직 실행하지 않은 응답을 삭제합니다. 결과가 단순히 사라져 버린 것입니다. 더 나쁜 것은, 메인 스레드가 대기 중인 응답을 가져와 실행하기 시작하는 것과 동시에 스레드가 해제되는 좁은 타이밍 창에서, 응답이 절반쯤 파괴된 객체의 필드를 건드리게 되어 해제 후 사용(use-after-free) 오류가 발생한다는 점입니다

v1.61.0에서의 수정 사항은 Queue 대신 Synchronize로 응답을 전달하는 것이었습니다. Synchronize는 메인 스레드가 응답을 완료할 때까지 작업자 스레드를 차단합니다. 응답이 실행되는 동안 작업자는 여전히 살아 있으므로 중간에 해제될 대상이 없으며, 스레드는 응답이 전달될 때까지 Execute에서 반환되지 않습니다(따라서 스스로 파괴를 시작하지도 않습니다). 이로써 전달이 보장되고 해제 후 사용(use-after-free) 발생 창이 닫힙니다

procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // already cancelled? skip the worker
    FResult.Value := FWorker(FToken);
  except
    on E: EPdfOperationCancelled do
    begin
      FResult.Status := pfsCancelled;
      FResult.ErrorMessage := E.Message;
    end;
    on E: Exception do
    begin
      FResult.Status := pfsFailure;
      FResult.ErrorMessage := E.Message;
    end;
  end;

  if Assigned(FReply) then
    // Synchronize, not Queue: this thread is FreeOnTerminate, so a queued reply
    // could be dropped by RemoveQueuedEvents before the main thread ran it.
    Synchronize(DispatchReply);
end;

이 일반적인 교훈은 특정 수정 사항을 넘어 더 오래 지속됩니다. Fire-and-forget 비동기 콜백은 미묘하게 틀리기 가장 쉬운 동시성 패턴입니다. 왜냐하면 일반적인 정상 경로는 첫 시도에 잘 작동하고, 버그는 스레드 해체 순서와 대기열 간의 상호 작용 속에 숨어 있기 때문입니다. 이 버그는 원할 때마다 재현되지 않습니다. 메인 스레드가 대기열을 비우는 작업이 작업자가 스레드 파괴를 완료하기 전에 우연히 일어났는지 여부에 따라 달라지며, 이는 스케줄러가 실행할 때마다 다르게 결정하는 타이밍 문제입니다. 바인딩 내에서 한 번 정확하게 구현된 원시 타입은 백그라운드 렌더링이 필요한 모든 애플리케이션에서 동일한 코드를 다시 도출하는 것보다 훨씬 더 큰 가치가 있습니다

콜백이 메서드 포인터인 이유

작업자(worker)와 응답(reply)은 익명 메서드(anonymous methods)가 아닙니다. 이들은 procedure of object 타입인 TPdfFutureWorker<T>TPdfFutureReply<T>이며, 이러한 선택은 컴파일러 매트릭스에 의해 강제된 것입니다. PDFiumPas는 Delphi XE5 이상 및 Delphi 모드의 Free Pascal 3.2에서 컴파일되는데, 해당 모드의 FPC 3.2는 익명 메서드를 지원하지 않습니다. 로컬 변수를 캡처하는 프로시저 참조(reference-to-procedure) 콜백은 Delphi에서는 컴파일되지만 FPC에서는 실패하므로, 이 유닛은 두 컴파일러가 모두 수용하는 최소 공통 분모를 사용합니다

이러한 결정의 실질적인 결과는 상태(state)가 유지되는 위치입니다. 익명 메서드는 로컬 변수를 클로저(closure)로 캡처하지만 메서드 포인터는 그렇지 않습니다. 따라서 페이지 인덱스, 줌 레벨, 출력 경로 등 작업자에게 필요한 모든 상태와 대상 이미지 컨트롤이나 진행 상태 레이블 등 응답에서 업데이트해야 하는 모든 상태는 전달되는 메서드를 소유한 객체에 종속되어야 합니다. 뷰어에서 이 객체는 일반적으로 폼(form)이나 폼이 소유한 렌더러 컨트롤러입니다. 이것은 마지못해 적용된 임시방편이 아닙니다. 이 방식은 상태가 클로저 내부에 숨겨지지 않고, 이를 수신하는 객체에서 상태의 소유권을 명확하고 눈에 띄게 유지하도록 해줍니다

강제 종료(Hard kill)가 아닌 협력적 취소(Cooperative cancellation)

여기서의 취소는 협력적(cooperative)입니다. 렌더링 도중 스레드를 종료하면 PDFium이 잠금 상태를 유지하고 부분적으로 작성된 비트맵을 남기게 되며, 강제 종료 후의 프로세스 상태는 예측하기 어렵기 때문에 작업자 스레드에 접근하여 종료하는 API는 없습니다. 대신 작업자에게는 읽기 전용 토큰이 전달되고 이를 확인하도록 요구되며, 렌더링 루프는 중단하는 것이 깔끔한 페이지 사이나 타일 사이에서 토큰을 확인하도록 작성됩니다

토큰은 취소를 관찰할 수 있는 세 가지 방법을 제공합니다. IsCancelled는 스스로 테스트하고 결정하려는 루프를 위한 저렴한 부울 검사(boolean poll)입니다. ThrowIfCancelled는 일반적인 경우입니다. 자연스러운 취소 지점에서 이 메서드를 호출하고, 취소가 요청된 경우 EPdfOperationCancelled를 발생시켜 작업자를 곧바로 Future로 되돌립니다(unwind). RegisterCallback은 소스가 취소될 때 한 번 실행되는 일회성 알림을 연결하며, 루프 안에서 맴돌지 않고 작업자가 중단할 수 있는 무언가에 차단되어 있을 때 유용합니다

예외는 스레드 경계가 중요해지는 부분입니다. 작업자가 EPdfOperationCancelled를 발생시키면, Future가 이를 잡아 취소된 상태로 전환하므로 응답 측은 실패가 아닌 IsCancelled로 보게 됩니다. 예외 객체 자체는 메인 스레드로 마샬링되지 않습니다. 이는 작업자 스레드 내에서 생성되고 소멸하며, 해당 메시지 문자열만 ErrorMessage에 복사됩니다. 살아있는 예외 객체를 스레드 간에 마샬링한다는 것은 종료 중인 스레드가 소유한 메모리에 접근하는 것을 의미하며, 이는 Synchronize 수정이 방지하고자 했던 것과 동일한 종류의 오류입니다. 상태 코드와 문자열은 경계를 깔끔하게 넘어갈 수 있지만, 객체는 그렇지 않습니다

작업자가 스스로 취소할 수 없도록 하는 두 개의 인터페이스

취소가 두 개의 인터페이스로 분할된 것은 의도적인 것입니다. IPdfCancellationTokenSource는 쓰기 측면입니다. Cancel 메서드를 가지고 있으며, 보통 폼(form)인 생성 소유자가 이를 보관하고 사용자가 버튼을 클릭하거나 폼이 닫힐 때 Cancel을 호출합니다. IPdfCancellationToken은 읽기 측면입니다. IsCancelled, ThrowIfCancelledRegisterCallback을 가지고 있으며, 이것이 작업자가 받는 전부입니다. 하나의 구체적인 객체가 두 가지를 모두 구현하지만, 작업자에게는 토큰만 전달되므로 자신이 실행 중인 작업을 취소할 수 있는 방법이 없습니다. 이 분할은 API 수준의 안전 장치(guard rail)입니다. 토큰을 통해 Cancel에 도달할 수 있는 작업자라면 잘못 작성된 코드가 자기 자신을 취소하는 상황을 초래할 수 있지만, 타입 시스템이 이러한 가능성을 원천적으로 제거합니다

호출자가 렌더링을 원하지만 절대 취소할 의도가 없는 경우에 해당하는 세부 사항도 있습니다. 호출마다 새로운 소스를 강제하는 대신, 유닛은 영구적으로 취소되지 않은 상태인 싱글톤 토큰 PdfNoCancellationToken을 노출합니다. Run 메서드는 토큰 인수가 nil로 남겨졌을 때 이 싱글톤으로 대체합니다. 이 싱글톤은 처음 사용할 때 느리게(lazily) 생성되는 것이 아니라 유닛 초기화 시점에 적극적으로(eagerly) 생성되는데, 그 이유는 다시 동시성(concurrency) 때문입니다. 만약 서로 다른 작업자 스레드에 있는 여러 Run 호출이 동시에 지연 생성되는 싱글톤에 접근한다면, 생성 시 경쟁 상태가 발생하거나 중복 객체가 유출되거나 절반만 초기화된 인스턴스를 잠시 관찰할 위험이 있습니다. 어떤 작업자든 실행되기 전에 이를 미리 빌드해 두면 경쟁 상태를 완전히 제거할 수 있습니다

취소 가능한 렌더링 실행하기

실제로 사용할 때는 소스를 생성하여 폼에 유지하고, 작업자 메서드 및 응답 메서드와 함께 소스의 TokenRun에 전달한 다음, 취소 버튼을 소스에 연결합니다. 작업자는 렌더링하는 동안 토큰을 확인하고, 결과가 반환되면 응답 메서드가 UI를 업데이트합니다. 콜백이 메서드 포인터이기 때문에 작업자와 응답 메서드는 폼의 필드에서 필요한 모든 것을 자유롭게 읽을 수 있습니다

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // field, lives on the form
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // worker observes this at its next cancel point
end;

// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // clean stop between pages
    RenderOnePage(PageIndex);       // synchronous PDFium rasterisation
  end;
  Result := True;
end;

// Runs on the main thread. Safe to touch the VCL here.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
  if AResult.IsSuccess then
    StatusLabel.Caption := 'Render complete'
  else if AResult.IsCancelled then
    StatusLabel.Caption := 'Cancelled'
  else
    StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;

세 가지 결과 모두 도달 가능하므로 응답 메서드는 이 세 가지를 모두 처리합니다. 렌더링이 완료되면 성공을 보고하고, 취소를 누른 사용자는 취소 분기를 보게 되며, 작성할 수 없는 파일이나 구문 분석에 실패한 페이지는 메시지와 함께 실패로 도착합니다. 이러한 분기 중 어느 것도 실행을 차단하지 않으며 작업자 스레드를 건드리지도 않습니다. 또한 작업자가 생성한 비트맵이나 상태는 Future가 UI를 소유한 스레드에 전달한 후에만 읽혀집니다

동일한 스레딩 원칙은 뷰어의 다른 부분에서도 그 가치를 발휘합니다. 렌더링된 비트맵이 줌 변경 전반에 걸쳐 유지되고 재사용되는 방식은 렌더 캐시 및 줌 성능에 대한 당사 노트에서 다루며, Delphi 환경에서 PDFium 경계를 안전하게 유지하는 광범위한 주제는 메모리 안전을 위한 PDFium Component ABI 강화에서 확인할 수 있습니다. 여기에 설명된 비동기 인프라는 이 블로그의 다른 곳에서 다루는 렌더링, 텍스트 및 폼(form) API와 함께 Delphi 및 C++Builder용 PDFium Component의 일부로 제공됩니다