רינדור דפי 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, והוא הופך ליבות לא פעילות לעמודים גמורים ללא אף מלכודות בטיחות החוטים שהיית צריך לפתור בעצמך אחרת