מאמר טכני

מבוי סתום WaitForIdle ברינדור אסינכרוני PDFium ב-Delphi

רינדור אצווה נתקע באמצע כי ה-executor ב-PDFium Component לא מחשיב משימה כגמורה עד שהתשובה שלה נשלחה. תחת padSynchronize התשובה הזו רצה על ה-thread הראשי. אם ה-thread הראשי חוסם בלי לשאוב CheckSynchronize, ה-worker ממתין ל-thread הראשי בעוד ה-thread הראשי ממתין ל-idle

תמונת הדיבאגר חד-משמעית ברגע שראיתם אותה. משהים את התהליך התקוע ו-thread הראשי יושב בתוך המתנה על אירוע ה-idle, כמה frames מתחת ללולאת האצווה שלכם עצמכם. עוברים ל-thread worker כלשהו והוא יושב בתוך TThread.Synchronize, מחזיק תוצאה גמורה שהוא לא יכול למסור. שום דבר לא מסתובב, אף CPU לא שורף, התהליך פשוט חנוי. המאמר הזה עוסק בשאלה מדוע המצב הזה קיים בכלל, ובשלושה כללים שכנים שקובעים האם מאגר worker של Delphi מעל PDFium מתנהג יפה או נושך: מה QueueCapacity באמת חוסם, באיזה סדר כיבוי חייב לבטל, ומה מקביליות לא קונה לכם היכן שמדובר בבעלות אובייקטי PDFium

מדוע WaitForIdle תוקע את ה-thread הראשי?

הוא נתקע כי idle ב-TPdfAsyncExecutor מוגדר לכלול שילוח תשובה, לא רק השלמת worker. הספירה הרצה מוגדלת ב-DequeueTask כש-worker מרים משימה, והיא מופחתת ב-TaskFinished, ש-worker קורא לה רק אחרי ש-TPdfAsyncTaskOperation.Execute חזרה. השיטה הזו מריצה את גוף ה-worker, רושמת את התוצאה, ואז משלחת את התשובה בהתאם ל-TPdfAsyncDispatchMode. עם padSynchronize השילוח הוא קריאת TThread.Synchronize, כך ש-Execute לא חוזרת עד ש-thread הראשי הריץ אותה

Delphi שם את המחצית השנייה של החוזה הזה עליכם. TThread.Synchronize מצרפת את השיטה לתור גלובלי וחוסמת את ה-thread הקורא על אירוע; משהו על ה-thread הראשי חייב לקרוא ל-CheckSynchronize לפני שהאירוע הזה אי פעם מסומן. לולאת ההודעות של VCL עושה זאת בשבילכם בין הודעות, וזו בדיוק הסיבה שהבאג בלתי-נראה בשימוש אינטראקטיבי ומופיע ברגע שכותבים לולאת אצווה חוסמת. thread ראשי חוסם הוא thread ראשי שעזב את לולאת ההודעות, ו-thread ראשי מחוץ ללולאת ההודעות לא שואב אף אחד

uses
  System.Classes, FPdfAsync, PDFium;

// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // the main thread now parks in a kernel wait

// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.

השלמת משימה ו-idle של executor הם שני אבני-דרך שונות

הן מופרדות בכוונה, וידיעה על איזו מהן אתם ממתינים היא כל התיקון. IPdfAsyncTask.WaitFor מתקיים ברגע שתוצאת ה-worker מוכרעת: Complete כותב את TPdfAsyncTaskState הסופי ומגדיר את אירוע הגמר לפני שכל תשובה נבחנת. TPdfAsyncExecutor.WaitForIdle מתקיים מאוחר יותר, ברגע שספירות התור והריצה שתיהן אפס, וריצה לא יורדת עד שהתשובה נחתה. כך שמשימה יכולה להיות patsSucceeded וניתנת לצפייה דרך Snapshot בעוד ה-executor עדיין עסוק בלגיטימיות

// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // release any pending padSynchronize reply
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

השלכה אחת ששווה להפנים: תשובה שמעלה חריגה לא כותבת מחדש היסטוריה. DispatchReply תופסת את החריגה ומאחסנת אותה ב-ReplyErrorMessage, משאירה את State, CancellationReason ו-ErrorMessage בדיוק כפי שה-worker קבע אותם. callback ממשק משתמש שמתפוצץ בזמן ציור תמונה ממוזערת לכן לעולם לא הופך רינדור מוצלח לכשלון, והטלמטריה שלכם ממשיכה לדווח מה שמנוע הרינדור באמת עשה. אם אתם רוצים API בצורת-callback סביב פעולה בודדת במקום מאגר, רינדור ברקע עם futures ניתנים-לביטול מכסה את הנתיב הזה

האם QueueCapacity חוסם גם workers רצים?

לא. QueueCapacity ב-PDFium Component סופר רק משימות בתור, לעולם לא אלה שכבר רצות על worker. זה מכוון: קיבולת נועדה לבטא backpressure אמיתי על תור ההמתנה, וקיפול חריצי המקביליות הקבועים לתוך אותו מספר היה סופר אותם פעמיים. עם ארבעה workers וקיבולת של שמונה יכולות להיות לכם שתים עשרה משימות בטיסה, ו-GetStats מדווח את הפיצול בכנות דרך QueuedCount ו-RunningCount

var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit never raises: it returns False when the waiting line is full or
  // the executor is already shutting down, and bumps RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
    // WorkerCount and is never charged against the capacity.
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

ארבעת המסלולים של TPdfAsyncPriority קשיחים, לא משוקללים. DequeueTask עובר מ-papCritical ומטה עד papLow ולוקח את המסלול הלא-ריק הראשון, שומר על סדר FIFO בתוך כל מסלול. זה נותן לבקשה אינטראקטיבית דרך נקייה לדלג קדימה לפני אצווה שעדיין לא התחילה, אך זה לעולם לא מפריע לעבודה שכבר רצה, וקורא שממשיך להזין papCritical יכול להרעיב את papLow ללא הגבלה. שמרו את שני המסלולים העליונים לדברים שבן אדם ממתין להם באופן גלוי, והשאירו ייצוא בכמות ב-papNormal או מתחת. השתמשו ב-Submit כשתור מלא הוא שגיאת תכנות ששווה EPdfAsyncQueueFull, וב-TrySubmit כשזה מצב רגיל שאתם מתכוונים לטפל בו

מדוע Shutdown מבטל מחוץ למנעול ה-executor?

כי ביטול בתוכו היה הופך את סדר המנעולים ותוקע את הכיבוי שאתם מנסים לבצע. Shutdown(True) לוקח את מנעול ה-executor, מהפך את דגל הכיבוי, ומצרף כל משימה ממתינה למערך snapshot מקומי דרך AppendSnapshot. אז הוא משחרר את המנעול ורק אחר כך עובר על ה-snapshot וקורא Cancel על כל רשומה. ביטול משימה יורה callbacks של משתמש רשומים על מקור הטוקן שלה, ואותם callbacks הם קוד אפליקציה רגיל: הם עשויים לשאול GetStats, להגיש עבודה מפצה, או להמתין ל-idle. כל אחד מהם נכנס מחדש למנעול ה-executor, ו-callback שנקרא בעוד המנעול ההוא מוחזק היה נתקע מול עצמו

מקור הטוקן מציית לאותה משמעת רמה אחת למטה. CancelWithReason לוקח את מנעול המקור, מחליט את המבטל המנצח היחיד, כותב Reason, CancellationMessage ו-CancelledAtTick, ורק אז מהפך את דגל הביטול באופן אטומי. פרסום-לפני-היפוך הוא מה שהופך את המטא-דאטה לבטוחה לקריאה: כל thread שצופה ב-IsCancelled כ-True מובטח למצוא סיבה שלמה מאחוריו, וקוראים מאוחרים יותר מפסידים במרוץ, מחזירים False, ולא יכולים לדרוס את הסיבה הראשונה. ה-callbacks הרשומים נלקחים כ-snapshot ומנוקים בתוך המנעול אך נקראים מחוצה לו, כל אחד עטוף כך שהנדלר כושל אחד לא יכול לדכא את השאר. משימות שכבר רצות לעולם לא נהרגות; הן מסתיימות באופן שיתופי כשגוף ה-worker שלהן קורא בפעם הבאה ל-ThrowIfCancelled, וזו הסיבה ש-Shutdown מסתיים עם WaitForIdle שואב לפני הצטרפות ל-threads

האם workers מקביליים מקלים את בעלות אובייקט PDFium?

הם לא, וזה הגבול שהכי סביר שיובן לא נכון. TPdfAsyncExecutor מתזמן עבודה; הוא לא טוען דבר על שיוך ה-thread של כל דבר שאתם נוגעים בו בתוך העבודה הזו. מופע TPdf חי לא הופך נגיש-במקביל כי שני workers קורים לקרוא אליו, ומנעול הרינדור הפנימי הוא שומר מול קריאות רינדור חופפות, לא רישיון לחלוק מסמך בין threads. רינדור או ייצוא מקביליים אומרים TPdf אחד לכל worker, נוצר ונהרס בתוך העבודה

type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // one document instance per worker, never shared
  Bmp: TBitmap;
begin
  LocalPdf := TPdf.Create(nil);
  try
    LocalPdf.FileName := FFileName;
    LocalPdf.Active := True;
    LocalPdf.PageNumber := FPageIndex;
    AToken.ThrowIfCancelled;
    Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
    try
      HandOffBitmap(FPageIndex, Bmp);   // ownership moves to the reply stage
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

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

הרחבת ממשק מפורסם בלי לשבור את ה-vtable

IPdfCancellationToken ו-IPdfCancellationTokenSource הם ממשקים בסגנון-COM שבינאריים חיצוניים אולי כבר צורכים, כך שהוספת שיטה לאחד מהם הייתה מזיזה כל חריץ מאוחר יותר ב-vtable ומנתבת בשקט לא נכון קריאות שקומפלו מול הפריסה הישנה. יכולות האבחון, ההמתנה, ה-callback הניתן-להסרה, וביטול-אטומי לכן חיות ב-IPdfCancellationTokenEx וב-IPdfCancellationTokenSourceEx, שיורשים במקום משנים. New ו-Run שומרים על הסמנטיקה המקורית שלהם עבור קוראים קיימים; קוד חדש פונה ל-NewEx, NewTimeout ו-RunEx כשהוא רוצה CancelWithReason, WaitForCancellation או IPdfCancellationRegistration מנוהל. ירושה היא הדרך הבטוחה היחידה להגדיל ממשק מפורסם, והיא עולה סוג נוסף אחד לדור

NewTimeout ראוי להערה כנה. כל מקור timeout מחזיק thread קליל שממתין על אירוע הביטול או הדדליין, מה שיגיע קודם. עבור כמה או כמה תריסרים דדליינים זה פשוט, בעל השהיה נמוכה, וזהה בין Delphi, Lazarus ו-C++Builder. עבור אלפי דדליינים קצרים זו הצורה הלא נכונה, ועדיף להניע ביטול מטיימר ברמת-אפליקציה אחד במקום להחזיק אלפי threads ממתינים

אף אחד מהכללים הללו אינו אקזוטי ברגע שכתוב, אך כל אחד מהם הוא תקרית ייצור כשהוא לא. ממתינים על אבן-הדרך הנכונה ונותנים למשהו לשאוב את תור ה-synchronize, קוראים את QueueCapacity כגבול על תור ההמתנה בלבד, מבטלים מחוץ למנעולים שלכם, ונותנים לכל worker מסמך משלו. שכבת האסינכרוניות המתוארת כאן מגיעה כחלק מ-PDFium Component ל-Delphi, לצד ה-API-ים לרינדור, טקסט וטופס שהיא מתזמנת