מאמר טכני

בטיחות תהליכונים ב-PDFium: נעילה לכל מסמך אינה מספיקה

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 בכל רגע, ללא קשר לכמה מסמכים פתוחים

דיאגרמת PDFium Component של שני תהליכונים שמריצים מופעי TPdf נפרדים על מסמכים שונים בזמן שכל קריאה מתכנסת אל מודול pdfium.dll טעון אחד שהמטמון של הגופנים, מודול העמודים ושאר ה-globals הפרוצס-רחבים שלו משותפים, ומפיקה כשלי טעינה, access violations ויציאות fail-fast
PDFium מחזיקה את המצב הלא בטוח שלה במודול, לא במסמך, ולכן שני מופעי TPdf בשני תהליכונים כותבים אל אותו מטמון גופנים לא משנה עד כמה הקבצים לא קשורים

איך נראית השחתה בין-מסמכים בתהליך 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 עבור הבא

דיאגרמת ValidatePdfFilesParallel של PDFium Component שמציגה כל worker מחזיק critical section אחד פרוצס-רחב לאורך יצירת TPdf, טעינה, preflight ושחרור, בזמן שהערכת הכללים של הדוח שנלכד רצה מחוץ לנעילה במקביל, ולכן החצי של 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 פותח את המסמך השמור במודול הפרטי שלו, מרנדר את עמודיו בהדרגה עם בדיקות ביטול בין צעדים, ואז משמיד את הספרייה, פורק את העותק ומוחק את הקובץ

דיאגרמת RenderPagesParallel של PDFium Component שבה תהליכון הקורא שומר תמונת מצב של המסמך, ואז כל worker מעתיק את ה-DLL של PDFium אל קובץ temp ייחודי, טוען אותה בתור מודול נפרד עם globals משלו, מרנדר את עמודיו עם בדיקות ביטול ופורק את העותק
המקביליות האמיתית מגיעה מבידוד מודולים: Windows מתייחסת לכל עותק DLL בתור מודול אחר, ולכן ה-workers לא חולקים דבר חוץ מתמונת המצב שתהליכון הקורא שמר תחת הנעילה

הבידוד אינו חינם, וברירות המחדל משקפות זאת. כל 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 := 1
  • TPdf.RenderPagesParallel מקבילית באמת כי כל worker טוען עותק מבודד של מודול ה-PDFium
  • התהליכונים, המשימות וה-futures שלכם זקוקים לנעילה אחת ברמת התהליך שמכסה כל TPdf מ-Create עד Free

PDFium Component עוטף את מנוע ה-PDFium עבור Delphi עם preflight ואימות באצווה, רינדור מקבילי מבודד, עבודת רקע שניתנת לביטול ואבחון טעינה מפורט. פרטים ומהדורות ב-דף המוצר של PDFium Component