מאמר טכני

שחרור גרף אובייקטים של PDF פעם אחת בדיוק בדלפי: HotPDF

רכיב HotPDF ל-Delphi משחרר כל אובייקט PDF שהמסמך מחזיק בבעלותו כשהמסמך הזה נסגר או נטען מחדש: THotPDF.CloseIndirectObjects עוברת על רישום האובייקטים, אוספת כל קשת בעלות לסט מצביעים, מנתקת את כל הקשתות האלה, ורק אז משחררת כל צומת ייחודי וכל מטען זרם פעם אחת בדיוק. הסדר הזה בשלושה שלבים הוא מה שמאפשר לילדים משותפים, למעגלי בעלות, לרישומים כפולים ולכינויי wrapper/body לרדת כולם בלי double free ובלי להשאיר משהו מאחור. לפני v2.752.4 אותה שגרה עשתה משהו הרבה יותר פשוט והרבה יותר גרוע: היא שחררה את מקורות זרם הקובץ העצלים, קראה ל-Clear על רשומת IndirectObjects, שחררה את מכולת הרשימה, והשאירה כל אובייקט PDF אמיתי ליציאת התהליך שתאסוף. גם ההערה בקוד ההוא הייתה כנה לגבי זה. שחרור האובייקטים בנפרד גרם להפרות גישה, ולכן ה"גישה הבטוחה" הייתה לא לשחרר אותם בכלל. הפוסט הזה עוסק בלמה הגישה הפרטנית באמת קרסה, ואיך נראית סגירה שעובדת בשפה עם ניהול זיכרון ידני

למה אי אפשר סתם לעשות Free לכל אובייקט רשום?

כי ההורסים של מחלקות האובייקטים לא מסכימים מי הוא הבעלים של מה, והרישום מכיל רשומות בכמה רמות של אותה שרשרת בעלות. מעבר על הרשימה וקריאה ל-Free על כל רשומה משחררת לכן חלק מהזיכרון פעמיים וחלק ממנו אף פעם, תלוי אילו מחלקות במקרה יושבות זו ליד זו

שלוש אי-סימטריות ב-HPDFObjs.pas וב-HPDFDoc.pas יוצרות את הבעיה. THPDFDictionaryObject.Destroy עובר על ה-Items שלו ומשחרר ערך רק כשה-IsIndirect הוא False, מתוך הנחה שילדים עקיפים שייכים לרישום וישוחררו שם. THPDFArrayObject.Destroy לא עושה הבחנה כזו ומשחרר כל איבר שהוא מחזיק. ו-THPDFIndirectObject.Destroy, ה-wrapper שנושא מספר אובייקט, משחרר את גוף ה-InternalObject שלו. עכשיו דמיינו רישום שמחזיק מילון עקיף, מערך שמפרט את אותו מילון באחד מהתאים שלו, ו-wrapper שהגוף שלו גם רשום כשורש נפרד — וזה בדיוק מה שהמפרסר מפיק על קבצים אמיתיים. שחררו את המערך ראשון והמילון נעלם לפני שהרישום מגיע אליו. שחררו את ה-wrapper ואת הגוף, בכל סדר, והקריאה השנייה מריצה הורס על מצביע תלוי. שחררו את המילון לבדו וכל ילד עקיף שהוא דילג עליו נשאר מוקצה לנצח. שום סדר של הרישום לא מתקן את זה, כי הרישום הוא רשימה שטוחה ויחס הבעלות הוא גרף, והסקה על הגרף היא הדרך היחידה החוצה

למה שחרור כל רשומה ברישום של HotPDF קרס: THPDFDictionaryObject.Destroy מדלג על ילדים עקיפים בעוד THPDFArrayObject.Destroy משחרר כל מה שהוא מחזיק ו-THPDFIndirectObject.Destroy משחרר את גוף ה-InternalObject שלו, כך שעם wrapper, מערך ומילון משותף ברשימת IndirectObjects שטוחה אחת חלק מהזיכרון מת פעמיים וחלק אף פעם
ההורסים לא מסכימים מי הוא הבעלים של מה, והרישום מחזיק רשומות בכמה רמות של אותה שרשרת בעלות, כך ששום סדר של רשימה שטוחה לא יכול להפוך Free נאיבי לכל אובייקט לסגירה נכונה

מה נחשב קשת בעלות בגרף אובייקטים של PDF?

קשת בעלות היא מצביע שהמקור אחראי להרוס את היעד שלו; הפניה היא כל דבר אחר, והסגירה חייבת ללכת אחרי הסוג הראשון ולהתעלם מהשני. ב-HotPDF זה נותן בדיוק ארבעה סוגי קשתות: ה-Items של THPDFDictionaryObject, ה-Items של THPDFArrayObject, ה-InternalObject שמאחורי THPDFIndirectObject, ושני החצאים של THPDFStreamObject, ה-Dictionary שלו ומטען ה-Stream שלו. סוגי ההפניה חשובים בדיוק באותה מידה, כי הליכה אחרי אחת מהם הופכת מעבר על גרף ללולאה אינסופית או ל-use-after-free. THPDFLink מחזיק מספר אובייקט ומספר דור, שזה איך ש-ISO 32000-1 §7.3.10 מגדיר הפניה עקיפה: שם של אובייקט שחי במקום אחר, ולא האובייקט עצמו. פענוח המספר הזה דרך הרישום מניב צומת שנמצאת כבר בבעלותה של קשת אחרת, ולכן CloseIndirectObjects אף פעם לא מפנה הפניות בכלל. מצביע ה-FParent שמילונים ומערכים שומרים הוא אותו סיפור בכיוון ההפוך; ההורה כבר מחזיק בבעלות הילד, כך שהליכה במצביע כלפי מעלה רק תחזור לצומת שהמעבר כבר עבר בו. שניהם נשארים בשקט, וההערה בקוד מקור אומרת זאת בשורה אחת: קישורים ומצביעי parent הם הפניות, לא קשתות בעלות

קשתות בעלות מול הפניות בגרף האובייקטים של HotPDF: Items של DictionaryObject, Items של ArrayObject, ה-InternalObject של IndirectObject ושני החצאים של StreamObject נעקבים ומנותקים, בעוד שמספר האובייקט ב-THPDFLink ומצביע ה-FParent הם שמות לאובייקטים שחיים במקום אחר, ולכן CloseIndirectObjects אף פעם לא מפנה אליהם
קשת בעלות היא מצביע שהמקור חייב להרוס את היעד שלו; הליכה אחרי הפניה במקום זה הייתה הופכת את המעבר לרוחב ללולאה אינסופית או ל-use-after-free, ולכן קישורים ומצביעי parent נשארים בשקט

איך עובדת הסגירה בת שלושת השלבים?

שלב ראשון הוא איסוף לרוחב. השגרה מזרעת רשימת עבודה בכל רשומה של IndirectObjects, ואז לכל צומת היא מוסיפה את היעדים של קשתות הבעלות של אותו צומת, ומדלגת על כל דבר שכבר נראה. סט ה"נראו" הוא מערך open-addressing של מצביעים גולמיים עם hash דרך HPDFFastCacheHashInt64 על ערך המצביע, עם linear probing ו-GrowSeen שמכפיל את הגודל כשהוא מגיע לחצי מלא. שום דבר במבנה הזה לא מקצה זיכרון לכל צומת, וזה חשוב כשמסמך נושא כמה מאות אלפי אובייקטים. מטעני זרמים נכנסים לרשימת Streams נפרדת כי הם צאצאים של TStream ולא צמתים של THPDFObject, ומשוחררים במעבר משלהם

הסגירה בת שלושת השלבים של CloseIndirectObjects ב-HotPDF: איסוף לרוחב שמזרע את רשימת העבודה מ-IndirectObjects ועוקב רק אחרי קשתות בעלות דרך סט נראו ב-open-addressing עם hash של HPDFFastCacheHashInt64, שלב שני מנתק כל קשת עם MarkAsFreed והשמת nil, ושלב שלישי משחרר כל צומת ומטען זרם פעם אחת בדיוק
חיתוך הקשתות לפני שאף הורס רץ הוא מה שהופך את ההורסים הקיימים לבטוחים לשימוש חוזר: כל אחד מהם מוצא אז אין למה לרדת ברקורסיה, כך שילדים משותפים, מעגלים וכינויי wrapper-body יורדים כולם בלי double free
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;

// שלב ראשון: לזרוע מהרישום, ואז ללכת רק אחרי קשתות בעלות
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;

שלב שני הוא החלק שהופך את ההורסים לבטוחים להרצה: כל קשת בעלות מוגדרת ל-nil לפני שאף הורס רץ. wrapper מקבל MarkAsFreed, שמנקה את FInternalObject ומדליק את הדגל שההורס שלו בודק ראשון. לאובייקט זרם מושמות nil ב-Dictionary וב-Stream. כל איבר במילון מקבל Item^.Value מנוקה וכל תא במערך נדרס ב-nil. אחרי המעבר הזה לגרף לא נשארו קשתות, כך שכששלב שלישי קורא ל-Free על כל צומת ב-Nodes ואז על כל מטען ב-Streams, כל הורס מוצא אין למה לרדת ברקורסיה והורס רק את עצמו

// שלב שני: לנתק כל קשת בעלות לפני שמשחררים משהו
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;

// שלב שלישי: כל צומת ומטען ייחודיים משוחררים פעם אחת בדיוק
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);

תראו מה הפיצול קונה. מילון שמשותף לשני אובייקטי זרם נאסף פעם אחת, מנותק משניהם, ומשוחרר פעם אחת. מעגל שבו מערך מפרט את מילון ההורה של עצמו מסתיים כי סט ה"נראו" מסרב לביקור השני. wrapper והגוף שלו, ששניהם רשומים כשורשים, הם שני מצביעים שונים בסט, כך ששניהם משוחררים, וההורס של ה-wrapper כבר לא מנסה לשחרר את הגוף כי MarkAsFreed כבר לקח את הקשת הזו. TMemoryStream בודד שמושם כמטען של שני אובייקטי זרם יושב ב-Streams פעם אחת בדיוק. אף אחד מהמקרים האלה לא צריך טיפול מיוחד, וזה הסימן שהמודל נכון

איך מבדילים בין דליפה לבין זיכרון שה-allocator מחזיק?

בבדיקה אם מונה ההקצאות החיות של מנהל הזיכרון זז עם העומס, ולא רק טביעת הרגל השמורה שלו. מנהל זיכרון של דלפי מחזיק בלוקים גדולים ששוחררו לשימוש חוזר, כך שתהליך שנשאר על 400 MiB אחרי סגירת מסמך לאו דווקא דלף; תהליך שמונה הבלוקים החיים שלו עולה באחד לכל עמוד בכל הרצה — כן. הבדיקה שהובילה לתיקון הזה הייתה קטנה בכוונה: כותב THotPDF אחד שמפיק עמוד אחד, ואז שלושה קוראים שטוענים אותו. אחרי שכל הארבעה שוחררו, דוח ה-heap הראה בדיוק ארבע הקצאות חיות של 512 KiB, אחת לכל מופע, שזה מטען זרם התוכן שכל אחד מהם החזיק בבעלותו ואף פעם לא שחרר. הגדלה של הבדיקה הפכה את אותו דפוס לבלתי ניתן לטעות. הרצה כפולה של צינור הרינדור המקבילי הזיזה את נתון הבלוקים הגדולים המוקצים מ-384 MiB ל-640 MiB, עלייה שפרופורציונלית למספר העמודים ושה-allocator לא יכול להסביר. אחרי הכתיבה מחדש, האבחון של עמוד בודד דיווח על אפס בתים גדולים מוקצים ואפס שמורים ברגע שהמופעים נעלמו. אם אתם צדים אותו סוג של גידול בתהליך שלכם, גרף התלויות של האובייקטים עם הבתים המוחזקים אומר לכם אילו אובייקטים מחזיקים את הזיכרון כל עוד המסמך פתוח; הפוסט הזה עוסק בהתנהגות השחרור שלהם כשהוא נסגר

ספי זיכרון מייצרים בדיקות רגרסיה שבירות, ולכן הבדיקות שנשלחות סופרות קריאות להורסים במקום. קובץ בדיקה בונה את הגרף הפתולוגי ביד, עם מילון משותף תחת שני זרמים, מערך שמכיל גם את המילון המשותף וגם את השורש של עצמו, מטען אחד שמושם לשני הזרמים, השורש רשום פעמיים, ו-wrapper שהגוף שלו רשום בנפרד, ואז משחרר את המסמך ומאמת הרס אחד לכל אובייקט ייחודי: מטען אחד, שני זרמים, שני מילונים, מערך אחד, wrapper אחד, מספר אחד. תחת הקוד הישן כל שלוש בדיקות משך החיים דיווחו על אפס הרסות, וזו ההצהרה הישירה ביותר האפשרית של מה ש"להשאיר ליציאת התהליך" אומר

מה חייב לקרות לפני שהגרף יורד?

כל עבודת רקע שלווה אובייקטים מהגרף חייבת לעצור קודם, וכל cache שמחזיק display lists או bitmaps שהורכבו מהאובייקטים האלה חייב להיזרק, אחרת thread של worker או הפניה מ-cache קוראים זיכרון משוחרר. CloseIndirectObjects נפתחת לכן עם CancelLoadedPagePrefetch, ואז מבטלת את ה-cache של העמודים המרונדרים לפני שהיא נוגעת ברישום. נתיב הטעינה מחדש ב-LoadFromFile וב-LoadFromStream והורס הרכיב עוברים שניהם דרכה, כך שאותו סדר חל בין אם אתם מחליפים מסמך ובין אם אתם משליכים את המופע; הכללים לשימוש חוזר ב-THotPDF אחד בין מסמכים נשענים על ההבטחה הזו. שני פרטים בפתיח הזה צפו רק מהרצת הבדיקות. ראשית, ההורס כבר השליך את סקיצות התדירות שמאחורי ה-cache של הרינדור ושל ה-display list עד שהוא סוגר את הגרף, ולכן הביטול מוגן בבדיקה שהשדות האלה אינם nil ולא נקרא ללא תנאי. שנית, InvalidateRenderedPageCache היא השגרה שמפעילה את OnLoadedDocumentModified עם אינדקס עמוד של 1-, וקורא שטוען קובץ מחדש לא אמור לקבל התראת עריכה על הפירוק הפנימי של המסמך הישן. ה-handler נשמר, מוגדר ל-nil סביב הקריאה, ומשוחזר ב-finally, ורגרסיית הטעינה מחדש מאמתת מונה התראות של אפס אחרי ה-LoadFromStream השני. תיקון זיכרון שמשנה בשקט חוזה של אירוע הוא רגרסיה עם יחסי ציבור טובים יותר, ולכן יש לו בדיקה משלו. אם אתם מריצים את צינור הרינדור המקבילי על מסמך ואז טוענים אותו מחדש, שלב הביטול הוא מה שמונע ממאגר ה-workers להתחרות בסגירה

שימוש חוזר בתבנית בקוד הדלפי שלכם

הטכניקה לא ספציפית ל-PDF. כל מודל אובייקטים בדלפי שבו הורסים מחזיקים בילדים באופן לא עקבי, שבו אפשר להגיע לאותו ילד מכמה הורים, או שבו מצביעי back ו-forward חיים יחד, יקרוס או ידלוף תחת Free נאיבי לכל אובייקט. התיקון הוא תמיד אותה צורה: להחליט אילו שדות מצביע הם בעלות ואילו הפניות, לאסוף את הסגור של קשתות הבעלות דרך סט מצביעים שסובל ביקורים חוזרים, לחתוך כל קשת, ואז להרוס את הרשימה השטוחה. שלב החיתוך הוא זה שאנשים מדלגים עליו, והוא זה שהופך את ההורסים הקיימים לבטוחים לשימוש חוזר במקום לכפות כתיבה מחדש של כל מחלקה במודל. הגבולות בכל זאת שווה להצהיר בפשטות. סט המצביעים משתמש בכתובת האובייקט כזהות, כך שאובייקט שכבר שוחרר וכתובתו נוצלה מחדש על ידי הקצאה חדשה יהיה בלתי ניתן להבחנה; סדר הפעולות מבטיח שאף הורס לא רץ בזמן האיסוף, וזה מה ששולל את זה. המעבר רואה רק את ארבעת סוגי הקשתות שהוא מכיר, כך שמחלקה חדשה שמחזיקה ילד דרך שדה שהמעבר לא בודק תדלוף את הילד הזה עד שילמדו את המעבר עליה. ומכיוון שקישורים נפתרים דרך הרישום ולא נעקבים, אובייקט שמופנה רק על ידי קישור ומעולם לא נרשם אינו נגיש לסגירה הזו בכלל; ב-HotPDF המפרסר מבטיח רישום, אבל גרף שנבנה ביד חייב לכבד את אותו כלל

כל זה נמצא בתוך הרכיב, כך שההשפעה הנראית לאפליקציה היא פשוט שסגירה או טעינה מחדש של מסמך מחזירה את הזיכרון שלו, בלי שינוי API. ‏HotPDF היא ספריית PDF ילידית ל-VCL עבור Delphi ו-C++Builder עם קוד מקור מלא; תיעוד ה-API וגרסת ניסיון נמצאים בעמוד רכיב HotPDF ל-PDF בדלפי