Το HotPDF Delphi Component αποδεσμεύει κάθε PDF object που κατέχει ένα έγγραφο όταν εκείνο κλείνει ή ξαναφορτώνει: το THotPDF.CloseIndirectObjects περπατάει το registry των objects, συλλέγει κάθε ακμή ιδιοκτησίας σε σύνολο pointers, αποκόπτει όλες εκείνες τις ακμές, και μόνο τότε απελευθερώνει κάθε μοναδικό κόμβο και κάθε payload stream ακριβώς μία φορά. Εκείνη η τριών φάσεων σειρά είναι που αφήνει κοινόχρηστα παιδιά, κύκλους ιδιοκτησίας, διπλές καταχωρήσεις και aliases wrapper/σώματος να πέφτουν όλοι χωρίς double free και χωρίς να αφήνει τίποτα πίσω. Πριν το v2.752.4 η ίδια ρουτίνα έκανε κάτι πολύ απλούστερο και πολύ χειρότερο: αποδεσμευε τις lazy πηγές file stream, καλούσε Clear στη λίστα IndirectObjects, απελευθέρωνε το container της λίστας, και άφηνε κάθε πραγματικό PDF object στην έξοδο της process για ανάκτηση. Το σχόλιο σε εκείνον τον κώδικα ήταν ειλικρινές γι' αυτό, επίσης. Η απελευθέρωση των objects μεμονωμένα προκαλούσε access violations, οπότε η «ασφαλής προσέγγιση» ήταν να μην απελευθερώνονται καθόλου. Αυτή η ανάρτηση αφορά το γιατί η ατομική προσέγγιση πράγματι κατέρρεε, και πώς μοιάζει μια κατεδάφιση που δουλεύει σε γλώσσα με χειροκίνητη διαχείριση μνήμης
Γιατί δεν μπορείς απλώς να κάνεις Free κάθε καταχωρημένο object;
Επειδή οι destructors των classes των objects διαφωνούν για το ποιος κατέχει τι, και το registry περιέχει εγγραφές σε πολλά επίπεδα της ίδιας αλυσίδας ιδιοκτησίας. Το περπάτημα της λίστας και το Free σε κάθε εγγραφή απελευθερώνει λοιπό κάποια μνήμη δύο φορές και κάποια μνήμη ποτέ, ανάλογα με το ποιες classes τυχαίνει να κάθονται δίπλα-δίπλα
Τρεις ασυμμετρίες στο HPDFObjs.pas και HPDFDoc.pas δημιουργούν το πρόβλημα. Το THPDFDictionaryObject.Destroy περπατάει τα Items του και απελευθερώνει μια τιμή μόνο όταν το IsIndirect είναι False, με την υπόθεση ότι τα indirect παιδιά ανήκουν στο registry και θα απελευθερωθούν εκεί. Το THPDFArrayObject.Destroy δεν κάνει τέτοια διάκριση και απελευθερώνει κάθε στοιχείο που κρατά. Και το THPDFIndirectObject.Destroy, ο wrapper που κουβαλάει αριθμό object, απελευθερώνει το σώμα InternalObject του. Τώρα σκέψου registry που κρατά indirect dictionary, έναν array που απαριθμεί εκείνο το ίδιο dictionary σε μία από τις θέσεις του, και έναν wrapper του οποίου το σώμα είναι επίσης καταχωρημένο ως ξεχωριστή ρίζα, που είναι ακριβώς αυτό που παράγει ο parser σε πραγματικά αρχεία. Απελευθέρωσε πρώτα τον array και το dictionary έχει φύγει πριν το registry το φτάσει. Απελευθέρωσε τον wrapper και το σώμα, με οποιαδήποτε σειρά, και η δεύτερη κλήση τρέχει destructor πάνω σε κρεμαστό pointer. Απελευθέρωσε μόνο το dictionary και κάθε indirect παιδί που παράλειψε μένει δεσμευμένο για πάντα. Καμία ταξινόμηση του registry δεν διορθώνει αυτό, επειδή το registry είναι επίπεδη λίστα και η σχέση ιδιοκτησίας είναι γράφος, και το συλλογισμό πάνω στον γράφο είναι ο μόνος τρόπος έξω
Τι μετράει ως ακμή ιδιοκτησίας σε γράφο PDF objects;
Ακμή ιδιοκτησίας είναι pointer του οποίου ο στόχος η πηγή έχει ευθύνη να καταστρέψει· αναφορά είναι οτιδήποτε άλλο, και η κατεδάφιση πρέπει να ακολουθεί το πρώτο είδος και να αγνοεί το δεύτερο. Στο HotPDF αυτό δίνει ακριβώς τέσσερα είδη ακμών: τα Items ενός THPDFDictionaryObject, τα Items ενός THPDFArrayObject, το InternalObject πίσω από ένα THPDFIndirectObject, και τα δύο μισά ενός THPDFStreamObject, το Dictionary και το payload Stream του. Τα είδη αναφορών μετράνε το ίδιο, επειδή το να ακολουθήσεις μία μετατρέπει το περπάτημα γράφου σε ατέρμονο βρόχο ή use-after-free. Ένα THPDFLink κρατά αριθμό object και generation, που είναι πώς το ISO 32000-1 §7.3.10 ορίζει indirect reference: ένα όνομα για object που ζει αλλού, όχι το ίδιο το object. Η επίλυση εκείνου του αριθμού μέσω του registry βγάζει κόμβο που κάποια άλλη ακμή κατέχει ήδη, οπότε το CloseIndirectObjects δεν απο-αναφέρε ποτέ links. Ο back-pointer FParent που κρατούν dictionaries και arrays είναι η ίδια ιστορία προς την άλλη κατεύθυνση· ο γονέας κατέχει ήδη το παιδί, οπότε το να ακολουθήσεις τον pointer προς τα πάνω θα ξαναεπισκεπτόταν μόνο κόμβο από τον οποίο έχει περάσει το περπάτημα. Και τα δύο μένουν ήσυχα, και το σχόλιο στην πηγή το λέει σε μία γραμμή: τα links και οι parent pointers είναι αναφορές, όχι ακμές ιδιοκτησίας
Πώς δουλεύει η τριών φάσεων κατεδάφιση;
Φάση ένα είναι συλλογή breadth-first. Η ρουτίνα σπέρνει worklist με κάθε εγγραφή του IndirectObjects, και μετά για κάθε κόμβο προσθέτει τους στόχους των ακμών ιδιοκτησίας εκείνου του κόμβου, παραλείποντας οτιδήποτε ήδη έχει δει. Το σύνολο των ήδη-δων είναι array open-addressing με ακατέργαστους pointers hashed με HPDFFastCacheHashInt64 πάνω στην τιμή του pointer, με γραμμική διερεύνηση και διπλασιαζόμενο GrowSeen όταν φτάσει μισό γεμάτο. Τίποτα σε εκείνη τη δομή δεν δεσμεύει ανά κόμβο, που μετράει όταν ένα έγγραφο κουβαλάει μερικές εκατοντάδες χιλιάδες objects. Τα payloads stream μπαίνουν σε ξεχωριστή λίστα Streams επειδή είναι απόγονοι TStream και όχι κόμβοι THPDFObject και απελευθερώνονται στο δικό τους πέρασμα
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;
// Φάση ένα: σπόρος από το registry, μετά ακολουθείς μόνο ακμές ιδιοκτησίας
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;
Η φάση δύο είναι το κομμάτι που κάνει τους destructors ασφαλείς να τρέξουν: κάθε ακμή ιδιοκτησίας μηδενίζεται πριν εκτελεστεί οποιοσδήποτε destructor. Ένας wrapper παίρνει MarkAsFreed, που καθαρίζει το FInternalObject και θέτει τη σημαία που ο destructor του ελέγχει πρώτα. Ένα stream object έχει Dictionary και Stream ανατεθειμένα nil. Κάθε στοιχείο dictionary έχει το Item^.Value καθαρισμένο και κάθε θέση array ξαναγράφεται με nil. Μετά από εκείνο το πέρασμα ο γράφος δεν έχει αφήσει καμία ακμή, οπότε όταν η φάση τρία καλεί Free σε κάθε κόμβο στα Nodes και μετά σε κάθε payload στα Streams, κάθε destructor δεν βρίσκει τίποτα μέσα στο οποίο να αναδυθεί και καταστρέφει μόνο τον εαυτό του
// Φάση δύο: αποκόλλησε κάθε ακμή ιδιοκτησίας πριν απελευθερώσεις οτιδήποτε
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;
// Φάση τρία: κάθε μοναδικός κόμβος και payload απελευθερώνεται ακριβώς μία φορά
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);
Κοίτα τι αγοράζει ο διαχωρισμός. Ένα dictionary κοινόχρηστο από δύο stream objects συλλέγεται μία φορά, αποκόπτεται και από τα δύο, και απελευθερώνεται μία φορά. Ένας κύκλος όπου ένας array απαριθμεί το δικό του γονικό dictionary τερματίζει επειδή το σύνολο των ήδη-δων αρνείται τη δεύτερη επίσκεψη. Ένας wrapper και το σώμα του και οι δύο καταχωρημένοι ως ρίζες είναι δύο διακριτοί pointers στο σύνολο, οπότε και οι δύο απελευθερώνονται, και ο destructor του wrapper δεν προσπαθεί πια να απελευθερώσει το σώμα επειδή το MarkAsFreed πήρε ήδη εκείνη την ακμή μακριά. Ένα μοναδικό TMemoryStream ανατεθειμένο ως payload δύο stream objects κάθεται στα Streams ακριβώς μία φορά. Καμία από εκείνες τις περιπτώσεις δεν θέλει ειδικό χειρισμό, που είναι το σημάδι ότι το μοντέλο είναι σωστό
Πώς ξεχωρίζεις μια διαρροή από κατακράτηση του allocator;
Ελέγχοντας αν ο ζωντανός μετρητής δεσμεύσεων του memory manager κινείται με το workload, όχι μόνο το δεσμευμένο του footprint. Ένας memory manager Delphi κρατά απελευθερωμένα μεγάλα blocks γύρω για επαναχρησιμοποίηση, οπότε μια process που μένει στα 400 MiB μετά το κλείσιμο εγγράφου δεν έχει κατ' ανάγκη διαρροή· μια process του οποίου ο ζωντανός μετρητής blocks ανεβαίνει ένα ανά σελίδα ανά run έχει. Η δοκιμή που οδήγησε αυτό το fix ήταν σκόπιμα μικρή: ένας writer THotPDF που παράγει μία σελίδα, και μετά τρεις readers που τη φορτώνουν. Αφού και τα τέσσερα απελευθερώθηκαν, η αναφορά heap έδειξε ακριβώς τέσσερις ζωντανές δεσμεύσεις 512 KiB, μία ανά instance, που είναι το payload content stream που κατείχε καθεμία και δεν απελευθέρωσε ποτέ. Η κλιμάκωση έκανε το ίδιο μοτίβο αναντίρρητο. Το τρέξιμο του παράλληλου pipeline απόδοσης δύο φορές μετέφερε το μέγεθος δεσμευμένων large-block από 384 MiB σε 640 MiB, αύξηση ανάλογη του πλήθους σελίδων που η κατακράτηση allocator δεν μπορεί να εξηγήσει. Μετά την επανεγγραφή, το διαγνωστικό μίας σελίδας ανέφερε μηδέν large bytes δεσμευμένα και μηδέν δεσμευμένα μόλις τα instances έφυγαν. Αν κυνηγάς το ίδιο είδος ανάπτυξης στη δική σου process, το γράφος εξάρτησης objects με retained bytes σου λέει ποια objects κρατούν τη μνήμη όσο το έγγραφο είναι ανοιχτό· αυτή η ανάρτηση αφορά το release behavior τους όταν κλείνει
Τα κατώφλια μνήμης κάνουν εύθραυστα regression tests, οπότε τα tests που κυκλοφορούν μετράνε κλήσεις destructor αντί αυτού. Ένα fixture χτίζει τον παθολογικό γράφο με το χέρι, με κοινόχρηστο dictionary κάτω από δύο streams, έναν array που περιέχει και το κοινόχρηστο dictionary και τη δική του ρίζα, ένα payload ανατεθειμένο και στα δύο streams, τη ρίζα καταχωρημένη δύο φορές, και έναν wrapper του οποίου το σώμα είναι ξεχωριστά καταχωρημένο, και μετά απελευθερώνει το έγγραφο και ισχυρίζεται μία καταστροφή ανά μοναδικό object: ένα payload, δύο streams, δύο dictionaries, έναν array, έναν wrapper, έναν αριθμό. Κάτω από τον παλιό κώδικα και τα τρία tests διάρκειας ζωής ανέφεραν μηδέν καταστροφές, που είναι η πιο άμεση δυνατή δήλωση του τι σημαίνει «άσε το για την έξοδο της process»
Τι πρέπει να συμβεί πριν πέσει ο γράφος;
Κάθε background δουλειά που δανείζεται objects από τον γράφο πρέπει να σταματήσει πρώτα, και κάθε cache που κρατά display lists ή bitmaps μεταγλωττισμένα από εκείνα τα objects πρέπει να πεταχτεί, αλλιώς ένα worker thread ή μια cached αναφορά διαβάζει απελευθερωμένη μνήμη. Το CloseIndirectObjects ανοίγει λοιπόν με CancelLoadedPagePrefetch, και μετά ακυρώνει το rendered page cache πριν αγγίξει το registry. Η διαδρομή reload στα LoadFromFile και LoadFromStream και ο destructor του component και οι δύο δρομολογούνται μέσα από αυτό, οπότε η ίδια σειρά ισχύει είτε αντικαθιστάς έγγραφο είτε απορρίπτεις το instance· οι κανόνες για την επαναχρησιμοποίηση ενός THotPDF σε έγγραφα στηρίζονται σε εκείνη την εγγύηση. Δύο λεπτομέρειες σε εκείνο το προοίμιο βγήκαν στην επιφάνεια μόνο από το τρέξιμο των tests. Πρώτον, ο destructor έχει ήδη απορρίψει τα frequency sketches πίσω από τα caches render και display list τη στιγμή που κλείνει τον γράφο, οπότε η ακύρωση φυλάσσεται πάνω στο να είναι μη-nil εκείνα τα πεδία αντί να καλείται άνευ όρων. Δεύτερον, το InvalidateRenderedPageCache είναι η ρουτίνα που πυροδοτεί OnLoadedDocumentModified με δείκτη σελίδας -1, και ένας caller που ξαναφορτώνει αρχείο δεν πρέπει να λαμβάνει ειδοποίηση επεξεργασίας για την εσωτερική κατεδάφιση του παλιού εγγράφου. Ο handler σώζεται, θέτεται nil γύρω από την κλήση, και επανέρχεται σε finally, και το reload regression ισχυρίζεται πλήθος ειδοποιήσεων μηδέν μετά το δεύτερο LoadFromStream. Ένα fix μνήμης που αλλάζει σιωπηλά μια σύμβαση event είναι regression με καλύτερο PR, οπότε παίρνει τον δικό του ισχυρισμό. Αν τρέχεις το παράλληλο render pipeline απέναντι σε έγγραφο και μετά το ξαναφορτώνεις, το βήμα ακύρωσης είναι αυτό που κρατά το pool των workers από το να τρέχουν ανταγωνιστικά με την κατεδάφιση
Επαναχρησιμοποίηση του μοτίβου στον δικό σου κώδικα Delphi
Η τεχνική δεν είναι ειδική για PDF. Οποιοδήποτε μοντέλο objects Delphi όπου οι destructors κατέχουν παιδιά ασυνεπώς, όπου το ίδιο παιδί μπορεί να φτάσει κανείς από πολλούς γονείς, ή όπου back-pointers και forward pointers συνυπάρχουν, θα καταρρεύσει ή θα διαρρεύσει κάτω από ναΐφικο Free ανά object. Το fix είναι πάντα το ίδιο σχήμα: αποφάσισε ποια πεδία pointer είναι ιδιοκτησίας και ποια αναφορές, συλλέξε την κλειστότητα των ακμών ιδιοκτησίας μέσω συνόλου pointers που ανέχεται επανεπισκέψεις, κόψε κάθε ακμή, και μετά κατέστρεψε την επίπεδη λίστα. Το βήμα του κοψίματος είναι αυτό που παραλείπει ο κόσμος, και είναι αυτό που κάνει τους υπάρχοντες destructors ασφαλείς για επαναχρησιμοποίηση αντί να επιβάλει επανεγγραφή κάθε class του μοντέλου. Τα όρια αξίζει να ειπωθούν καθαρά, πάντως. Το σύνολο pointers χρησιμοποιεί τη διεύθυνση object ως ταυτότητα, οπότε ένα object που έχει ήδη απελευθερωθεί και του οποίου η διεύθυνση επαναχρησιμοποιήθηκε από φρέσκια δέσμευση θα ήταν αδιαχώριστο· η σειρά εγγυάται ότι κανένας destructor δεν τρέχει κατά τη συλλογή, που είναι αυτό που το αποκλείει. Το περπάτημα βλέπει μόνο τα τέσσερα είδη ακμών που ξέρει, οπότε ένα νέο class που κατέχει παιδί μέσω πεδίου που το περπάτημα δεν εξετάζει θα διαρρεύσει εκείνο το παιδί μέχρι το περπάτημα να διδαχτεί γι' αυτό. Και επειδή τα links επιλύονται μέσω του registry αντί να ακολουθούνται, ένα object που αναφέρεται μόνο από link και δεν καταχωρήθηκε ποτέ δεν είναι καθόλου προσβάσιμο από αυτή την κατεδάφιση· στο HotPDF ο parser εγγυάται την καταχώρηση, αλλά ένας γράφος χτισμένος με το χέρι πρέπει να τηρεί τον ίδιο κανόνα
Όλα αυτά είναι μέσα στο component, οπότε το ορατό αποτέλεσμα για μια εφαρμογή είναι απλώς ότι το κλείσιμο ή το reload εγγράφου επιστρέφει τη μνήμη του, χωρίς καμία αλλαγή API. Το HotPDF είναι εγγενής VCL βιβλιοθήκη PDF για Delphi και C++Builder με πλήρη πηγή· η αναφορά API και ένα trial build βρίσκονται στη σελίδα του HotPDF Delphi PDF component