기술 문서

델파이 PDFium 비동기 렌더링의 WaitForIdle 데드락

PDFium Component의 실행기는 작업의 응답이 디스패치될 때까지 그 작업을 완료된 것으로 간주하지 않기 때문에 배치 렌더링이 중간에 멈춰버립니다. padSynchronize 아래에서는 그 응답이 메인 스레드에서 실행됩니다. 메인 스레드가 CheckSynchronize를 펌핑하지 않고 블록되면, 워커는 메인 스레드를 기다리는데 메인 스레드는 유휴 상태를 기다립니다

디버거 화면은 한 번 본 적이 있다면 틀림없이 알아볼 수 있습니다. 멈춘 프로세스를 일시 정지하면 메인 스레드는 여러분 자신의 배치 루프보다 몇 프레임 아래, 유휴 이벤트에 대한 대기 안에 앉아 있습니다. 워커 스레드로 전환하면 TThread.Synchronize 안에서 넘겨줄 수 없는 완료된 결과를 쥔 채 앉아 있습니다. 아무것도 스핀하지 않고 CPU도 타지 않으며, 프로세스는 그저 주차되어 있을 뿐입니다. 이 글은 그런 상태가 애초에 왜 존재하는지, 그리고 PDFium 위에서 동작하는 델파이 워커 풀이 얌전히 동작할지 사람을 무는지를 결정하는 세 가지 인접 규칙에 관한 것입니다: QueueCapacity가 실제로 무엇을 제한하는지, 종료가 어떤 순서로 취소되어야 하는지, 그리고 PDFium 객체 소유권과 관련해서 병렬성이 사주지 않는 것은 무엇인지입니다

WaitForIdle이 메인 스레드를 멈추게 하는 이유는 무엇인가

TPdfAsyncExecutor에서 유휴 상태는 워커의 완료뿐 아니라 응답 디스패치까지 포함하도록 정의되어 있기 때문에 멈춥니다. 실행 중인 개수는 워커가 작업을 집어 들 때 DequeueTask에서 증가하고, TaskFinished에서 감소하는데, 워커는 TPdfAsyncTaskOperation.Execute가 반환된 뒤에야 그것을 호출합니다. 그 메서드는 워커 본문을 실행하고, 결과를 기록한 다음, TPdfAsyncDispatchMode에 따라 응답을 디스패치합니다. padSynchronize에서는 그 디스패치가 TThread.Synchronize 호출이므로, 메인 스레드가 그것을 실행할 때까지 Execute는 반환되지 않습니다

델파이는 그 계약의 나머지 절반을 여러분에게 맡깁니다. TThread.Synchronize는 그 메서드를 전역 큐에 덧붙이고 호출한 스레드를 이벤트에 블록시킵니다. 메인 스레드의 무언가가 그 이벤트가 신호되기 전에 CheckSynchronize를 호출해야 합니다. VCL 메시지 루프는 메시지 사이에서 이것을 자동으로 해주는데, 이것이 바로 이 버그가 대화형 사용 중에는 보이지 않다가 블로킹 배치 루프를 작성하는 순간 나타나는 이유입니다. 블로킹되는 메인 스레드는 메시지 루프를 벗어난 메인 스레드이며, 메시지 루프 밖의 메인 스레드는 아무도 배출시키지 않습니다

uses
  System.Classes, FPdfAsync, PDFium;

// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // the main thread now parks in a kernel wait

// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.

작업 완료와 실행기 유휴는 서로 다른 두 이정표다

이 둘은 의도적으로 분리되어 있으며, 여러분이 어느 쪽을 기다리고 있는지 아는 것이 수정 전체입니다. IPdfAsyncTask.WaitFor는 워커 결과가 결정되는 즉시 만족됩니다: Complete는 응답이 고려되기도 전에 최종 TPdfAsyncTaskState를 쓰고 완료 이벤트를 세팅합니다. TPdfAsyncExecutor.WaitForIdle은 그보다 나중에, 큐잉된 개수와 실행 중인 개수가 모두 0이 될 때 만족되며, 실행 중인 개수는 응답이 도착하기 전까지는 줄어들지 않습니다. 그래서 작업은 실행기가 여전히 정당하게 바쁜 동안에도 patsSucceeded이면서 Snapshot을 통해 관찰 가능할 수 있습니다

// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // release any pending padSynchronize reply
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

새겨둘 가치가 있는 결과 하나: 예외를 던지는 응답이 역사를 다시 쓰지는 않습니다. DispatchReply는 예외를 잡아 ReplyErrorMessage에 저장하며, State, CancellationReason, ErrorMessage는 워커가 결정한 그대로 정확히 남겨둡니다. 그래서 썸네일을 그리다가 폭발하는 UI 콜백이 성공한 렌더링을 실패한 것으로 바꿔놓는 일은 결코 없으며, 여러분의 원격 측정은 렌더링 엔진이 실제로 한 일을 계속 보고합니다. 풀이 아니라 단일 연산 주변의 콜백 형태 API를 원한다면, 취소 가능한 퓨처를 사용한 백그라운드 렌더링이 그 경로를 다룹니다

QueueCapacity는 실행 중인 워커도 제한하는가

아닙니다. PDFium Component의 QueueCapacity는 큐에 있는 작업만 셀 뿐, 이미 워커에서 실행 중인 것은 결코 세지 않습니다. 이는 의도적입니다: 용량은 대기 줄에 대한 실제 배압을 표현하기 위한 것이며, 고정된 동시성 슬롯을 같은 숫자에 접어 넣으면 두 번 세게 됩니다. 워커 네 개와 용량 여덟에서 열두 개의 작업이 진행 중일 수 있으며, GetStatsQueuedCountRunningCount를 통해 그 분리를 정직하게 보고합니다

var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit never raises: it returns False when the waiting line is full or
  // the executor is already shutting down, and bumps RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
    // WorkerCount and is never charged against the capacity.
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

TPdfAsyncPriority의 네 개 차선은 가중치가 아니라 엄격합니다. DequeueTaskpapCritical부터 papLow까지 내려가며 비어 있지 않은 첫 차선을 취하고, 각 차선 안에서는 FIFO 순서를 보존합니다. 이는 대화형 요청이 아직 시작되지 않은 배치를 깔끔하게 앞지를 방법을 주지만, 이미 실행 중인 작업을 결코 방해하지는 않으며, 계속 papCritical을 먹이는 호출자는 papLow를 무기한 굶길 수 있습니다. 상위 두 차선은 사람이 눈으로 기다리고 있는 것들을 위해 남겨두고, 대량 내보내기는 papNormal이나 그 이하에 두십시오. 가득 찬 큐가 EPdfAsyncQueueFull 값어치의 프로그래밍 오류일 때는 Submit을, 그것이 여러분이 처리하려는 정상적인 조건일 때는 TrySubmit을 사용하십시오

Shutdown이 실행기 잠금 밖에서 취소하는 이유는 무엇인가

그 안에서 취소하면 잠금 순서를 뒤집어 여러분이 수행하려는 종료 자체를 멈춰버리기 때문입니다. Shutdown(True)은 실행기 잠금을 잡고, 종료 플래그를 뒤집은 다음, AppendSnapshot을 통해 대기 중인 모든 작업을 로컬 스냅샷 배열에 덧붙입니다. 그런 다음 잠금을 해제하고 그 이후에야 스냅샷을 순회하며 각 항목에 대해 Cancel을 호출합니다. 작업을 취소하면 그 토큰 소스에 등록된 사용자 콜백이 발동하는데, 그 콜백들은 평범한 애플리케이션 코드입니다: GetStats를 조회하거나, 보상 작업을 제출하거나, 유휴를 기다릴 수 있습니다. 그 각각이 실행기 잠금에 재진입하며, 그 잠금이 걸려 있는 동안 호출된 콜백은 스스로에 맞서 데드락에 빠질 것입니다

토큰 소스도 한 단계 아래에서 같은 규율을 따릅니다. CancelWithReason은 소스 잠금을 잡고, 단일한 승리 취소자를 결정하고, Reason, CancellationMessage, CancelledAtTick을 쓴 다음, 그제서야 취소됨 플래그를 원자적으로 뒤집습니다. 뒤집기 전에 발행한다는 것이 그 메타데이터를 읽기에 안전하게 만드는 요소입니다: IsCancelled를 True로 관찰하는 어떤 스레드든 그 뒤에 완전한 이유가 있다는 것을 보장받으며, 나중에 온 호출자는 경쟁에서 지고 False를 반환하며 첫 번째 이유를 덮어쓸 수 없습니다. 등록된 콜백은 잠금 안에서 스냅샷되고 지워지지만 잠금 밖에서 호출되며, 각각은 하나의 실패한 핸들러가 나머지를 억누를 수 없도록 감싸집니다. 이미 실행 중인 작업은 결코 죽임을 당하지 않습니다. 그것들은 워커 본문이 다음에 ThrowIfCancelled를 호출할 때 협조적으로 끝나며, 그래서 Shutdown은 스레드를 조인하기 전에 펌핑하는 WaitForIdle로 마무리합니다

병렬 워커는 PDFium 객체 소유권을 완화하는가

완화하지 않으며, 이것이 가장 잘못 읽히기 쉬운 경계입니다. TPdfAsyncExecutor는 작업을 스케줄링할 뿐, 그 작업 안에서 여러분이 건드리는 그 무엇의 스레드 소속에 대해서도 아무 주장을 하지 않습니다. 살아 있는 TPdf 인스턴스는 두 워커가 우연히 그것을 호출한다고 해서 동시 접근 가능해지지 않으며, 내부 렌더 잠금은 겹치는 렌더 호출에 대한 보호막이지, 스레드 간에 문서를 공유해도 좋다는 허가증이 아닙니다. 병렬 렌더링이나 내보내기는 작업 안에서 생성되고 파괴되는 워커당 하나의 TPdf를 의미합니다

type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // one document instance per worker, never shared
  Bmp: TBitmap;
begin
  LocalPdf := TPdf.Create(nil);
  try
    LocalPdf.FileName := FFileName;
    LocalPdf.Active := True;
    LocalPdf.PageNumber := FPageIndex;
    AToken.ThrowIfCancelled;
    Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
    try
      HandOffBitmap(FPageIndex, Bmp);   // ownership moves to the reply stage
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

비용은 실제이며 이름을 붙일 가치가 있습니다: 모든 워커가 자신의 파싱과 자신의 페이지 캐시를 지불하므로, 메모리는 문서 개수가 아니라 워커 개수에 따라 확장됩니다. 이는 워커가 다른 누구도 손상시키지 않고 취소되거나 크래시될 수 있는 모델의 대가입니다. 여러분의 워커가 대신 뷰어 측 문서를 공유한다면, 그 주변의 잠금 규칙은 렌더 잠금과 그것을 놓치는 호출들에서 다루며, 단일 문서 취소 가능 경로는 취소 가능한 점진적 렌더링에 있습니다

vtable을 깨지 않고 공개 인터페이스 확장하기

IPdfCancellationTokenIPdfCancellationTokenSource는 외부 바이너리가 이미 소비하고 있을 수 있는 COM 스타일 인터페이스이므로, 어느 쪽이든 메서드를 추가하면 그 뒤의 모든 vtable 슬롯이 밀려나서 예전 레이아웃에 대해 컴파일된 호출을 조용히 잘못 라우팅시킵니다. 그래서 진단, 대기, 제거 가능한 콜백, 원자적 취소 기능은 수정하는 대신 상속하는 IPdfCancellationTokenExIPdfCancellationTokenSourceEx에 있습니다. NewRun은 기존 호출자를 위해 원래의 의미론을 유지하며, 새 코드는 CancelWithReason, WaitForCancellation, 관리되는 IPdfCancellationRegistration을 원할 때 NewEx, NewTimeout, RunEx를 사용합니다. 상속이 공개 인터페이스를 확장하는 유일하게 안전한 방법이며, 세대마다 타입 하나의 비용이 듭니다

NewTimeout은 정직한 언급이 필요합니다. 각 타임아웃 소스는 취소 이벤트나 마감 시한 중 먼저 오는 것을 기다리는 경량 스레드를 소유합니다. 몇 개나 수십 개의 마감 시한에는 이것이 단순하고 지연이 낮으며 델파이, Lazarus, C++Builder 전반에서 동일합니다. 수천 개의 짧은 마감 시한에는 잘못된 형태이며, 수천 개의 대기 스레드를 붙잡고 있는 대신 하나의 애플리케이션 수준 타이머로부터 취소를 구동해야 합니다

이 규칙들 중 어느 것도 글로 적어놓으면 이색적이지 않지만, 적어놓지 않으면 하나하나가 운영 사고입니다. 올바른 이정표를 기다리고 무언가가 synchronize 큐를 펌핑하게 하며, QueueCapacity는 대기 줄에 대한 한계로만 읽고, 잠금 밖에서 취소하며, 모든 워커에게 자신만의 문서를 주십시오. 여기서 설명한 비동기 계층은 그것이 스케줄링하는 렌더링, 텍스트, 폼 API와 함께 델파이 PDFium Component의 일부로 제공됩니다