HotPDF Delphi Component giải phóng mọi object PDF mà một tài liệu sở hữu khi tài liệu đó đóng hoặc nạp lại: THotPDF.CloseIndirectObjects đi qua sổ đăng ký object, gom từng cạnh sở hữu vào một tập con trỏ, gỡ toàn bộ các cạnh đó, và chỉ sau đó mới free mỗi node duy nhất cùng mỗi payload stream đúng một lần. Thứ tự ba pha ấy là thứ giúp con dùng chung, chu trình sở hữu, đăng ký trùng lặp và các alias wrapper/thân object đều được dỡ xuống mà không double free và không để sót lại gì. Trước v2.752.4, chính thủ tục đó làm một việc đơn giản hơn nhiều và tệ hơn nhiều: nó giải phóng các nguồn file stream lazy, gọi Clear trên danh sách IndirectObjects, free luôn container của danh sách, rồi để mặc mọi object PDF thật cho lúc tiến trình thoát thu hồi. Comment trong đoạn mã đó cũng thành thật về điều này. Free từng object một gây access violation, nên “cách tiếp cận an toàn” là không free chúng chút nào. Bài này nói về lý do cách làm từng object thật sự sập, và một màn dỡ tải hoạt động được trông ra sao trong một ngôn ngữ quản lý bộ nhớ thủ công
Vì sao không thể cứ Free mọi object đã đăng ký?
Vì các destructor của những lớp object bất đồng với nhau về việc ai sở hữu cái gì, còn sổ đăng ký lại chứa các entry ở nhiều tầng khác nhau của cùng một chuỗi sở hữu. Thành ra việc duyệt danh sách rồi gọi Free lên từng entry sẽ free có vùng nhớ hai lần và có vùng nhớ chẳng bao giờ, tùy vào việc lớp nào tình cờ nằm cạnh lớp nào
Ba sự bất đối xứng trong HPDFObjs.pas và HPDFDoc.pas tạo ra vấn đề. THPDFDictionaryObject.Destroy duyệt Items của nó và chỉ free một giá trị khi IsIndirect là False, với giả định rằng các con gián tiếp thuộc về sổ đăng ký và sẽ được free ở đó. THPDFArrayObject.Destroy không phân biệt như vậy và free mọi item nó giữ. Còn THPDFIndirectObject.Destroy, lớp wrapper mang số object, free thân InternalObject của nó. Giờ hãy hình dung một sổ đăng ký giữ một dictionary gián tiếp, một mảng liệt kê chính dictionary đó trong một ô của nó, và một wrapper có thân cũng được đăng ký như một root riêng — đúng thứ mà parser tạo ra trên tệp thật. Free mảng trước thì dictionary biến mất trước khi sổ đăng ký chạm tới nó. Free wrapper lẫn thân, theo thứ tự nào cũng vậy, thì lệnh gọi thứ hai chạy destructor trên một con trỏ treo. Còn free riêng dictionary thì mọi con gián tiếp mà nó bỏ qua sẽ nằm lại được cấp phát mãi mãi. Không thứ tự nào của sổ đăng ký sửa được điều này, vì sổ đăng ký là một danh sách phẳng còn quan hệ sở hữu là một đồ thị, và suy nghĩ theo đồ thị là lối thoát duy nhất
Thế nào là một cạnh sở hữu trong đồ thị object PDF?
Một cạnh sở hữu là con trỏ mà nguồn phải chịu trách nhiệm hủy đích; một tham chiếu là mọi thứ còn lại, và màn dỡ tải phải đi theo loại thứ nhất và bỏ qua loại thứ hai. Trong HotPDF, điều đó cho ra đúng bốn loại cạnh: Items của một THPDFDictionaryObject, Items của một THPDFArrayObject, InternalObject nằm sau một THPDFIndirectObject, và cả hai nửa của một THPDFStreamObject, tức Dictionary cùng payload Stream của nó. Các loại tham chiếu cũng quan trọng không kém, vì đi theo một tham chiếu sẽ biến phép duyệt đồ thị thành vòng lặp vô hạn hoặc một lần dùng sau khi free. Một THPDFLink giữ số object cùng generation, đúng cách ISO 32000-1 §7.3.10 định nghĩa một tham chiếu gián tiếp: một cái tên cho object sống ở chỗ khác, chứ không phải chính object đó. Phân giải con số đó qua sổ đăng ký sẽ cho ra một node mà một cạnh khác đã sở hữu rồi, nên CloseIndirectObjects không bao giờ giải tham chiếu link. Con trỏ ngược FParent mà dictionary và mảng giữ cũng là câu chuyện tương tự ở chiều ngược lại; cha đã sở hữu con rồi, nên đi ngược lên theo con trỏ đó chỉ quay lại một node mà phép duyệt đã đi qua. Cả hai đều được để yên, và comment trong mã nguồn nói đúng điều đó trong một dòng: link và con trỏ cha là tham chiếu, không phải cạnh sở hữu
Màn dỡ tải ba pha hoạt động thế nào?
Pha một là một lượt gom theo chiều rộng. Thủ tục gieo worklist bằng mọi entry của IndirectObjects, rồi với từng node nó nối thêm đích của các cạnh sở hữu thuộc node đó, bỏ qua những gì đã thấy. Tập đã-thấy là một mảng open-addressing chứa con trỏ thô, băm bằng HPDFFastCacheHashInt64 trên giá trị con trỏ, dùng linear probing và nhân đôi qua GrowSeen khi đầy một nửa. Không có gì trong cấu trúc đó cấp phát theo từng node, và điều này quan trọng khi một tài liệu mang vài trăm nghìn object. Payload stream đi vào một danh sách Streams riêng vì chúng là hậu duệ TStream chứ không phải node THPDFObject, và được free trong lượt của chính chúng
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; // đã gom rồi
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;
// Pha một: gieo bằng sổ đăng ký, rồi chỉ đi theo các cạnh sở hữu
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;
Pha hai là phần khiến các destructor trở nên an toàn để chạy: mọi cạnh sở hữu đều được đặt về nil trước khi bất kỳ destructor nào thực thi. Một wrapper nhận MarkAsFreed, hàm này xóa FInternalObject và bật cờ mà destructor của nó kiểm tra trước tiên. Một stream object được gán nil cho Dictionary và Stream. Mỗi item trong dictionary được xóa Item^.Value và mỗi ô trong mảng bị ghi đè bằng nil. Sau lượt này, đồ thị không còn cạnh nào, nên khi pha ba gọi Free trên mọi node trong Nodes rồi mọi payload trong Streams, mỗi destructor không tìm thấy gì để đệ quy vào và chỉ hủy chính nó
// Pha hai: gỡ mọi cạnh sở hữu trước khi free bất cứ thứ gì
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;
// Pha ba: mỗi node và payload duy nhất được free đúng một lần
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);
Hãy xem cách tách pha này mua được gì. Một dictionary dùng chung bởi hai stream object được gom một lần, gỡ khỏi cả hai, và free một lần. Một chu trình trong đó một mảng liệt kê chính dictionary cha của nó sẽ kết thúc vì tập đã-thấy từ chối lần ghé thăm thứ hai. Một wrapper và thân của nó cùng được đăng ký làm root là hai con trỏ khác nhau trong tập, nên cả hai đều được free, và destructor của wrapper không còn thử free thân nữa vì MarkAsFreed đã lấy mất cạnh đó. Một TMemoryStream duy nhất được gán làm payload của hai stream object chỉ nằm trong Streams đúng một lần. Không trường hợp nào trong số đó cần xử lý đặc biệt, và đó là dấu hiệu cho thấy mô hình đã đúng
Làm sao phân biệt rò rỉ bộ nhớ với việc allocator giữ lại vùng nhớ?
Bằng cách kiểm tra xem số vùng cấp phát còn sống của memory manager có biến động theo khối lượng công việc hay không, chứ không chỉ nhìn dấu chân bộ nhớ đã đặt trước. Memory manager của Delphi giữ lại những khối lớn đã free để tái sử dụng, nên một tiến trình đứng ở 400 MiB sau khi đóng tài liệu chưa chắc đã rò rỉ; còn một tiến trình có số khối còn sống tăng thêm một sau mỗi trang mỗi lượt chạy thì đúng là rò rỉ. Phép đo dẫn tới bản sửa này được cố ý làm nhỏ: một writer THotPDF tạo ra một trang duy nhất, rồi ba reader nạp nó. Sau khi cả bốn được free, báo cáo heap cho thấy đúng bốn vùng cấp phát 512 KiB còn sống, một vùng mỗi instance, và đó là payload content stream mà mỗi instance sở hữu và chưa từng giải phóng. Phóng to lên thì cùng một mẫu hình hiện ra không thể nhầm. Chạy pipeline render song song hai lần đẩy con số khối lớn đã cấp phát từ 384 MiB lên 640 MiB, mức tăng tỷ lệ với số trang mà việc allocator giữ lại không thể giải thích. Sau khi viết lại, phép đo chẩn đoán một trang báo không byte lớn nào được cấp phát và không byte nào đặt trước khi các instance đã biến mất. Nếu bạn đang truy cùng kiểu tăng trưởng đó trong tiến trình của mình, đồ thị phụ thuộc object kèm số byte còn giữ cho bạn biết object nào đang giữ bộ nhớ trong lúc tài liệu còn mở; còn bài này nói về cách chúng được giải phóng khi tài liệu đóng
Các ngưỡng bộ nhớ tạo ra những bài kiểm thử hồi quy giòn, nên bộ kiểm thử được phát hành đếm số lần gọi destructor thay vào đó. Một fixture dựng đồ thị bệnh lý bằng tay, với một dictionary dùng chung nằm dưới hai stream, một mảng chứa cả dictionary dùng chung lẫn root của chính nó, một payload được gán cho cả hai stream, root được đăng ký hai lần, và một wrapper có thân được đăng ký riêng, rồi free tài liệu và khẳng định mỗi object duy nhất bị hủy đúng một lần: một payload, hai stream, hai dictionary, một mảng, một wrapper, một number. Dưới đoạn mã cũ, cả ba bài kiểm thử vòng đời đều báo không có lần hủy nào, và đó là phát biểu trực diện nhất có thể về ý nghĩa của “để cho lúc tiến trình thoát”
Điều gì phải xảy ra trước khi đồ thị được dỡ xuống?
Mọi công việc chạy nền có mượn object từ đồ thị đều phải dừng trước, và mọi cache giữ display list hay bitmap được biên dịch từ những object đó đều phải bị bỏ, nếu không một worker thread hay một tham chiếu được cache sẽ đọc vùng nhớ đã free. Vì vậy CloseIndirectObjects mở đầu bằng CancelLoadedPagePrefetch, rồi vô hiệu hóa cache trang đã render trước khi chạm vào sổ đăng ký. Đường nạp lại trong LoadFromFile và LoadFromStream cùng destructor của component đều đi qua đó, nên cùng một thứ tự được áp dụng dù bạn đang thay một tài liệu hay đang hủy instance; các quy tắc dùng lại một THotPDF cho nhiều tài liệu dựa vào bảo đảm đó. Hai chi tiết trong phần mở đầu ấy chỉ lộ ra khi chạy kiểm thử. Thứ nhất, tới lúc destructor đóng đồ thị thì nó đã hủy các frequency sketch nằm sau cache render và cache display list rồi, nên việc vô hiệu hóa được canh theo điều kiện các trường đó khác nil thay vì gọi vô điều kiện. Thứ hai, InvalidateRenderedPageCache chính là thủ tục phát OnLoadedDocumentModified với page index bằng -1, và một bên gọi đang nạp lại tệp thì không nên nhận thông báo chỉnh sửa cho màn dỡ tải nội bộ của tài liệu cũ. Handler được lưu lại, đặt về nil quanh lệnh gọi, và khôi phục trong một khối finally, còn bài kiểm thử hồi quy cho việc nạp lại khẳng định số thông báo bằng không sau lần LoadFromStream thứ hai. Một bản sửa bộ nhớ mà âm thầm đổi khế ước sự kiện là một lỗi hồi quy được PR đẹp hơn, nên nó có hẳn một khẳng định riêng. Nếu bạn chạy pipeline render song song trên một tài liệu rồi nạp lại nó, bước cancel chính là thứ giữ cho worker pool không đua với màn dỡ tải
Dùng lại mẫu này trong mã Delphi của chính bạn
Kỹ thuật này không dành riêng cho PDF. Bất kỳ mô hình object Delphi nào mà các destructor sở hữu con một cách không nhất quán, mà cùng một con có thể tới được từ nhiều cha, hay nơi con trỏ ngược và con trỏ xuôi cùng tồn tại, đều sẽ sập hoặc rò rỉ dưới kiểu Free từng object ngây thơ. Cách sửa luôn có cùng hình dạng: quyết định trường con trỏ nào là sở hữu và trường nào là tham chiếu, gom bao đóng của các cạnh sở hữu qua một tập con trỏ chịu được việc ghé thăm lại, cắt mọi cạnh, rồi hủy danh sách phẳng. Bước cắt là bước người ta hay bỏ qua, và cũng chính là bước khiến các destructor sẵn có trở nên an toàn để tái sử dụng thay vì buộc phải viết lại mọi lớp trong mô hình. Tuy vậy, các ranh giới cũng đáng được nói thẳng. Tập con trỏ dùng địa chỉ object làm định danh, nên một object đã bị free và có địa chỉ được một vùng cấp phát mới dùng lại sẽ không thể phân biệt được; thứ tự các pha bảo đảm không destructor nào chạy trong lúc gom, và đó là thứ loại trừ khả năng ấy. Phép duyệt chỉ nhìn thấy bốn loại cạnh mà nó biết, nên một lớp mới sở hữu con qua một trường mà phép duyệt không xét sẽ làm rò rỉ đứa con đó cho tới khi phép duyệt được dạy về nó. Và vì link được phân giải qua sổ đăng ký chứ không được đi theo, một object chỉ được tham chiếu bởi một link và chưa từng được đăng ký thì hoàn toàn không tới được bằng màn dỡ tải này; trong HotPDF, parser bảo đảm việc đăng ký, nhưng một đồ thị do tay người dựng phải tôn trọng đúng quy tắc đó
Tất cả những thứ này nằm bên trong component, nên hiệu ứng nhìn thấy được với một ứng dụng chỉ đơn giản là đóng hay nạp lại một tài liệu sẽ trả lại bộ nhớ của nó, mà không có thay đổi API nào. HotPDF là một thư viện PDF VCL native cho Delphi và C++Builder kèm đầy đủ mã nguồn; tài liệu tham chiếu API cùng bản dùng thử nằm trên trang component PDF HotPDF cho Delphi