מאמר טכני

רינדור מקבילי של דפי PDF בדלפי: בטיחות חוטים (Thread Safety)

רינדור דפי PDF במקביל מדלפי מתמצה בכלל אחד: תן לכל חוט עובד (worker thread) מרנדר משלו. losLab PDF Library חושפת את RenderPagesToFilesParallel בדיוק עבור עבודה זו, ומפזרת טווח דפים על פני בריכת (pool) TTask עם מופע TPDFlib אחד לכל עובד, כך שמכונה מרובת ליבות הופכת עבודת רסטר אצווה (batch raster job) לתפוקה המשתפרת כמעט בהתאמה למספר הליבות. שיתוף מופע יחיד בין חוטים שונים יגרום לכך שההרצה לא רק תאט, אלא תשחית זיכרון ותקרוס

זהו המאמר שתצטרך כאשר עבודת לילה נדרשת להפוך קובץ PDF בן 500 עמודים ל-500 קובצי PNG, למכונה יש 16 ליבות פנויות, והניסיון הראשון והכנה שלך להשתמש בחוטים נכשל בתוך GDI+. הגרסה הקצרה היא שבטיחות חוטים כאן היא תכונה מבנית, ולא דגל שאתה מגדיר, והמשך המאמר מפרט מדוע המבנה הבטוח נראה כפי שהוא נראה והיכן באמת נמצאת תקרת מהירות ההרצה האמיתית

האם TPDFlib בטוח לשימוש בחוטים (thread-safe) עבור רינדור מקבילי?

לא, והסיבה ראויה להבנה לפני שאתה מתכנן סביבה. מופע TPDFlib יחיד מוצהר לשימוש בחוט יחיד, והקצה החד הוא TPDFPageTree.GetPage: הוא כותב שדה FPagePointer משותף על המופע כתוצר לוואי של בחירת עמוד. שני חוטים הקוראים לאותו מופע מתחרים על השדה הזה, כך שעובד א' יכול להיות באמצע עמוד 3 כאשר עובד ב' מכוון מחדש את עץ העמודים לעמוד 40. שום דבר ב-API אינו מונע ממך לכתוב את הקוד שלהלן, והוא אף ירוץ במשך כמה עמודים לפני שייכשל, וזו הדרך הגרועה ביותר להתנהגות של באג כזה

// DO NOT do this: one shared instance, many threads
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // every thread reenters the same instance -> data race on FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

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

מדוע כל עובד רינדור זקוק למופע TPDFlib משלו?

מכיוון שהמופע הוא יחידת הבידוד. ברגע שכל עובד מחזיק ב-TPDFlib פרטי שטען את הקובץ באופן עצמאי, לכל אחד מהם יש עץ עמודים משלו, FPagePointer משלו, ומצב רינדור משלו, כך שאין שום דבר משותף להתחרות עליו. לבטיחות זו יש מחיר שכדאי להעריך מראש: כל עובד מנתח את המסמך כולו לתוך הזיכרון, כך שטביעת הרגל המרבית היא בערך פי N מעלות של מופע יחיד. שמונה עובדים על קובץ PDF בנפח 300 MB פירושם שמונה ניתוחים מלאים השוכנים בזיכרון בו-זמנית, ובקלטים גדולים מאוד זו המגבלה שקובעת את מספר העובדים שלך, ולא ה-CPU. כאשר המסמך ענק ואתה מוגבל בזיכרון ולא ב-CPU, נתיב הגישה הישירה ב-עיבוד קובצי PDF גדולים ללא ניתוח מסמך מלא הוא לרוב כלי יעיל יותר מאשר הוספת חוטים

ממשק ה-API בקריאה אחת: RenderPagesToFilesParallel

losLab PDF Library אורזת את כל הדפוס הבטוח מאחורי שיטה אחת, כך שבמקרה הנפוץ אינך צריך לכתוב דבר בעצמך. RenderPagesToFilesParallel מקבלת את שם הקובץ והסיסמה, DPI, עמוד התחלה ועמוד סיום (כולל), ערך Options המועבר ישירות לנתיב הרסטר לכל עמוד, תבנית פלט שבה %p מוחלף במספר העמוד, ותקרה למספר העובדים כאשר כל ערך שווה או קטן מאפס פירושו אוטומטי. היא מחזירה את מספר העמודים שרונדרו בהצלחה, וזהו נתיב המיועד לווינדוס בלבד מכיוון שהוא נשען על CoInitialize ו-GDI+

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // MaxWorkers = 0 -> auto: min(page count, CPU cores)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

מדוע CoInitialize בכל חוט עובד?

GDI+ הוא המרנדר שמתחת לרינדור העמודים, ו-GDI+ עובד במצב חוטים של דירה (apartment-threaded): הוא מצפה ש-COM יאותחל בכל חוט שקורא אליו. לחוט הראשי של אפליקציית VCL יש בדרך כלל את ההגדרה הזו כבר, אך לעובד TTask שזה עתה נוצר אין, וקריאה לנתיב הרינדור מחוט לא מאותחל היא דרך בטוחה לקריסה. לכן כל עובד מצמד CoInitialize(nil) בכניסה com עם CoUninitialize ביציאה, המקיפים את כל זמן חייו. זוהי אותה משמעת שכל עבודת GDI+ או COM זקוקה לה מחוץ לחוט הראשי, וזהו החצי השני של מה שגורם לבידוד של כל עובד להחזיק מעמד בפועל, כאשר החצי הראשון הוא המופע הפרטי. אותו נתיב רסטר של GDI+ מניע את המנועים בעלי חוט יחיד המכוסים ב-בחירת מנוע רינדור עבור פלט PDF

חלוקת עבודה סטטית לעומת תביעת דפים דינמית

הדרך הברורה לחלק 500 עמודים בין 8 עובדים היא להגיש לכל אחד מהם חלק קבוע של כ-62 עמודים. losLab PDF Library אינה עושה זאת, והסיבה היא איזון עומסים. עלות הרינדור של עמוד משתנה באופן קיצוני: עמוד של טקסט גוף מרונדר במילישניות, ועמוד של מפות וקטוריות צפופות או תמונה סרוקה מלאה יכול לקחת זמן ארוך פי חמישים. חלוקת העבודה לחלקים קבועים תגרום לכך שהעובד שמצייר את החלק הכבד ירוץ זמן רב לאחר שהאחרים יתבטלו, כך שזמן הריצה שלך ייקבע על ידי החלק הגרוע ביותר, ולא לפי הממוצע. במקום זאת, כל עובד תובע את העמודים הבאים מתוך מונה משותף תחת אזור קריטי קצר (critical section), מרנדר אותו, וחוזר לקבל אחד נוסף, מה ששומר על כל ליבה עסוקה עד שכל הטווח מתרוקן

// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ is apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // one private instance per worker
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // claim the next page atomically
          try
            PageNum := NextPage;
            Inc(NextPage);
          finally
            IdxLock.Leave;
          end;
          if PageNum > EndPage then Break;
          LocalLib.RenderPageToFile(DPI, PageNum, 0,
            Format('page_%d.png', [PageNum]));
        end;
      finally
        LocalLib.Free;
      end;
    finally
      CoUninitialize;
    end;
  end;

רישום יומן (logging) מובנה בין חוטים עובדים

ניפוי באגים של אצווה שמתה בעמוד 213 מתוך 500 הוא מייאש ללא יומן, ויומן פשוט הוא באג מקביליות בפני עצמו. losLab PDF Library מספקת את TPDFlibLogger, המחובר דרך המאפיין TPDFlib.Logger והוא nil כברירת מחדל כדי שנתיב ללא יומן יישאר ללא עלות. הוא עובד בשיטת callback תחילה: אתה מגדיר את OnLog ומנתב רשומות לאן שהמארח רוצה, מסונן לפי רמות llDebug / llInfo / llWarn / llError, ו-PDFlibErrorMessage הופך את הקודים המספריים הגולמיים לטקסט אנושי כדי שרשומת Error תקרא כיותר ממספר פשוט. קובץ היעד האופציונלי הוא המשאב המשותף היחיד, והוא מוגן על ידי TCriticalSection בדיוק כדי שמספר עובדים יוכלו להוסיף לקובץ יומן אחד בבטחה. שים לב לגבול הכנה: רק קובץ יעד זה מסונכרן, כך שאם אתה משתף יומן יחיד על פני בריכה שנבנתה ידנית וה-OnLog שלך נוגע בממשק המשתמש, עדיין עליך להעביר זאת חזרה לחוט הראשי בעצמך

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // optional shared sink (lock-guarded)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // marshal to the UI thread yourself; OnLog fires on worker threads
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil by default; zero-cost when unset
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

לכמה שיפור במהירות עליך לצפות בפועל?

היה כנה עם עצמך לגבי לאן הולך הזמן, מכיוון שרינדור מקבילי משתלם רק כאשר העבודה מוגבלת באמת על ידי ה-CPU. פלט DPI גבוה ועמודים וקטוריים מורכבים או מוצללים הם כבדי חישוב, ואלה משתפרים כמעט ליניארית עם מספר הליבות עד שאתה מגיע לרוויית CPU. עמודים פשוטים הם סיפור אחר: שם העלות הנוספת של LoadFromFile לכל עובד, בתוספת עלות הדיסק של כתיבת קובצי הפלט, יכולות להעפיל על הרינדור עצמו, ושמונה עובדים המכבידים על דיסק איטי אחד יכולים לסיים לאט יותר מאשר לולאה טורית נקייה. הגדר את MaxWorkers למספר הליבות הפיזיות שלך במקום למשהו שאיפתני, שים לב לזיכרון כאשר קובץ ה-PDF המקור גדול, ואם מתברר שהאצווה מוגבלת על ידי IO, התיקון הוא אחסון מהיר יותר או פחות עובדים, ולא עוד חוטים. בשימוש בעבודות שעבורן הוא נבנה, נתיב רינדור האצווה המוצג כאן הוא חלק מ-losLab PDF Library הסטנדרטית עבור דלפי ו-C++Builder, והוא הופך ליבות לא פעילות לעמודים גמורים ללא אף מלכודות בטיחות החוטים שהיית צריך לפתור בעצמך אחרת