기술 문서

Delphi에서 PDF 객체 그래프를 정확히 한 번 해제하기: HotPDF

HotPDF Delphi Component는 문서가 닫히거나 다시 로드될 때 그 문서가 소유한 모든 PDF 객체를 해제합니다. THotPDF.CloseIndirectObjects는 객체 레지스트리를 순회하며 각 소유 엣지를 포인터 집합에 모으고, 그 엣지들을 전부 떼어 낸 다음에야 각 노드와 각 스트림 페이로드를 정확히 한 번씩 해제합니다. 이 3단계 순서 덕분에 공유된 자식, 소유 순환, 중복 등록, 래퍼/본문 별칭이 모두 이중 해제 없이, 그리고 아무것도 남기지 않고 내려옵니다. v2.752.4 이전에 같은 루틴은 훨씬 단순하고 훨씬 나쁜 일을 했습니다. lazy 파일 스트림 소스를 해제하고, IndirectObjects 리스트에 Clear를 호출하고, 리스트 컨테이너를 해제하고, 실제 PDF 객체는 전부 프로세스 종료가 회수하도록 남겨 뒀습니다. 그 코드의 주석도 이 점에 대해 솔직했습니다. 객체를 개별적으로 해제하면 access violation이 났기 때문에 "안전한 방법"은 아예 해제하지 않는 것이었습니다. 이 글은 개별 해제 방식이 실제로 왜 크래시했는지, 그리고 수동 메모리 관리 언어에서 제대로 동작하는 teardown이 어떤 모습인지를 다룹니다

등록된 모든 객체를 그냥 Free하면 안 되는 이유는?

객체 클래스들의 소멸자가 누가 무엇을 소유하는지에 대해 서로 다른 말을 하고, 레지스트리에는 같은 소유 사슬의 여러 계층에 속한 항목이 섞여 있기 때문입니다. 그래서 리스트를 훑으며 각 항목에 Free를 호출하면 어떤 메모리는 두 번 해제되고 어떤 메모리는 영원히 해제되지 않는데, 어느 쪽이 될지는 어떤 클래스들이 우연히 이웃해 있느냐에 달려 있습니다

HPDFObjs.pas와 HPDFDoc.pas의 비대칭 세 가지가 문제를 만듭니다. THPDFDictionaryObject.Destroy는 Items를 훑으면서 IsIndirect가 False일 때만 값을 해제하는데, 간접 자식은 레지스트리에 속해 있고 거기서 해제될 것이라고 가정하기 때문입니다. THPDFArrayObject.Destroy는 그런 구분 없이 자기가 들고 있는 모든 항목을 해제합니다. 그리고 객체 번호를 들고 다니는 래퍼인 THPDFIndirectObject.Destroy는 자기 InternalObject 본문을 해제합니다. 이제 간접 딕셔너리 하나, 그 딕셔너리를 자기 슬롯 하나에 담고 있는 배열 하나, 그리고 본문이 별도 루트로도 등록된 래퍼 하나를 담은 레지스트리를 생각해 봅시다. 실제 파일에서 파서가 만들어 내는 모양이 정확히 이것입니다. 배열을 먼저 해제하면 레지스트리가 그 딕셔너리에 도달하기 전에 딕셔너리가 사라집니다. 래퍼와 본문을 어느 순서로 해제하든 두 번째 호출은 매달린 포인터에 소멸자를 실행합니다. 딕셔너리만 해제하면 그것이 건너뛴 간접 자식은 영원히 할당된 채 남습니다. 레지스트리를 어떤 순서로 돌아도 해결되지 않습니다. 레지스트리는 평평한 리스트인데 소유 관계는 그래프이고, 그래프를 따져 보는 것만이 유일한 출구이기 때문입니다

HotPDF 레지스트리 항목을 전부 해제하면 크래시한 이유: THPDFDictionaryObject.Destroy는 간접 자식을 건너뛰고, THPDFArrayObject.Destroy는 자기가 들고 있는 모든 것을 해제하며, THPDFIndirectObject.Destroy는 InternalObject 본문을 해제하므로, 래퍼와 배열과 공유 딕셔너리가 하나의 평평한 IndirectObjects 리스트에 있으면 어떤 메모리는 두 번 죽고 어떤 메모리는 한 번도 죽지 않습니다
소멸자들은 누가 무엇을 소유하는지에 대해 서로 다른 말을 하고, 레지스트리는 같은 소유 사슬의 여러 계층에 속한 항목을 담고 있어서, 평평한 리스트의 어떤 순서도 순진한 객체별 Free를 올바른 teardown으로 바꿔 주지 못합니다

PDF 객체 그래프에서 무엇이 소유 엣지인가?

소유 엣지는 그 대상에 대해 출발 쪽이 파괴 책임을 지는 포인터이고, 참조는 그 밖의 모든 것이며, teardown은 앞의 종류는 따라가고 뒤의 종류는 무시해야 합니다. HotPDF에서는 이것이 정확히 네 종류의 엣지가 됩니다. THPDFDictionaryObject의 Items, THPDFArrayObject의 Items, THPDFIndirectObject 뒤에 있는 InternalObject, 그리고 THPDFStreamObject의 양쪽 절반인 Dictionary와 Stream 페이로드입니다. 참조 종류도 그만큼 중요합니다. 그중 하나를 따라가면 그래프 순회가 무한 루프나 use-after-free가 되기 때문입니다. THPDFLink는 객체 번호와 세대를 들고 있는데, ISO 32000-1 §7.3.10이 간접 참조를 정의하는 방식이 그렇습니다. 다른 곳에 사는 객체의 이름이지, 객체 자체가 아닙니다. 그 번호를 레지스트리로 해석하면 이미 다른 엣지가 소유한 노드가 나오므로, CloseIndirectObjects는 링크를 아예 역참조하지 않습니다. 딕셔너리와 배열이 들고 있는 FParent 역포인터도 방향만 반대인 같은 이야기입니다. 부모가 이미 자식을 소유하고 있으므로, 그 포인터를 위로 따라가면 순회가 이미 지나온 노드를 다시 방문할 뿐입니다. 둘 다 건드리지 않으며, 소스의 주석이 한 줄로 그렇게 말합니다. 링크와 부모 포인터는 참조이고 소유 엣지가 아닙니다

HotPDF 객체 그래프의 소유 엣지와 참조: DictionaryObject Items, ArrayObject Items, IndirectObject InternalObject, StreamObject의 양쪽 절반은 따라가서 떼어 내지만, THPDFLink의 객체 번호와 FParent 역포인터는 다른 곳에 사는 객체의 이름이므로 CloseIndirectObjects가 결코 역참조하지 않습니다
소유 엣지는 그 대상에 대해 출발 쪽이 파괴 책임을 지는 포인터입니다. 대신 참조를 따라가면 너비 우선 순회가 무한 루프나 use-after-free가 되므로, 링크와 부모 포인터는 건드리지 않습니다

3단계 teardown은 어떻게 동작할까요?

1단계는 너비 우선 수집입니다. 루틴은 IndirectObjects의 모든 항목으로 worklist를 채우고, 각 노드마다 그 노드의 소유 엣지 대상들을 덧붙이면서 이미 본 것은 건너뜁니다. seen 집합은 포인터 값을 HPDFFastCacheHashInt64로 해시한 원시 포인터의 open addressing 배열이고, 선형 탐사와 절반이 찼을 때 크기를 두 배로 늘리는 GrowSeen을 씁니다. 그 구조에서는 노드마다 할당하는 것이 아무것도 없는데, 문서가 객체 수십만 개를 들고 다닐 때 이게 중요합니다. 스트림 페이로드는 별도 Streams 리스트로 갑니다. THPDFObject 노드가 아니라 TStream 하위 클래스이고 자기 차례에 따로 해제되기 때문입니다

HotPDF의 3단계 CloseIndirectObjects teardown: 너비 우선 수집이 IndirectObjects로 worklist를 채우고 HPDFFastCacheHashInt64로 해시한 open addressing seen 집합을 통해 소유 엣지만 따라가며, 2단계는 MarkAsFreed와 nil 대입으로 모든 엣지를 떼어 내고, 3단계는 각 노드와 스트림 페이로드를 정확히 한 번씩 해제합니다
소멸자가 하나라도 실행되기 전에 엣지를 끊는 것이 기존 소멸자를 안전하게 재사용할 수 있게 해 줍니다. 그러면 각 소멸자는 재귀할 대상이 없고, 공유 자식과 순환, 래퍼-본문 별칭이 모두 이중 해제 없이 내려옵니다
procedure Collect(Value: TObject; Payload: boolean);
var
  Slot: Integer;
begin
  if Value = nil then Exit;
  if (SeenCount + 1) * 2 >= Length(Seen) then GrowSeen;
  Slot := PointerSlot(Pointer(Value), Length(Seen));
  while Seen[Slot] <> nil do
  begin
    if Seen[Slot] = Pointer(Value) then Exit;   // 이미 수집됨
    Slot := (Slot + 1) and (Length(Seen) - 1);
  end;
  Seen[Slot] := Pointer(Value);
  Inc(SeenCount);
  if Payload then Streams.Add(Value) else Nodes.Add(Value);
end;

// 1단계: 레지스트리로 시작하고, 소유 엣지만 따라갑니다
for I := 0 to IndirectObjects.Count - 1 do
  Collect(TObject(IndirectObjects[I]), False);
I := 0;
while I < Nodes.Count do
begin
  Obj := THPDFObject(Nodes[I]);
  if Obj is THPDFIndirectObject then
    Collect(THPDFIndirectObject(Obj).InternalObject, False)
  else if Obj is THPDFStreamObject then
  begin
    Collect(THPDFStreamObject(Obj).Dictionary, False);
    Collect(THPDFStreamObject(Obj).Stream, True);
  end
  else if Obj is THPDFDictionaryObject then
    for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
      Collect(PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value, False)
  else if Obj is THPDFArrayObject then
    for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
      Collect(TObject(THPDFArrayObject(Obj).Items[J]), False);
  Inc(I);
end;

2단계는 소멸자를 안전하게 실행할 수 있게 만드는 부분입니다. 소멸자가 하나라도 실행되기 전에 모든 소유 엣지를 nil로 설정합니다. 래퍼에는 MarkAsFreed를 적용하는데, 이것이 FInternalObject를 비우고 소멸자가 가장 먼저 검사하는 플래그를 세웁니다. 스트림 객체는 Dictionary와 Stream에 nil이 대입됩니다. 각 딕셔너리 항목은 Item^.Value가 비워지고, 각 배열 슬롯에는 nil이 덮어써집니다. 이 단계를 지나면 그래프에 남은 엣지가 없으므로, 3단계가 Nodes의 모든 노드와 그다음 Streams의 모든 페이로드에 Free를 호출할 때 각 소멸자는 재귀할 대상이 없고 자기 자신만 파괴합니다

// 2단계: 무엇을 해제하기 전에 모든 소유 엣지를 떼어 냅니다
for I := 0 to Nodes.Count - 1 do
begin
  Obj := THPDFObject(Nodes[I]);
  if Obj is THPDFIndirectObject then
    THPDFIndirectObject(Obj).MarkAsFreed
  else if Obj is THPDFStreamObject then
  begin
    THPDFStreamObject(Obj).Dictionary := nil;
    THPDFStreamObject(Obj).Stream := nil;
  end
  else if Obj is THPDFDictionaryObject then
    for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
      PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value := nil
  else if Obj is THPDFArrayObject then
    for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
      THPDFArrayObject(Obj).Items[J] := nil;
end;

// 3단계: 각 노드와 페이로드를 정확히 한 번씩 해제합니다
IndirectObjects.Clear;
for I := 0 to Nodes.Count - 1 do TObject(Nodes[I]).Free;
for I := 0 to Streams.Count - 1 do TObject(Streams[I]).Free;
FreeAndNil(IndirectObjects);

이 분리가 무엇을 사 주는지 보십시오. 두 스트림 객체가 공유하는 딕셔너리는 한 번 수집되고, 둘 모두에서 떨어져 나가고, 한 번 해제됩니다. 배열이 자기 부모 딕셔너리를 담고 있는 순환은 seen 집합이 두 번째 방문을 거부하기 때문에 종료됩니다. 래퍼와 그 본문이 둘 다 루트로 등록된 경우 집합 안에서는 서로 다른 포인터 두 개이므로 둘 다 해제되고, 래퍼의 소멸자는 더 이상 본문을 해제하려 들지 않습니다. MarkAsFreed가 이미 그 엣지를 가져갔기 때문입니다. 두 스트림 객체의 페이로드로 대입된 하나의 TMemoryStream은 Streams에 정확히 한 번 들어갑니다. 이 경우들 중 특별한 처리가 필요한 것은 하나도 없고, 그것이 모델이 맞다는 신호입니다

누수를 할당자 보유와 어떻게 구분할까요?

메모리 관리자의 예약된 풋프린트가 아니라 살아 있는 할당 수가 작업량에 따라 움직이는지를 확인하는 것입니다. Delphi 메모리 관리자는 해제한 큰 블록을 재사용하려고 남겨 두므로, 문서를 닫은 뒤에도 400 MiB에 머무는 프로세스가 반드시 누수한 것은 아닙니다. 반대로 실행할 때마다 페이지당 하나씩 살아 있는 블록 수가 올라가는 프로세스는 누수한 것입니다. 이 수정을 이끈 탐침은 일부러 작았습니다. 한 페이지를 만들어 내는 THotPDF 라이터 하나와 그것을 읽는 리더 셋입니다. 넷을 모두 해제한 뒤 힙 리포트는 정확히 네 개의 살아 있는 512 KiB 할당을 보여 줬는데, 인스턴스마다 하나씩이며 각자가 소유하고 한 번도 해제하지 않은 콘텐츠 스트림 페이로드였습니다. 규모를 키우자 같은 패턴이 틀림없이 드러났습니다. 병렬 렌더 파이프라인을 두 번 돌리자 큰 블록 할당 수치가 384 MiB에서 640 MiB로 올라갔고, 이 증가는 페이지 수에 비례하는 것이라 할당자 보유로는 설명할 수 없었습니다. 재작성 이후에는 한 페이지 진단이 인스턴스가 사라진 뒤 할당된 큰 바이트 0, 예약 0을 보고했습니다. 여러분 프로세스에서 같은 종류의 증가를 쫓고 있다면, 보유 바이트를 보여 주는 객체 의존성 그래프가 문서가 열려 있는 동안 어떤 객체가 메모리를 쥐고 있는지 알려 줍니다. 이 글은 문서가 닫힐 때 그 객체들의 해제 동작을 다룹니다

메모리 임계값은 회귀 테스트를 잘 부서지게 만들므로, 배포된 테스트는 대신 소멸자 호출 횟수를 셉니다. 픽스처는 병적인 그래프를 손으로 만듭니다. 두 스트림 아래에 공유 딕셔너리 하나, 공유 딕셔너리와 자기 루트를 모두 담은 배열 하나, 두 스트림에 모두 대입된 페이로드 하나, 두 번 등록된 루트, 그리고 본문이 따로 등록된 래퍼 하나를 준비한 다음, 문서를 해제하고 고유 객체마다 파괴가 한 번씩 있었는지 단언합니다. 페이로드 하나, 스트림 둘, 딕셔너리 둘, 배열 하나, 래퍼 하나, 숫자 하나입니다. 예전 코드에서는 세 가지 수명 테스트가 모두 파괴 0을 보고했는데, "프로세스 종료에 맡긴다"는 말이 무엇을 뜻하는지 가장 직접적으로 보여 주는 진술입니다

그래프가 내려오기 전에 무엇이 일어나야 할까요?

그래프에서 객체를 빌려 쓰는 백그라운드 작업은 먼저 멈춰야 하고, 그 객체들로 컴파일된 디스플레이 리스트나 비트맵을 들고 있는 캐시는 버려야 합니다. 그렇지 않으면 워커 스레드나 캐시된 참조가 해제된 메모리를 읽습니다. 그래서 CloseIndirectObjects는 CancelLoadedPagePrefetch로 시작하고, 레지스트리를 건드리기 전에 렌더링된 페이지 캐시를 무효화합니다. LoadFromFile과 LoadFromStream의 재로드 경로와 컴포넌트 소멸자가 모두 이곳을 거쳐 가므로, 문서를 교체하든 인스턴스를 폐기하든 같은 순서가 적용됩니다. 하나의 THotPDF를 여러 문서에 걸쳐 재사용하는 규칙도 그 보장에 기대고 있습니다. 이 머리말의 세부 사항 두 가지는 테스트를 돌려 보고 나서야 드러났습니다. 첫째, 소멸자는 그래프를 닫을 무렵이면 렌더 캐시와 디스플레이 리스트 캐시 뒤의 빈도 스케치를 이미 폐기한 상태이므로, 무효화는 무조건 호출되지 않고 그 필드들이 non-nil일 때만 수행됩니다. 둘째, InvalidateRenderedPageCache는 페이지 인덱스 -1로 OnLoadedDocumentModified를 발생시키는 루틴인데, 파일을 재로드하는 호출자가 옛 문서의 내부 teardown에 대한 편집 알림을 받아서는 안 됩니다. 핸들러는 저장해 두고 호출 주위에서 nil로 설정한 뒤 finally에서 복원하며, 재로드 회귀 테스트는 두 번째 LoadFromStream 이후 알림 횟수가 0이라고 단언합니다. 이벤트 계약을 조용히 바꾸는 메모리 수정은 포장만 좋은 회귀 버그이므로, 그 단언을 따로 갖습니다. 문서에 대해 병렬 렌더 파이프라인을 돌린 뒤 다시 로드한다면, 워커 풀이 teardown과 경쟁하지 않게 막아 주는 것이 바로 이 취소 단계입니다

이 패턴을 자신의 Delphi 코드에서 재사용하기

이 기법은 PDF에 국한되지 않습니다. 소멸자가 자식을 일관성 없이 소유하거나, 같은 자식이 여러 부모에서 도달 가능하거나, 역포인터와 순방향 포인터가 공존하는 Delphi 객체 모델은 어느 것이든 순진한 객체별 Free 아래에서 크래시하거나 누수합니다. 수정은 언제나 같은 모양입니다. 어떤 포인터 필드가 소유이고 어떤 것이 참조인지 정하고, 재방문을 견디는 포인터 집합을 통해 소유 엣지의 폐포를 모으고, 모든 엣지를 끊고, 평평한 리스트를 파괴합니다. 사람들이 건너뛰는 단계가 바로 그 끊기 단계이고, 그것이 모델의 모든 클래스를 다시 쓰게 만드는 대신 기존 소멸자를 안전하게 재사용할 수 있게 해 줍니다. 다만 경계는 분명히 말해 둘 가치가 있습니다. 포인터 집합은 객체 주소를 아이덴티티로 쓰므로, 이미 해제되어 주소가 새 할당에 재사용된 객체는 구별할 수 없습니다. 순서가 수집 중에는 어떤 소멸자도 실행되지 않도록 보장하고, 그것이 그런 경우를 배제합니다. 순회는 자기가 아는 네 종류의 엣지만 봅니다. 그래서 순회가 검사하지 않는 필드를 통해 자식을 소유하는 새 클래스는, 순회가 그 클래스를 배울 때까지 그 자식을 누수합니다. 그리고 링크는 따라가지 않고 레지스트리를 통해 해석하므로, 링크로만 참조되고 한 번도 등록되지 않은 객체는 이 teardown으로 도달할 수 없습니다. HotPDF에서는 파서가 등록을 보장하지만, 손으로 만든 그래프는 같은 규칙을 지켜야 합니다

이 모든 것이 컴포넌트 내부에 있으므로, 애플리케이션이 보는 효과는 문서를 닫거나 다시 로드하면 메모리가 반환된다는 것뿐이고 API 변경은 없습니다. HotPDF는 전체 소스가 포함된 Delphi와 C++Builder용 네이티브 VCL PDF 라이브러리입니다. API 레퍼런스와 체험판 빌드는 HotPDF Delphi PDF 컴포넌트 페이지에 있습니다