רינדור (Rendering) של עמוד ב-PDFium הוא פעולה סינכרונית. אתה קורא לספרייה, היא מבצעת רסטריזציה (rasterises) למפת סיביות (bitmap) שהעברת אליה, והשליטה חוזרת כאשר הפיקסלים כתובים. עבור עמוד בודד בגודל מסך ברמת זום אחת זה לוקח מספר אלפיות שנייה ואף אחד לא שם לב. עבור ייצוא ב-300 dpi של מסמך בן 200 עמודים, או רצועת תמונות ממוזערות שחייבת לרנדר את כל העמודים בבת אחת, אותה קריאה עולה בשניות. אם אתה מבצע את הקריאה הזו מהחוט הראשי (main thread), לולאת ההודעות נעצרת, החלון מפסיק להצטייר מחדש, ו-Windows מצייר את "לא מגיב" ("Not Responding") האימתני על שורת הכותרת שלך. העבודה נכונה. המקום שבו הרצת אותה הוא שגוי
התיקון הוא להעביר את הרינדור הארוך לחוט רקע ולהחזיר את התוצאה לחוט הראשי, שם מפת הסיביות יכולה להימסר לפקד (control). PDFium עצמה אינה מונעת ממך לעשות זאת, אך החיבור (binding) חייב להפוך את המסירה לבטוחה, משום ששטח הבאגים סביב "רוץ על פועל (worker), ענה ב-UI" הוא רחב והכשלים הם לסירוגין. היחידה (unit) FPdfAsync ב-PDFiumPas קיימת כדי לתת לתבנית זו מימוש נכון אחד, עם מודל ביטול שתואם לאופן שבו רינדור ארוך מתנהג בפועל
צורתה של העבודה
שלוש פעולות שולטות במקרים שבהם רינדור נמשך מעבר למסגרת (frame). רינדור אצווה (Batch rendering) סורק טווח עמודים ומרנדר כל עמוד, בדרך כלל לדיסק. ייצוא מרובה-עמודים עושה את אותו הדבר אך מרכיב את הפלט לקובץ אחד. רינדור עמודים ברקע הוא מה שצופה (viewer) עושה כאשר המשתמש קופץ לעמוד שאינו במטמון עדיין, כך שמפת הסיביות מיוצרת מחוץ-לחוט ומוצגת כשהיא מוכנה. כל השלושה חולקים את אותם אילוצים. הם רצים מספיק זמן כדי שחוט ה-UI לא יכול לארח אותם, הם מייצרים תוצאה שחוט ה-UI צריך בסופו של דבר, והמשתמש עשוי לנטוש אותם. סגירת המסמך, גלילה מעבר לעמוד, או לחיצה על ביטול (Cancel) צריכות לעצור את העבודה במקום להכריח את המשתמש להמתין לפלט שהוא אינו רוצה בו יותר
האילוץ האחרון הזה הוא זה שמעצב את התכנון. רינדור שלא ניתן לבטל אותו הוא רינדור שמחזיק את המסמך פתוח ושורף CPU לאחר שהתשובה הפסיקה להיות חשובה. לכן היחידה בנויה סביב שני פרימיטיבים (primitives) המשתלבים יחד: עתיד (future) שנושא את התוצאה חזרה, ואסימון (token) שנושא את בקשת הביטול קדימה
עתיד (future) מסוג שגר-ושכח
TPdfFuture<T>.Run מקבל פועל (worker), תגובה (reply), ואסימון ביטול אופציונלי. הוא מתחיל את הפועל בחוט רקע, וכאשר הפועל מסיים הוא מספק את התגובה בחוט הראשי. הפרמטר הגנרי T הוא כל מה שהרינדור מייצר, לעיתים קרובות ידית למפת סיביות או רשומת סטטוס. הפועל רץ מחוץ-לחוט הראשי; התגובה רצה במקום בו בטוח לגעת ב-VCL
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
ההשמטה המכוונת היא של כל סוג של Wait. אין שום מתודה לחסום (block) את הקורא עד שהעתיד ישלים את פעולתו, וזהו אינו חוסר תשומת לב. קריאה ל-Wait מהחוט הראשי היא הדרך הקלאסית לגרום לקיפאון (deadlock) של ממשק משתמש: הפועל צריך את החוט הראשי כדי להריץ את התגובה שלו דרך Synchronize, החוט הראשי חונה בתוך Wait, ואף צד אינו יכול להמשיך. על ידי סירוב להציע את הפרימיטיב, העתיד שולל את התבנית שלרוב מביסה אנשים שמנסים לכתוב זאת בעצמם. קוד שבאמת צריך להיחסם צריך להשתמש ב-TThread רגיל ולשאת בתוצאות. העתיד נועד למקרה של שגר-ושכח (fire-and-forget), שזה מה שרינדור רקע באמת
התוצאה עטופה ב-TPdfFutureResult<T>, רשומה שאומרת לתגובה איזה מתוך שלושה דברים קרה. IsSuccess אומר שהפועל חזר באופן תקין וה-Value מחזיק את הרינדור. IsCancelled אומר שהאסימון נורה והפועל יצא בנקודת ביטול. IsFailure אומר שהפועל זרק (raised) שגיאה, ו-ErrorMessage נושא את הטקסט. התגובה בודקת את הסטטוס פעם אחת ומתפצלת, במקום לנחש מערך שומר (sentinel value) האם מפת הסיביות המוחזרת היא אמיתית
המירוץ (race) ב-v1.61.0 ששינה את אספקת התגובות
החלק המאלף ביותר של יחידה זו הוא שינוי בן שורה אחת שלקח קצת זמן להבין. במהלך גרסאות מוקדמות פועל החוט סיפק את תגובתו בעזרת TThread.Queue. התור (Queue) מפרסם את התגובה לתור של החוט הראשי וחוזר מיד, מה שנקרא בדיוק כמו מה שעתיד של שגר-ושכח רוצה. זה היה שגוי, והסיבה ראויה לאיות מפורט משום שזהו סוג הבאג שעובר כל בדיקה שאתה חושב לכתוב
חוט הפועל נוצר עם FreeOnTerminate := True. משמעות הדבר היא שברגע ש-Execute חוזר, החוט מפרק את עצמו, ו-TThread.Destroy קורא ל-RemoveQueuedEvents(Self) כחלק מהניקוי. RemoveQueuedEvents מוחק כל מתודה בתור שהיעד שלה הוא החוט הגוסס. כך שהרצף היה: הפועל מסיים, הוא מכניס את התגובה לתור כנגד עצמו, Execute חוזר, החוט משמיד את עצמו, ו-RemoveQueuedEvents מוחק את התגובה שהחוט הראשי עדיין לא הריץ. התוצאה פשוט נעלמה. גרוע מכך, בחלון הצר שבו החוט הראשי שלף את התגובה שבתור והתחיל להריץ אותה בדיוק באותו הרגע שהחוט שוחרר, התגובה נגעה בשדות של אובייקט חצי-מושמד, מה שנקרא שימוש-לאחר-שחרור (use-after-free)
התיקון ב-v1.61.0 היה לספק את התגובה בעזרת Synchronize במקום Queue. Synchronize חוסם את חוט הפועל עד שהחוט הראשי הריץ את התגובה עד השלמתה. הפועל עדיין חי בזמן שהתגובה שלו מבוצעת, כך שאין שום דבר לשחרר מתחתיו, והחוט אינו חוזר מ-Execute (ולכן אינו מתחיל להשמיד את עצמו) עד שהתגובה סופקה. האספקה מובטחת, וחלון השימוש-לאחר-שחרור נסגר
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // already cancelled? skip the worker
FResult.Value := FWorker(FToken);
except
on E: EPdfOperationCancelled do
begin
FResult.Status := pfsCancelled;
FResult.ErrorMessage := E.Message;
end;
on E: Exception do
begin
FResult.Status := pfsFailure;
FResult.ErrorMessage := E.Message;
end;
end;
if Assigned(FReply) then
// Synchronize, not Queue: this thread is FreeOnTerminate, so a queued reply
// could be dropped by RemoveQueuedEvents before the main thread ran it.
Synchronize(DispatchReply);
end;
הלקח הכללי נשאר רלוונטי מעבר לתיקון הספציפי. קריאות חוזרות אסינכרוניות מסוג שגר-ושכח הן תבנית המקביליות שהכי קל לטעות בה באופן מעודן, משום שהנתיב השמח (happy path) עובד בניסיון הראשון והבאג חי באינטראקציה שבין סדר פירוק החוטים לבין התור. זה לא משתחזר לפי דרישה. הדבר תלוי בשאלה האם החוט הראשי במקרה רוקן את התור לפני שהפועל במקרה סיים להשמיד את עצמו, תזמון שהמתזמן (scheduler) מחליט אחרת בכל ריצה. פרימיטיב שנכון פעם אחת, בתוך החיבור, שווה הרבה יותר מאשר אותו קוד שנגזר מחדש בכל יישום שצריך רינדור ברקע
מדוע הקריאות החוזרות (callbacks) הן מצביעי מתודה (method pointers)
הפועל והתגובה אינם מתודות אנונימיות. הם טיפוסים של procedure of object, כגון TPdfFutureWorker<T> ו-TPdfFutureReply<T>, והבחירה הזו נכפית על ידי מטריצת המהדרים (compiler matrix). PDFiumPas מתקמפל ב-Delphi XE5 ומעלה וב-Free Pascal 3.2 במצב Delphi, ו-FPC 3.2 במצב זה אינו תומך במתודות אנונימיות. קריאה חוזרת מסוג הפניה-לפרוצדורה שלוכדת משתנים מקומיים תתקמפל ב-Delphi ותיכשל ב-FPC, כך שהיחידה משתמשת במכנה המשותף הנמוך ביותר ששני המהדרים מקבלים
ההשלכה המעשית היא היכן יושב המצב (state). מתודה אנונימית סוגרת סביב משתנים מקומיים (closes over locals); מצביע מתודה אינו עושה זאת. לכן כל מצב שהפועל צריך, אינדקס העמוד, הזום, נתיב הפלט, וכל מצב שהתגובה צריכה לעדכן, פקד התמונה של היעד או תווית ההתקדמות, חייבים להיות תלויים באובייקט שהמתודה שלו מועברת. בצופה (viewer) האובייקט הזה הוא לרוב הטופס או בקר רינדור שנמצא בבעלותו. זהו אינו מעקף שנכפה בחוסר רצון; זה שומר את הבעלות על אותו מצב מפורשת וגלויה על האובייקט המקבל במקום להיות מוסתרת בתוך סגור (closure)
ביטול בשיתוף פעולה (Cooperative cancellation), לא הריגה קשיחה
הביטול כאן הוא בשיתוף פעולה. אין API שמגיע אל תוך חוט הפועל ומסיים אותו, משום שסיום חוט באמצע רינדור משאיר את PDFium מחזיק במנעולים (locks) ובמפות סיביות כתובות בחלקן, ומצב התהליך לאחר הריגה מאולצת אינו משהו שאתה יכול לחשוב עליו בצורה הגיונית. במקום זאת, לפועל נמסר אסימון לקריאה-בלבד ומצופה ממנו לבדוק אותו, ולולאת הרינדור נכתבת כך שתבדוק אותו בין עמודים או בין אריחים (tiles), היכן שעצירה היא נקייה
האסימון מציע שלוש דרכים להבחין בביטול. IsCancelled הוא דגימה (poll) בוליאנית זולה עבור לולאה שרוצה לבדוק ולהחליט בעצמה. ThrowIfCancelled הוא המקרה הנפוץ: קרא לו בנקודת ביטול טבעית ו, אם התבקש ביטול, הוא זורק (raises) EPdfOperationCancelled, מה שפורש (unwinds) את הפועל היישר בחזרה אל העתיד. RegisterCallback מצרף התראה חד-פעמית (one-shot notification) שיורה פעם אחת כאשר המקור מבוטל, דבר שימושי כאשר פועל חסום במשהו שהוא יכול להפריע לו במקום לשבת בלולאה צפופה
החריג (exception) הוא המקום שבו גבול החוטים משמעותי. כאשר הפועל זורק EPdfOperationCancelled, העתיד תופס אותו והופך אותו לסטטוס מבוטל, כך שהתגובה רואה IsCancelled ולא כשל. אובייקט החריגה עצמו לעולם אינו מקודד (marshaled) לחוט הראשי. הוא חי ומת על חוט הפועל; רק מחרוזת ההודעה שלו מועתקת לתוך ErrorMessage. קידוד אובייקט חריגה חי על פני חוטים היה אומר להגיע אל תוך זיכרון שבבעלות חוט שמסיים את פעולתו, שזה בדיוק אותו סוג של טעות שתיקון ה-Synchronize קיים כדי למנוע. קוד סטטוס ומחרוזת חוצים את הגבול בצורה נקייה; אובייקט לא היה עושה זאת
שני ממשקים, כדי שפועל לא יוכל לבטל את עצמו
הביטול מפוצל בין שני ממשקים (interfaces) בכוונה. IPdfCancellationTokenSource הוא צד הכתיבה: יש לו Cancel, והבעלים שיוצר אותו, לרוב הטופס, שומר אותו וקורא ל-Cancel כאשר המשתמש לוחץ על הכפתור או כשהטופס נסגר. IPdfCancellationToken הוא צד הקריאה: יש לו IsCancelled, ThrowIfCancelled, ו-RegisterCallback, וזה כל מה שהפועל אי פעם מקבל. אובייקט קונקרטי אחד מממש את שניהם, אך לפועל נמסר אך ורק האסימון, כך שאין לו שום דרך לבטל את הפעולה שהוא מריץ. הפיצול הוא מעקה בטיחות ברמת ה-API. פועל שהיה יכול להגיע ל-Cancel דרך האסימון שלו היה מזמין קוד מבולבל לבטל את עצמו, ומערכת הטיפוסים מסירה את האפשרות הזו
ישנו פרט משלים עבור המקרה שבו קורא רוצה רינדור אך לעולם אינו מתכוון לבטלו. במקום להכריח מקור חדש לכל קריאה, היחידה חושפת את PdfNoCancellationToken, אסימון יחידון (singleton) שנמצא באופן קבוע במצב לא-מבוטל. Run מציב אותו במקום כשהארגומנט של האסימון נשאר nil. היחידון ההוא נבנה באופן להוט (eagerly) במהלך אתחול היחידה ולא באופן עצל (lazily) בשימוש הראשון, והסיבה היא שוב מקביליות. אם מספר קריאות Run על חוטי פועלים שונים היו כולן מגיעות ליחידון שנוצר באופן עצל בבת אחת, הן היו יכולות להתחרות על בנייתו, לדלוף עותק, או להבחין לזמן קצר במופע חצי-מאתחל. בנייתו לפני שכל פועל יכול לרוץ מסירה את המירוץ לחלוטין
הרצת רינדור שניתן לביטול
בפועל אתה יוצר מקור, שומר אותו על הטופס, מעביר את ה-Token שלו אל תוך Run לצד מתודת פועל ומתודת תגובה, ומחווט את כפתור הביטול למקור. הפועל בודק את האסימון בזמן שהוא מרנדר; התגובה מעדכנת את ה-UI ברגע שהתוצאה חוזרת. מכיוון שהקריאות החוזרות הן מצביעי מתודה, הפועל והתגובה קוראים כל מה שהם צריכים מהשדות של הטופס
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // field, lives on the form
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // worker observes this at its next cancel point
end;
// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // clean stop between pages
RenderOnePage(PageIndex); // synchronous PDFium rasterisation
end;
Result := True;
end;
// Runs on the main thread. Safe to touch the VCL here.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
if AResult.IsSuccess then
StatusLabel.Caption := 'Render complete'
else if AResult.IsCancelled then
StatusLabel.Caption := 'Cancelled'
else
StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;
התגובה מטפלת בכל שלוש התוצאות משום ששלושתן ניתנות להשגה. רינדור שהסתיים מדווח על הצלחה, משתמש שלחץ על 'ביטול' רואה את הענף המבוטל, וקובץ שלא ניתן היה לכתוב אותו או עמוד שנכשל בניתוח מגיע ככשל עם הודעה. אף אחד מהענפים הללו אינו חוסם (block), אף אחד מהם אינו נוגע בחוט הפועל, ומפת הסיביות או הסטטוס שהפועל ייצר נקראים רק לאחר שהעתיד סיפק אותם בחוט שבבעלותו ה-UI
אותה משמעת חוטים משתלמת במקומות אחרים בצופה. האופן שבו מפות סיביות מרונדרות נשמרות ונמצאות בשימוש חוזר על פני שינויי זום מכוסה בהערה שלנו על מטמון הרינדור וביצועי זום, והשאלה הרחבה יותר של שמירה על גבול ה-PDFium בטוח תחת Delphi נמצאת בהקשחת ה-PDFium VCL ABI לבטיחות זיכרון. תשתית ה-async המתוארת כאן מסופקת כחלק מ-PDFium Component עבור Delphi ו-C++Builder, לצד ממשקי ה-API לרינדור, טקסט וטפסים הנסקרים במקומות אחרים בבלוג זה