기술 문서

Delphi에서 렌더 락을 빠뜨렸던 PDFium 호출 여섯 곳

PDFiumPas의 렌더 락은 문서별 임계 구역이다 — TPdfTRTLCriticalSection 필드가 뒷받침하는 EnterRenderLockLeaveRenderLock으로, 진행 중인 렌더링 아래에서 페이지가 언로드되거나 다시 로드되지 않도록 PDFium의 래스터라이저로의 모든 호출을 감싸기 위한 것이다. TPdfTPdfView에 고르게 나뉜 여섯 개의 메서드가 PDFium의 비트맵·썸네일 추출 API를 직접 호출하면서 이 락을 완전히 건너뛰었는데, PDFiumPas v2.26.0은 다른 모든 렌더링 진입점이 이미 사용하던 것과 같은 락 쌍으로 이 여섯 개를 감싸서 이 공백을 메웠다

여기서 다루는 공백은 이 블로그의 다른 곳에서 다룬 ABI 강화 패스가 아니다. 그 글은 같은 PDFium 바인딩 안의 cdecl 호출 규약 불일치와 FPC Win64 포인터 폭 절단을 다뤘다. 여기서 다루는 것은 더 좁고 더 기계적이다: PDFium의 렌더링 경로로 들어가는 여섯 개 호출 지점에 대한 락 커버리지 체크리스트, 각각이 왜 놓치기 쉬웠는지, 그리고 락 누락에서 비롯되는 경쟁이 왜 이 코드베이스에서 요구에 따라 재현하기 가장 어려운 결함 중 하나인지다

렌더 락이 실제로 보호하는 것

PDFiumPas가 렌더링을 직렬화하는 이유는 PDFium의 로드된 페이지를 한 스레드가 읽는 동안 다른 스레드가 자유롭게 그것을 해제할 수 있는 상태가 안전하지 않기 때문이다. TPdfFRenderLockTRTLCriticalSection을 소유하며, 생성자에서 초기화되고 FRenderLockReady 플래그로 보호되어, 해체 이후 도착하는 호출은 삭제된 임계 구역에 진입하는 대신 조용한 무동작이 된다. EnterRenderLockLeaveRenderLock은 그 구역을 드나드는 유일하게 승인된 방법이다

procedure TPdf.EnterRenderLock;
begin
  if FRenderLockReady then
    EnterCriticalSection(FRenderLock);
end;

procedure TPdf.LeaveRenderLock;
begin
  if FRenderLockReady then
    LeaveCriticalSection(FRenderLock);
end;

TPdf.RenderPage, RenderTile, RenderPageProgressive는 이 특정 감사가 시작되기 전부터 이미 그 원칙을 따르고 있었다. 각각 PDFium을 호출하기 전에 락을 잡고 finally 블록에서 이를 해제해, 같은 TPdf 인스턴스에 대한 백그라운드 사전 렌더링과 포그라운드 UnloadPage가 겹치지 못하도록 했다. PDFiumPas v2.26.0이 발견한 공백은 그런 명백한 진입점에는 없었다 — 이는 렌더보다는 접근자처럼 읽히는 여섯 개의 메서드에서 나타났는데, 그럼에도 각각은 무언가를 반환하기 전에 PDFium에게 픽셀을 래스터화하라고 요청한다

어느 여섯 개의 호출이 렌더 락을 건너뛰었는가?

TPdf.GetObjectBitmap, TPdf.GetBitmap, TPdf.GetThumbnail이 목록의 절반을 이루었고, TPdfView.GetObjectBitmap, TPdfView.GetBitmap, TPdfView.GetThumbnail이 나머지 절반을 이루었다 — 같은 세 가지 작업이 같은 밑에 있는 페이지를 노출하는 두 컴포넌트 클래스에 걸쳐 중복된 것이다. 여섯 개 모두 결국 FPDFImageObj_GetBitmap이나 FPDFPage_GetThumbnailAsBitmap을 호출하며, 이 두 PDFium 진입점은 이미 렌더링된 무언가에 대한 참조를 돌려주는 대신 그 자리에서 래스터화한다. 여섯 메서드 이름 중 어느 것도 "render"라고 말하지 않으며, 이것이 처음에 이들이 RenderPageRenderTile과 같은 체크리스트에 대해 작성되지 않았던 이유를 합리적으로 설명해 준다

function TPdf.GetObjectBitmap(Index: Integer): TBitmap;
var
  Bitmap: FPDF_BITMAP;
begin
  Result:= nil;
  EnterRenderLock;
  try
    Bitmap:= FPDFImageObj_GetBitmap(GetObjectHandle(Index));
  finally
    LeaveRenderLock;
  end;
  if Bitmap<> nil then
    try
      Result:= ToBitmap(Bitmap);
    finally
      FPDFBitmap_Destroy(Bitmap);
    end;
end;

TPdfView는 왜 락 호출을 nil 검사로 감싸는가

TPdfView는 자신만의 임계 구역을 소유하지 않는다 — 그 여섯 개 락 호출 각각은 FPdf.EnterRenderLockFPdf.LeaveRenderLock으로 전달되며, 먼저 연결된 TPdf 참조가 nil이 아닌지 확인하는 검사로 감싸져 있다. 이 보호가 존재하는 이유는 TPdfView가 디자인 타임에 폼 위에 놓여 있거나, 문서 하나가 닫히고 다음 것이 열리는 사이에 잠깐, FPdf에 아직 아무 TPdf도 할당되지 않은 채로 있을 수 있기 때문이다. 이 보호를 건너뛰면 한 크래시를 다른 크래시로 바꾸는 것에 불과할 것이다. nil 참조에 대한 잠금 호출은 락이 막으려는 경쟁만큼이나 험하게 실패하기 때문이다

function TPdfView.GetThumbnail: TBitmap;
var
  PdfBitmap: FPDF_BITMAP;
begin
  CheckActive;
  Result:= nil;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  try
    PdfBitmap:= FPDFPage_GetThumbnailAsBitmap(Page);
  finally
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
  if PdfBitmap<> nil then
    try
      Result:= ToBitmap(PdfBitmap);
    finally
      FPDFBitmap_Destroy(PdfBitmap);
    end;
end;

RenderPage(HDC)는 왜 같은 감사에 속하는가?

디바이스 컨텍스트를 대상으로 하는 TPdfView.RenderPage는 이 여섯 개 중 하나가 아니다 — 이는 한 릴리스 앞선 PDFiumPas v2.25.0에서 나타났으며, 같은 결함이 다른 서명을 쓰고 있는 경우이기에 이 체크리스트에 자리를 차지할 자격이 있다. 그 오버로드는 EnterRenderLock도, 구형 Delphi 컴파일러에서 FPU 예외를 막아주는 SetArithmeticMask 호출도 없이 FPDF_RenderPage를 곧바로 호출했는데, 같은 클래스 안에서 몇 줄 아래에 있던 TBitmap 오버로드는 이미 둘 다 갖추고 있었다. 두 번의 감사 패스가 한 릴리스를 사이에 두고 같은 실패 모드를 잡아냈다는 것은 어느 특정 메서드에 대해서라기보다 이 버그의 모양에 대해 더 많은 것을 말해준다: 이는 형제 메서드가 올바르게 보이면 아무도 다시 읽지 않을 오버로드 안에 숨는다

procedure TPdfView.RenderPage(DeviceContext: HDC; Left, Top, Width,
  Height: Integer; Rotation: TRotation; Options: TRenderOptions);
var
  ArithmeticMask: TArithmeticMask;
begin
  CheckActive;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  ArithmeticMask:= SetArithmeticMask;
  try
    FPDF_RenderPage(DeviceContext, FPage, Left, Top, Width, Height,
      Ord(Rotation), EncodeRenderOptions(Options));
  finally
    RestoreArithmeticMask(ArithmeticMask);
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
end;

이 경쟁은 왜 재현이 거의 불가능한가?

PDFiumPas의 렌더 락 공백은 매 실행마다, 심지어 대부분의 실행에서도 실패하지 않는다. 같은 TPdf 인스턴스에 동시에 두 가지 특정 상황이 겹쳐야 하기 때문이다: 이미 진행 중인 래스터화 호출, 그리고 바로 그 창 안에 도착하는 동시적인 UnloadPageReloadPage다. 단일 스레드 테스트는 이 경로를 전혀 실행하지 않으며, 진짜로 다중 스레드인 워크로드조차 백그라운드 렌더링과 문서 생명주기 이벤트가 한 페이지의 생애 안에서 우연히 겹칠 때만 이를 촉발한다. 가장 현실적인 촉발 조건은 취소 가능한 퓨처 기반의 백그라운드 PDF 사전 렌더링인데, 여기서는 워커 스레드가 다음 페이지를 래스터화하는 동안 UI 스레드가 사용자 입력에 따라 현재 페이지를 다시 로드하거나 언로드한다

FPDFImageObj_GetBitmapFPDFPage_GetThumbnailAsBitmapUnloadPage가 순회 도중 자유롭게 해제할 수 있는 페이지 객체 구조를 순회하므로, 실제로 발생하는 경쟁이 항상 즉각적인 액세스 위반을 만들어내는 것도 아니다. 한 박자 늦게 읽힌 구조는 그저 쓰레기 픽셀을 돌려줄 수도 있고, PDF 페이지를 전혀 건드린 적 없는 함수에서 나중에서야 여러 무관한 할당을 무너뜨리는 힙 메타데이터를 손상시킬 수도 있다. 이것이 이 부류의 버그가 여러 릴리스 사이클에 걸쳐 코드베이스 안에서 살아남을 수 있었던 솔직한 이유다: 실패 시점의 스택 트레이스는 실제로 락이 빠져 있던 그 여섯 줄 근처는 좀처럼 가리키지 않는다

호출자에게 바뀌는 것

GetBitmap, GetObjectBitmap, GetThumbnail, 그리고 RenderPage의 HDC 오버로드는 공개 서명을 이전과 정확히 그대로 유지하는데, 이 수정이 마이그레이션이 아니라 기존 호출 주변에 추가된 내부 잠금이기 때문이다. 렌더 락이 프로세스 전역이 아니라 TPdf 인스턴스별로 범위가 정해진다는 점을 기억할 가치가 있다. 그래서 각자 별도로 로드된 두 문서를 렌더링하는 두 스레드는 여전히 완전히 병렬로 실행된다 — 락은 두 스레드가 우연히 공유하는 그 하나의 문서에 대한 작업만 직렬화한다. 잠금이 이미 견고한데도 확대나 스크롤 시 렌더링이 여전히 느리게 느껴진다면, 그것은 별개의 질문이며 PDFium 렌더 캐시와 확대 성능 전략에 관한 글에서 답한다 — 정확성과 속도는 여기서 별개의 축이며, 이 수정은 오직 첫 번째만 건드린다

여섯 개의 메서드와 형제 오버로드 하나는 PDFiumPas가 노출하는 PDFium 표면 중 작은 부분이지만, 마침 디버거 안에서 실행된 적 없는 부하 아래에서만 오작동하던 바로 그 부분이었다. 렌더 락 자체와 이제 그것이 커버하는 전체 렌더링 진입점 집합은 Delphi, C++Builder, Lazarus/FPC용 PDFium 컴포넌트의 일부로 제공된다