مقاله فنی

آزاد کردن گراف شیء PDF دقیقاً یک بار در Delphi: HotPDF

HotPDF Delphi Component وقتی سندی بسته می‌شود یا دوباره بارگذاری می‌شود، هر شیء PDFی را که آن سند مالکش است آزاد می‌کند: THotPDF.CloseIndirectObjects رجیستری objectها را می‌پیماید، هر یال مالکیت را در یک مجموعهٔ اشاره‌گر جمع می‌کند، همهٔ آن یال‌ها را جدا می‌کند و تنها پس از آن هر گره یکتا و هر payload مربوط به stream را دقیقاً یک بار آزاد می‌کند. همین ترتیب سه-فازی است که می‌گذارد فرزندان مشترک و چرخه‌های مالکیت و ثبت‌های تکراری و aliasهای wrapper/body همه بدون آزادسازی دوباره و بدون جا گذاشتن چیزی پایین بیایند. پیش از v2.752.4 همان روتیین چیز بسیار ساده‌تر و بسیار بدتری می‌کرد: منابع stream فایل تنبل را آزاد می‌کرد، Clear را روی لیست IndirectObjects صدا می‌زد، ظرف لیست را آزاد می‌کرد و هر شیء PDF واقعی را برای بازیافتِ خروج پروسه جا می‌گذاشت. کامنت آن کد هم صادقانه همین را می‌گفت. آزاد کردن objectها به‌صورت تک‌تک access violation می‌داد، پس «رویکرد ایمن» این بود که اصلاً آزادشان نکنیم. این مقاله دربارهٔ این است که چرا رویکرد تک‌تک واقعاً کرش می‌کرد و یک teardown کارآمد در زبانی با مدیریت دستی حافظه چه شکلی است

چرا نمی‌شود فقط هر شیء ثبت‌شده را Free کرد؟

چون destructorهای کلاس‌های شیء دربارهٔ اینکه چه چیزی مالِ کیست با هم اختلاف دارند و رجیستری درایه‌هایی در چند سطح از یک زنجیرهٔ مالکیت واحد دارد. پس پیمودن لیست و صدا زدن Free روی هر درایه، بسته به اینکه کدام کلاس‌ها تصادفاً کنار هم نشسته باشند، بخشی از حافظه را دو بار آزاد می‌کند و بخشی را هرگز

سه عدم‌تقارن در HPDFObjs.pas و HPDFDoc.pas این مشکل را می‌سازند. THPDFDictionaryObject.Destroy روی Items اش راه می‌رود و یک مقدار را فقط وقتی آزاد می‌کند که IsIndirect برابر False باشد، با این فرض که فرزندان غیرمستقیم مالِ رجیستری‌اند و همان‌جا آزاد می‌شوند. THPDFArrayObject.Destroy چنین تفکیکی نمی‌کند و هر درایه‌ای را که نگه می‌دارد آزاد می‌کند. و THPDFIndirectObject.Destroy، یعنی همان wrapperی که یک شمارهٔ شیء حمل می‌کند، بدنهٔ InternalObject اش را آزاد می‌کند. حالا رجیستری‌ای را در نظر بگیر که یک dictionary غیرمستقیم دارد، یک آرایه که همان dictionary را در یکی از خانه‌هایش فهرست می‌کند، و یک wrapper که بدنه‌اش هم به‌صورت یک root جداگانه ثبت شده؛ دقیقاً همان چیزی که parser روی فایل‌های واقعی تولید می‌کند. اول آرایه را آزاد کن و dictionary پیش از آنکه رجیستری به آن برسد از بین می‌رود. wrapper و بدنه را آزاد کن، به هر ترتیبی، و فراخوانی دوم یک destructor را روی یک اشاره‌گر آویزان اجرا می‌کند. فقط dictionary را آزاد کن و هر فرزند غیرمستقیمی که جا انداخته برای همیشه تخصیص‌یافته می‌ماند. هیچ ترتیبی از رجیستری این را درست نمی‌کند، چون رجیستری یک لیست تخت است و رابطهٔ مالکیت یک گراف، و استدلال دربارهٔ آن گراف تنها راه خروج است

چرا آزاد کردن هر درایهٔ رجیستری HotPDF کرش می‌کرد: THPDFDictionaryObject.Destroy فرزندان غیرمستقیم را رد می‌کند در حالی که THPDFArrayObject.Destroy هر چه را نگه می‌دارد آزاد می‌کند و THPDFIndirectObject.Destroy بدنهٔ InternalObject اش را آزاد می‌کند، پس با یک wrapper و یک آرایه و یک dictionary مشترک در یک لیست تخت IndirectObjects بخشی از حافظه دوبار و بخشی هرگز نمی‌میرد
destructorها دربارهٔ اینکه چه چیزی مالِ کیست اختلاف دارند و رجیستری درایه‌هایی در چند سطح از یک زنجیرهٔ مالکیت واحد دارد، پس هیچ ترتیبی از یک لیست تخت نمی‌تواند Free سادهٔ هر شیء را به یک teardown درست تبدیل کند

در گراف شیء PDF چه چیزی یک یال مالکیت حساب می‌شود؟

یال مالکیت اشاره‌گری است که مبدأ مسئول نابود کردن هدفش است؛ بقیهٔ چیزها reference‌اند و teardown باید اولی را دنبال کند و دومی را نادیده بگیرد. در HotPDF این دقیقاً چهار نوع یال می‌دهد: Items یک THPDFDictionaryObject، Items یک THPDFArrayObject، InternalObject پشت یک THPDFIndirectObject، و هر دو نیمهٔ یک THPDFStreamObject یعنی Dictionary و payload مربوط به Stream. انواع reference به همان اندازه مهم‌اند، چون دنبال کردن یکی از آن‌ها یک پیمایش گراف را به حلقهٔ بی‌پایان یا use-after-free تبدیل می‌کند. THPDFLink یک شمارهٔ شیء و generation نگه می‌دارد که ISO 32000-1 §7.3.10 آن را یک ارجاع غیرمستقیم تعریف می‌کند: نامی برای شیئی که جای دیگری زندگی می‌کند، نه خود آن شیء. resolve کردن آن شماره از طریق رجیستری گرهی می‌دهد که از قبل یال دیگری مالکش است، پس CloseIndirectObjects اصلاً هیچ linkی را dereference نمی‌کند. اشاره‌گر برگشتی FParent که dictionaryها و آرایه‌ها نگه می‌دارند در جهت مخالف همان داستان است؛ والد از قبل مالک فرزند است، پس دنبال کردن اشاره‌گر رو به بالا فقط گرهی را دوباره می‌بیند که پیمایش از آن گذشته. هر دو رها می‌شوند و کامنت داخل source در یک خط همین را می‌گوید: linkها و اشاره‌گرهای والد reference هستند، نه یال مالکیت

یال‌های مالکیت در برابر reference در گراف شیء HotPDF: Items یک DictionaryObject و Items یک ArrayObject و InternalObject یک IndirectObject و هر دو نیمهٔ یک StreamObject دنبال و جدا می‌شوند، در حالی که شمارهٔ شیء یک THPDFLink و اشاره‌گر برگشتی FParent نام‌هایی برای اشیائی هستند که جای دیگری زندگی می‌کنند و CloseIndirectObjects هرگز dereferenceشان نمی‌کند
یال مالکیت اشاره‌گری است که مبدأ باید هدفش را نابود کند؛ دنبال کردن یک reference به‌جایش پیمایش سطح-اول را به حلقهٔ بی‌پایان یا use-after-free تبدیل می‌کند، پس linkها و اشاره‌گرهای والد رها می‌شوند

teardown سه-فازی چگونه کار می‌کند؟

فاز یک یک جمع‌آوری سطح-اول است. روتیین یک worklist را با هر درایهٔ IndirectObjects بذر می‌پاشد و بعد برای هر گره هدف‌های یال‌های مالکیت آن گره را اضافه می‌کند و هر چیزی را که قبلاً دیده شده رد می‌کند. مجموعهٔ دیده‌شده‌ها یک آرایهٔ open-addressing از اشاره‌گرهای خام است که با HPDFFastCacheHashInt64 روی مقدار اشاره‌گر hash می‌شود، با linear probing و یک GrowSeen که هر بار به نصف ظرفیت رسید دو برابر می‌کند. هیچ‌چیز در آن ساختار به ازای هر گره تخصیص نمی‌دهد و این وقتی مهم می‌شود که سندی چند صد هزار شیء دارد. payloadهای stream به یک لیست جداگانه Streams می‌روند، چون آن‌ها descendantهای TStream هستند نه گره‌های THPDFObject، و در پاس خودشان آزاد می‌شوند

teardown سه-فازی CloseIndirectObjects در HotPDF: یک جمع‌آوری سطح-اول worklist را از IndirectObjects بذر می‌پاشد و فقط یال‌های مالکیت را از یک مجموعهٔ دیده‌شدهٔ open-addressing که با HPDFFastCacheHashInt64 hash شده دنبال می‌کند، فاز دو هر یال را با MarkAsFreed و nil کردن جدا می‌کند و فاز سه هر گره و payload stream را دقیقاً یک بار آزاد می‌کند
بریدن یال‌ها پیش از اجرای هر destructor همان چیزی است که استفادهٔ دوباره از destructorهای موجود را ایمن می‌کند: بعد از آن هرکدام چیزی برای بازگشت به آن پیدا نمی‌کند، پس فرزندان مشترک و چرخه‌ها و aliasهای wrapper-body همه بدون آزادسازی دوباره پایین می‌آیند
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;

فاز دو همان بخشی است که destructorها را برای اجرا ایمن می‌کند: هر یال مالکیت پیش از آنکه هر destructorی اجرا شود nil می‌شود. یک wrapper متد MarkAsFreed می‌گیرد که FInternalObject را پاک می‌کند و پرچمی را ست می‌کند که destructorش اول از همه بررسی‌اش می‌کند. یک stream object هم Dictionary و هم Stream اش nil می‌شود. هر درایهٔ dictionary مقدار Item^.Value اش پاک می‌شود و هر خانهٔ آرایه با 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 object شریکش‌اند یک بار جمع می‌شود، از هر دو جدا می‌شود و یک بار آزاد می‌شود. یک چرخه که در آن آرایه‌ای dictionary والد خودش را فهرست می‌کند تمام می‌شود، چون مجموعهٔ دیده‌شده‌ها بازدید دوم را رد می‌کند. یک wrapper و بدنه‌اش که هر دو به‌عنوان root ثبت شده‌اند دو اشاره‌گر متمایز در مجموعه‌اند، پس هر دو آزاد می‌شوند و destructor wrapper دیگر تلاش نمی‌کند بدنه را آزاد کند، چون MarkAsFreed از قبل آن یال را برداشته. یک TMemoryStream واحد که به‌عنوان payload دو stream object نسبت داده شده دقیقاً یک بار در Streams می‌نشیند. هیچ‌یک از این موارد به برخورد ویژه‌ای نیاز ندارد و همین نشانهٔ درست بودن مدل است

چطور نشت را از نگه‌داشت allocator تشخیص می‌دهی؟

با بررسی اینکه آیا شمارش تخصیص‌های زندهٔ memory manager با حجم کار حرکت می‌کند، نه فقط رد پای رزرو‌شدهٔ آن. یک memory manager دلفی بلوک‌های بزرگ آزادشده را برای استفادهٔ دوباره نگه می‌دارد، پس پروسه‌ای که بعد از بستن سند روی 400 MiB می‌ماند لزوماً نشت نکرده؛ پروسه‌ای که شمارش بلوک‌های زنده‌اش به ازای هر صفحه در هر اجرا یک واحد بالا می‌رود. پروبی که این fix را به راه انداخت عمداً کوچک بود: یک نویسندهٔ THotPDF که یک صفحهٔ واحد تولید می‌کرد و بعد سه خواننده که همان را بارگذاری می‌کردند. پس از آزاد شدن هر چهار مورد، گزارش heap دقیقاً چهار تخصیص زندهٔ 512 KiB نشان داد، یکی به ازای هر نمونه، که همان payload مربوط به stream محتوا بود که هرکدام مالکش بود و هرگز آزادش نمی‌کرد. بزرگ‌تر کردن مقیاس همان الگو را انکارناپذیر کرد. اجرای دو بارهٔ خط لولهٔ رندر موازی رقم بلوک‌های بزرگ تخصیص‌یافته را از 384 MiB به 640 MiB برد، افزایشی متناسب با تعداد صفحه که نگه‌داشت allocator نمی‌تواند توضیحش بدهد. بعد از بازنویسی، همان عیب‌یاب تک-صفحه‌ای پس از از بین رفتن نمونه‌ها صفر byte بزرگ تخصیص‌یافته و صفر byte رزرو‌شده گزارش کرد. اگر در پروسهٔ خودت دنبال همین نوع رشد می‌گردی، گراف وابستگی objectها با byteهای نگه‌داشته‌شده می‌گوید تا وقتی سند باز است کدام objectها حافظه را نگه می‌دارند؛ این مقاله دربارهٔ رفتار آزادسازی‌شان موقع بسته شدن سند است

آستانه‌های حافظه تست‌های رگرسیون شکننده می‌سازند، پس تست‌های منتشرشده در عوض فراخوانی‌های destructor را می‌شمارند. یک fixture گراف بیمارگونه را با دست می‌سازد، با یک dictionary مشترک زیر دو stream، یک آرایه که هم آن dictionary مشترک را در خود دارد و هم root خودش را، یک payload که به هر دو stream نسبت داده شده، rootی که دو بار ثبت شده، و یک wrapper که بدنه‌اش جداگانه ثبت شده؛ بعد سند را آزاد می‌کند و یک نابودی به ازای هر شیء یکتا assertion می‌کند: یک payload، دو stream، دو dictionary، یک آرایه، یک wrapper، یک عدد. زیر کد قدیمی هر سه تست طول-عمر صفر نابودی گزارش می‌کردند، که مستقیم‌ترین بیان ممکن از معنای «بگذارش برای خروج پروسه» است

پیش از پایین آمدن گراف چه چیزی باید رخ بدهد؟

هر کار پس‌زمینه‌ای که objectها را از گراف قرض می‌گیرد باید اول متوقف شود و هر cacheی که display list یا bitmapهای کامپایل‌شده از آن objectها را نگه می‌دارد باید دور ریخته شود، وگرنه یک thread کارگر یا یک ارجاع cache‌شده حافظهٔ آزادشده را می‌خواند. پس CloseIndirectObjects با CancelLoadedPagePrefetch باز می‌شود و بعد پیش از آنکه به رجیستری دست بزند، cache صفحات رندرشده را بی‌اعتبار می‌کند. مسیر بارگذاری مجدد در LoadFromFile و LoadFromStream و destructor کامپوننت هر دو از آن می‌گذرند، پس همان ترتیب اعمال می‌شود، چه سندی را جایگزین کنی و چه خود نمونه را خلاص کنی؛ قواعد استفادهٔ دوباره از یک THotPDF میان چند سند به همین تضمین تکیه دارد. دو جزئیات در آن پیش‌درآمد فقط از اجرای تست‌ها رو شد. اول، destructor تا وقتی گراف را می‌بندد از قبل sketchهای frequency پشت cacheهای رندر و display list را دور انداخته، پس این بی‌اعتبار کردن روی غیر-nil بودن آن فیلدها محافظت شده، نه اینکه بی‌قیدوشرط صدا زده شود. دوم، InvalidateRenderedPageCache همان روتیینی است که OnLoadedDocumentModified را با شاخص صفحهٔ -1 شلیک می‌کند و callerی که یک فایل را دوباره بارگذاری می‌کند نباید برای teardown داخلی سند قبلی یک اعلان ویرایش بگیرد. handler ذخیره می‌شود، دور فراخوانی nil می‌شود و در یک finally برگردانده می‌شود، و رگرسیون بارگذاری مجدد شمارش اعلان صفر را بعد از دومین LoadFromStream assertion می‌کند. یک fix حافظه که بی‌صدا یک قرارداد رویداد را عوض کند یک رگرسیون است با روابط عمومی بهتر، پس assertion خودش را می‌گیرد. اگر خط لولهٔ رندر موازی را روی سندی اجرا کنی و بعد دوباره بارگذاری‌اش کنی، مرحلهٔ لغو همان چیزی است که نمی‌گذارد استخر کارگر با teardown مسابقه بدهد

استفادهٔ دوباره از این الگو در کد Delphi خودت

این تکنیک مخصوص PDF نیست. هر مدل شیء دلفی که در آن destructorها فرزندان را ناهمگون مالک باشند، یا همان فرزند از چند والد قابل‌دسترس باشد، یا اشاره‌گرهای برگشتی و رو-به-جلو کنار هم زندگی کنند، زیر یک Free سادهٔ هر شیء کرش می‌کند یا نشت می‌دهد. fix همیشه یک شکل دارد: تعیین کن کدام فیلدهای اشاره‌گر مالک‌اند و کدام reference، بستار یال‌های مالکیت را از طریق یک مجموعهٔ اشاره‌گر که بازدید دوباره را تحمل می‌کند جمع کن، هر یال را ببُر، بعد لیست تخت را نابود کن. مرحلهٔ بریدن همانی است که آدم‌ها جا می‌اندازند و همانی است که استفادهٔ دوباره از destructorهای موجود را ایمن می‌کند، به‌جای اینکه بازنویسی هر کلاس در مدل را اجباری کند. مرزها اما ارزش دارد صریح گفته شوند. مجموعهٔ اشاره‌گر از آدرس شیء به‌عنوان هویت استفاده می‌کند، پس شیئی که از قبل آزاد شده و آدرسش با یک تخصیص تازه دوباره استفاده شده باشد قابل‌تشخیص نیست؛ ترتیب تضمین می‌کند که هیچ destructorی در طول جمع‌آوری اجرا نشود و همین این حالت را منتفی می‌کند. پیمایش فقط همان چهار نوع یالی را می‌بیند که می‌شناسد، پس کلاس جدیدی که فرزندی را از طریق فیلدی مالک باشد که پیمایش بازرسی‌اش نمی‌کند، آن فرزند را نشت می‌دهد تا وقتی که پیمایش دربارهٔ آن آموزش ببیند. و چون linkها از طریق رجیستری resolve می‌شوند نه دنبال، شیئی که فقط با یک link ارجاع داده شده و هرگز ثبت نشده اصلاً با این teardown قابل‌دسترس نیست؛ در HotPDF parser ثبت را تضمین می‌کند، اما یک گراف دست‌ساز باید همان قاعده را رعایت کند

همهٔ این‌ها داخل کامپوننت است، پس اثر قابل‌مشاهده برای یک اپلیکیشن صرفاً این است که بستن یا دوباره بارگذاری کردن یک سند حافظه‌اش را پس می‌دهد، بی‌هیچ تغییری در API. HotPDF یک کتابخانهٔ PDF بومی VCL برای Delphi و C++Builder با سورس کامل است؛ مرجع API و یک build آزمایشی روی صفحهٔ کامپوننت PDF یعنی HotPDF برای Delphi در دسترس است