PDFium אינה thread-safe ברמת המודול, ולכן שני מופעי TPdf שעובדים על שני קבצים שונים בשני תהליכונים עדיין יכולים להשחית זה את זה. PDFium Component ל-Delphi מטפל בזה בשתי דרכים: מאז v3.125.1, ValidatePdfFilesParallel מסדרת בסדרה כל קריאת PDFium ילידית מאחורי נעילה אחת ברמת התהליך, בזמן ש-TPdf.RenderPagesParallel נותנת לכל worker עותק מבודד משלו של מודול ה-PDFium. הבאג שכפה את התיקון היה מהסוג הגרוע ביותר של אינטרמיטנטי. בדיקת אימות אצווה עברה רוב הזמן, ואז דיווחה על אחד משני קבצים טובים בתור נכשל, ואז הפילה את הבדיקה הבאה באותו תהליך עם access violation, ולפעמים לקחה את ה-runner כולו למטה עם קוד יציאה במקום stack trace. לא היה שום דבר רע בבדיקה, ולא היה שום דבר רע באף מסמך בודד. ההנחה הייתה שגויה: TPdf אחד לכל תהליכון אינו בידוד
מדוע TPdf אחד לכל תהליכון אינו מספיק?
TPdf אחד לכל תהליכון אינו מספיק כי PDFium מחזיקה את המצב הלא בטוח שלה במודול, לא במסמך. כל TPdf בבעלותו מצביע FPDF_DOCUMENT משלו, אבל כל מצביע בתהליך משורת על ידי אותה DLL טעונה, ואותה DLL מחזיקה singletons ברמת התהליך: מטמון הגופנים, מודול העמודים, ומבנים גלובליים אחרים שטעינת מסמכים, פרסור ורינדור כולם נוגעים בהם. שני תהליכונים שטוענים שני קבצים לא קשורים הם שני תהליכונים שכותבים אל אותו מטמון גופנים באותו רגע. אף אחד לא בבעלותו הנתונים האלה בצד Delphi, ולכן שום דבר בצד Delphi לא יכול לנעול אותם לכל מסמך
לרכיב יש נעילה, וקל להסיק ממנה מסקנה שגויה. TPdf עוטף את נתיבי הרינדור של עצמו ב-critical section פנימי (EnterRenderLock / LeaveRenderLock, מתודות פרטיות של TPdf). הנעילה הזאת היא לכל מופע. היא עוצרת שני תהליכונים מלהניע את אותו TPdf בבת אחת, מה שהוא סיכון אמיתי, אבל היא לא יכולה לראות מופע שני בתהליכון אחר, ולכן מקביליות בין-מופעים חולפת על פניה ישרות. הכלל הכללי פשוט מספיק כדי לנסח אותו בשורה: במודול PDFium טעון אחד, לכל היותר תהליכון אחד רשאי להיות בתוך PDFium בכל רגע, ללא קשר לכמה מסמכים פתוחים
איך נראית השחתה בין-מסמכים בתהליך Delphi?
השחתה בין-מסמכים נראית כמו תערובת אקראית של כשלים לא קשורים, והנזק שורד את הקוד שגרם לו. לפני v3.125.1, ValidatePdfFilesParallel יצרה TPdf אחד לכל תהליכון worker והריצה Active := True בתוספת בניית דוח ה-preflight במקביל על המודול המשותף. הסימפטומים שנראו ב-builds של Delphi ושל Free Pascal כיסו את כל הטווח:
- קובץ תקף נכשל בטעינה, או חוזר מהאצווה בתור נכשל כשהיה צריך לעבור
- access violation עולה בקריאה מאוחרת ולא קשורה, לעיתים קרובות בבדיקה אחרת או במסמך אחר
External exception C000001Dמופיע ב-Delphi. הקוד הזה הואSTATUS_ILLEGAL_INSTRUCTION, שמועלה על ידי ההוראהud2שה-macros של ה-CHECKוה-IMMEDIATE_CRASHהפנימיים של PDFium מריצים כשאינווריאנטה נשברת- התהליך יוצא עם
0xC0000409(fail-fast, מדווח בתור גלישת חוצץ מחסנית) או עם0xC0000374(השחתת heap), בלי חריגת Delphi כלל
שתי הנקודות האחרונות הן הסיבה שהבאג היה כה קשה לציוד. אימות המקביל הסתיים, המצב הגלובלי המושחת נשאר מאחור, וה-fixture הבא באותו תהליך נתקל בו. בהרצת רגרסיה אחת של Delphi Win64, גל של כשלי C000001D פגע בבדיקות שמעולם לא נגעו באימות אצווה; הן פשוט היו הקוד הראשון שהשתמש ב-PDFium אחרי הנזק. המספרים שנמדדו הופכים את סדר הגודל לברור. probe של Delphi שהריץ את אותה דגימה דרך שני workers נכשל ב-122 מתוך 160 מסמכים בהרצה אחת וב-138 מתוך 160 באחרת, ואחת מההרצות האלה העלתה External exception C000001D ממש. מקרה stress של 8 מסמכים, 4 workers ו-5 סבבים נכשל או קרס ב-5 מתוך 5 הרצות על Free Pascal Win64. אחרי התיקון, אותו probe נכשל ב-0 מתוך 1,200 מסמכים
איך ValidatePdfFilesParallel נשארת בטוחה מאז v3.125.1
ValidatePdfFilesParallel מסדרת עכשיו בסדרה את החצי הילידי של כל משימה ומשאירה את החצי המנוהל מקבילי. כל worker לוקח critical section אחד ברמת היחידה לפני שהוא יוצר את ה-TPdf שלו, ומחזיק אותו לאורך FileName, Active := True, בניית דוח ה-preflight, ו-Free. יצירה והרס נמצאים בתוך הנעילה בכוונה: סגירת מסמך קוראת בחזרה אל המודול בדיוק כמו טעינה. ברגע שה-worker מחזיק רשומת TPdfPreflightReport שנלכדה, הוא משחרר את הנעילה ומעריך את כללי האימות מול אותה רשומה, מה שלא נוגע באף מצב PDFium, ולכן הערכת הכללים עבור קובץ אחד חופפת את עבודת ה-PDFium עבור הבא
שני שינויים קטנים יותר הגיעו עם התיקון. כשל טעינה מעלה עכשיו EPdfError עם LastLoadReport.ErrorMessage, ולכן ה-ErrorMessage של הפריט קורא בשם את בעיית הפרסור האמיתית במקום שגיאה משנית של "אין מסמך פעיל". והעלות מנוסחת בכנות: החלק של PDFium באצווה עכשיו סידורי, ולכן באצווה שנשלטת על ידי פרסור ו-preflight, workers נוספים קונים מעט. אם אתם על גרסה לפני v3.125.1, הגדירו WorkerCount ל-1; זה מסיר את המקביליות ואיתה את ההשחתה
uses
System.SysUtils, PDFium, FPdfPreflightReport;
procedure ValidateBatch(const Files: array of string);
var
Registry: TPdfValidationRuleRegistry;
Options: TPdfBatchValidationOptions;
Report: TPdfBatchValidationReport;
I: Integer;
begin
Registry := CreateDefaultPdfValidationRuleRegistry;
try
Options := TPdfBatchValidationOptions.Default;
Options.WorkerCount := 4; // 0 = מספר המעבדים, חסום ב-8
Options.Standards := [ppsPdfA];
// עם registry מפורש, בוחרים בעצמכם את הפרופיל התואם.
// רשימת Profiles ריקה מריצה כל כלל רשום, וכללים עבור
// standards שלא בדקתם מראש מדווחים "לא עברו"
SetLength(Options.ValidationOptions.Profiles, 1);
Options.ValidationOptions.Profiles[0] := 'PDF/A';
Report := ValidatePdfFilesParallel(Files, Registry, Options);
finally
Registry.Free;
end;
for I := 0 to High(Report.Results) do
case Report.Results[I].Status of
pbvisPass: Writeln('PASS ', Report.Results[I].FileName);
pbvisFail: Writeln('FAIL ', Report.Results[I].FileName);
pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
Report.Results[I].ErrorMessage);
else
Writeln('SKIP ', Report.Results[I].FileName); // pbvisCancelled
end;
Writeln(Report.PassedDocumentCount, ' passed, ',
Report.FailedDocumentCount, ' failed, ',
Report.ErrorDocumentCount, ' errors');
end;
העברת nil בתור ה-registry היא הדרך הקצרה: ValidatePdfFilesParallel אז יוצרת את ה-registry המוגדר כברירת מחדל בעצמה, גוזרת את רשימת הפרופילים מ-Options.Standards, ומשחררת את ה-registry כשהיא חוזרת. התוצאות תמיד חוזרות בסדר הקלט, בכל סדר שבו ה-workers הסתיימו. עבור פורמטי הדוח ועוטף שורת הפקודה סביב אותו מנוע, ראו את דוחות preflight של PDF באצווה עם ה-CLI של PDFium Component, ועבור מה שבדיקות ה-PDF/A עצמן מכסות, אימות preflight של PDF/A ב-Delphi
איך RenderPagesParallel מריצה עמודים במקביל באמת?
TPdf.RenderPagesParallel רצה במקביל כי ה-workers שלה לעולם לא חולקים מודול PDFium. המתודה קודם שומרת את המסמך הפעיל אל מאגר מקורות בתהליכון הקורא. כל worker אז מעתיק את ה-DLL של PDFium הטעונה אל קובץ בשם ייחודי בתיקיית ה-temp, טוען את העותק הזה עם LoadLibrary, ומאתחל אותו. Windows מתייחסת ל-DLL הטעונה מנתיב אחר בתור מודול אחר, ולכן כל עותק מקבל globals משלו: מטמון גופנים משלו, מודול עמודים משלו, הכול משלו. ה-worker פותח את המסמך השמור במודול הפרטי שלו, מרנדר את עמודיו בהדרגה עם בדיקות ביטול בין צעדים, ואז משמיד את הספרייה, פורק את העותק ומוחק את הקובץ
הבידוד אינו חינם, וברירות המחדל משקפות זאת. כל worker משלם עותק DLL על הדיסק, קבוצה שנייה של globals של PDFium בזיכרון, ופרסור טרי של המסמך. MaxWorkers = 0 פירושו לכל היותר 4 workers, MaxPixelsPerPage ו-MaxTotalOutputBytes חוסמים את הפלט הגולמי, ואפשרויות הרינדור ההפוך וה-duotone הלילי נדחות כי החוצצים מוחזרים גולמיים. התוצאה היא TPdfParallelRenderReport שהמערך Results שלו מחזיק חוצץ אחד מלמעלה-למטה בן 32 ביט לכל עמוד מבוקש, בסדר הבקשה
procedure RenderAllPages(Pdf: TPdf);
var
Options: TPdfParallelRenderOptions;
Report: TPdfParallelRenderReport;
Pages: array of Integer;
I: Integer;
begin
SetLength(Pages, Pdf.PageCount);
for I := 0 to High(Pages) do
Pages[I] := I + 1; // מספרי עמודים מתחילים מ-1
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// תמונת המצב המקורית נלקחת על המודול המשותף, ולכן מחזיקים את
// הנעילה הפרוצס-רחבה של PDFium אם גם תהליכונים אחרים משתמשים ב-TPdf
PdfiumLock.Acquire;
try
Report := Pdf.RenderPagesParallel(Pages, Options);
finally
PdfiumLock.Release;
end;
for I := 0 to High(Report.Results) do
if Report.Results[I].Status = pprsSucceeded then
SavePageBuffer(Report.Results[I]) // Width, Height, Stride, PixelFormat, Pixels
else
Writeln('Page ', Report.Results[I].PageNumber, ': ',
Report.Results[I].ErrorMessage);
end;
שימו לב לנעילה סביב הקריאה. מודולי ה-workers פרטיים, אבל שלב תמונת המצב בהתחלה מריץ SaveAs על המודול המשותף מתהליכון הקורא. אם שום דבר אחר בתהליך שלכם לא נוגע ב-TPdf במקביל, אפשר לוותר על הנעילה; אם משהו כן נוגע, תמונת המצב זקוקה לאותה הגנה כמו כל קריאה אחרת של המודול המשותף
| תבנית | בטוח בין מסמכים | עבודת PDFium רצה במקביל | עלות |
|---|---|---|---|
TPdf אחד לכל תהליכון, בלי נעילה משותפת | לא | כן, עד שזה משחית | קריסות אינטרמיטנטיות, מצב תהליך פגום |
| נעילה אחת ברמת התהליך סביב כל קריאות ה-PDFium | כן | לא | החלק של PDFium סידורי |
ValidatePdfFilesParallel מאז v3.125.1 | כן | לא; הערכת הכללים מקבילית | פרסור ו-preflight סידוריים |
TPdf.RenderPagesParallel | כן | כן | עותק DLL, זיכרון ופרסור טרי לכל worker |
איך בונים קוד PDFium מרובה תהליכונים משלכם?
התהליכונים שלכם צריכים לחלוק נעילה אחת ברמת התהליך ולהחזיק אותה לכל חיי כל TPdf שהם משתמשים בו, או אחרת להשתמש ב-API של רכיב שמבודד את המודול בשבילכם. הנעילה חייבת להיות אובייקט אחד לכל התהליך, לא אחד לכל תהליכון, טופס או מסמך; נעילה ששני תהליכונים לא חולקים לא מגינה על דבר. התבנית להלן משקפת את מה שהרכיב עושה פנימית מאז v3.125.1: יוצרים, טוענים, קוראים ומשחררים בתוך הנעילה, ואז עושים הכול שאינו נוגע ב-PDFium מחוצה לה
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // נעילה אחת לכל התהליך
type
TTextExtractThread = class(TThread)
private
FFileName: string;
FText: string;
protected
procedure Execute; override;
public
constructor Create(const AFileName: string);
property ExtractedText: string read FText;
end;
constructor TTextExtractThread.Create(const AFileName: string);
begin
inherited Create(True);
FFileName := AFileName;
end;
procedure TTextExtractThread.Execute;
var
Pdf: TPdf;
Page: Integer;
Raw: TStringBuilder;
begin
Raw := TStringBuilder.Create;
try
PdfiumLock.Acquire;
try
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FFileName;
Pdf.Active := True;
if not Pdf.Active then
raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
for Page := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := Page;
Raw.AppendLine(Pdf.Text);
end;
finally
Pdf.Free; // סגירת המסמך היא גם עבודת PDFium
end;
finally
PdfiumLock.Release;
end;
// אין PDFium מתחת לשורה הזאת, ולכן החלק הזה רץ במקביל
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
כמה כללים שומרים על התבנית כנה באפליקציה אמיתית:
- שימו את
TPdf.CreateואתFreeבתוך הנעילה, לא רק את הקריאות הברורות. טעינה, סגירה, קריאות תכונה כמוPageCount, מעברי עמודים, חילוץ טקסט, רינדור ושמירה כולם מגיעים אל המודול - בדקו את
Activeאחרי שהשייכתם אותו. טעינה שנכשלה משאירה אתActiveב-False, ו-LastLoadReport.ErrorMessageאומר למה - החזיקו את הנעילה לכל מסמך ולא לכל קריאה. נעילה עדינה יותר אפשרית בעקרון, אבל רק אם אף חבר של
TPdfלעולם לא רץ מחוצה לה, והגרסה הגסה היא זו שהרכיב עצמו נשען עליה - השאירו עבודה איטית שאינה PDFium, כמו כתיבות מסד נתונים, אינדוקס וקריאות רשת, מחוץ לנעילה, או שצרכן איטי אחד יסדר בסדרה את הכול
- אל תתייחסו לנעילת הרינדור הפרטית לכל מופע בתור תחליף. היא מגינה על
TPdfאחד מפני עצמו ולא על דבר נוסף
אותה זהירות חלה על קוד שלא כתבתם בתור תהליכונים גולמיים. futures ברקע הן דרך טובה להרחיק רינדורים ארוכים מתהליכון ה-UI, כמתואר ב-רינדור PDF ברקע עם futures הניתנים לביטול, אבל מריץ ה-futures לא מוסיף נעילת PDFium גלובלית משלו. אם כמה futures יכולים להניע מופעי TPdf שונים באותו זמן, קחו את אותה נעילה ברמת התהליך בתוך כל worker, והתייחסו לצופה בתהליכון הראשי בתור עוד לקוח אחד של המודול המשותף. שימוש בין-מופעים דרך ה-APIs האסינכרוניים לא עבר ביקורת נפרדת, ולכן ההנחה השמרנית היא שהוא זקוק לאותו סידור כמו תהליכונים בכתב יד. כשצריך מקביליות PDFium אמיתית למשהו מעבר לרינדור עמודים, תהליכי workers נפרדים נותנים לכל משימה מודול משלה מעצם הבנייה
עזר זריז: כללי תהליכונים של PDFium עבור Delphi
- המצב הלא בטוח של PDFium חל על המודול כולו: מטמון הגופנים, מודול העמודים וglobals אחרים משותפים לכל מסמך בתהליך
TPdfאחד לכל תהליכון לא מבודד דבר; שני מופעים בשני תהליכונים עדיין יכולים להשחית זה את זה- הסימפטומים האופייניים הם כשלי טעינה, access violations בקוד מאוחר,
External exception C000001D, ויציאות עם0xC0000409או0xC0000374 - ההשחתה נשארת בתהליך, ולכן הקריאה שנכשלת היא לעיתים קרובות לא זו שגרמה לה
ValidatePdfFilesParallelבטוחה מאז v3.125.1; בגרסאות ישנות יותר משתמשים ב-WorkerCount := 1TPdf.RenderPagesParallelמקבילית באמת כי כל worker טוען עותק מבודד של מודול ה-PDFium- התהליכונים, המשימות וה-futures שלכם זקוקים לנעילה אחת ברמת התהליך שמכסה כל
TPdfמ-CreateעדFree
PDFium Component עוטף את מנוע ה-PDFium עבור Delphi עם preflight ואימות באצווה, רינדור מקבילי מבודד, עבודת רקע שניתנת לביטול ואבחון טעינה מפורט. פרטים ומהדורות ב-דף המוצר של PDFium Component