Τεχνικό Άρθρο

Thread safety PDFium: γιατί αποτυγχάνουν per-document locks

Το PDFium δεν είναι thread-safe σε επίπεδο module, οπότε δύο instances TPdf που δουλεύουν σε δύο διαφορετικά αρχεία σε δύο threads μπορούν ακόμα να αλληλοκαταστρέφονται. Το PDFium Component για Delphi το χειρίζεται με δύο τρόπους: από το v3.125.1, το ValidatePdfFilesParallel σειριοποιεί κάθε native κλήση PDFium πίσω από ένα lock όλης της διεργασίας, ενώ το TPdf.RenderPagesParallel δίνει σε κάθε worker δικό του απομονωμένο αντίγραφο του module PDFium. Το bug που επέβαλε τη διόρθωση ήταν το χειρότερο είδος διαλείπουσας αστοχίας. Ένα test batch validation πέρναγε τις περισσότερες φορές, μετά ανέφερε ένα από δύο σωστά αρχεία ως αποτυχημένο, μετά κατέρριψε το επόμενο test στην ίδια διεργασία με access violation, και καμιά φορά έσερνε κάτω όλο τον runner με exit code αντί για stack trace. Τίποτα δεν πήγαινε στραβά στο test, και τίποτα δεν πήγαινε στραβά σε οποιοδήποτε μεμονωμένο έγγραφο. Η υπόθεση ήταν λάθος: ένα TPdf ανά thread δεν είναι απομόνωση

Γιατί ένα TPdf ανά thread δεν φτάνει;

Ένα TPdf ανά thread δεν φτάνει επειδή το PDFium κρατά την ανασφαλή του κατάσταση στο module, όχι στο έγγραφο. Κάθε TPdf κατέχει δικό του handle FPDF_DOCUMENT, αλλά κάθε handle στη διεργασία εξυπηρετείται από την ίδια φορτωμένη DLL, και εκείνη η DLL κρατά process-wide singletons: το font cache, το page module, και άλλες καθολικές δομές που αγγίζουν όλα η φόρτωση, το parsing και η απόδοση εγγράφων. Δύο threads που φορτώνουν δύο άσχετα αρχεία είναι δύο threads που γράφουν στο ίδιο font cache ταυτόχρονα. Κανείς δεν κατέχει εκείνα τα δεδομένα από την πλευρά Delphi, οπότε τίποτα από την πλευρά Delphi δεν μπορεί να τα κλειδώσει ανά έγγραφο

Το component έχει όντως ένα lock, και είναι εύκολο να βγάλεις λάθος συμπέρασμα από αυτό. Το TPdf τυλίγει τις δικές του render διαδρομές σε εσωτερικό critical section (EnterRenderLock / LeaveRenderLock, private μέθοδοι του TPdf). Εκείνο το lock είναι ανά instance. Σταματά δύο threads να οδηγούν το ίδιο TPdf μαζί, που είναι πραγματικός κίνδυνος, αλλά δεν βλέπει δεύτερο instance σε άλλο thread, οπότε cross-instance συγχρονία περνά κατευθείαν δίπλα του. Ο γενικός κανόνας είναι αρκετά απλός να ειπωθεί σε μία γραμμή: σε ένα φορτωμένο module PDFium, το πολύ ένα thread μπορεί να είναι μέσα στο PDFium οποιαδήποτε στιγμή, ανεξάρτητα από το πόσα έγγραφα είναι ανοιχτά

Διάγραμμα PDFium Component δύο threads που τρέχουν ξεχωριστά instances TPdf πάνω σε διαφορετικά έγγραφα ενώ κάθε κλήση συγκλίνει σε ένα φορτωμένο module pdfium.dll του οποίου το font cache, το page module και άλλα process-wide globals είναι μοιρασμένα, παράγοντας αστοχίες φόρτωσης, access violations και fail-fast εξόδους
Το PDFium κρατά την ανασφαλή του κατάσταση στο module, όχι στο έγγραφο, οπότε δύο instances TPdf σε δύο threads γράφουν στο ίδιο font cache όσο άσχετα κι είναι τα αρχεία

Πώς φαίνεται cross-document καταστροφή σε διεργασία Delphi;

Η cross-document καταστροφή μοιάζει με τυχαίο μίγμα άσχετων αστοχιών, και η ζημιά επιβιώνει του κώδικα που την προκάλεσε. Πριν το v3.125.1, το ValidatePdfFilesParallel δημιουργούσε ένα TPdf ανά worker thread και έτρεχε Active := True συν το χτίσιμο preflight report ταυτόχρονα πάνω στο μοιρασμένο module. Τα συμπτώματα που είδαμε και σε Delphi και σε Free Pascal builds κάλυψαν όλο το φάσμα:

  • Έγκυρο αρχείο αποτυγχάνει να φορτώσει, ή γυρίζει από το batch ως αποτυχημένο όταν έπρεπε να περάσει
  • Access violation βγαίνει στην επιφάνεια σε μεταγενέστερη, άσχετη κλήση, συχνά σε διαφορετικό test ή διαφορετικό έγγραφο
  • Το External exception C000001D εμφανίζεται στο Delphi. Εκείνος ο κώδικας είναι STATUS_ILLEGAL_INSTRUCTION, που σηκώνεται από την οδηγία ud2 που εκτελούν οι μακροεντολές CHECK και IMMEDIATE_CRASH του PDFium όταν σπάει invariant
  • Η διεργασία εξέρχεται με 0xC0000409 (fail-fast, αναφέρεται ως stack buffer overrun) ή 0xC0000374 (heap corruption), χωρίς καθόλου Delphi exception

Τα δύο τελευταία σημεία είναι γιατί το bug ήταν τόσο δύσκολο να καρφωθεί. Το παράλληλο validation τελείωσε, η κατεστραμμένη καθολική κατάσταση έμεινε πίσω, και το επόμενο fixture στην ίδια διεργασία σκόνταψε πάνω της. Σε ένα τρέξιμο regression Delphi Win64, ένα κύμα αστοχιών C000001D χτύπησε tests που δεν άγγιξαν ποτέ batch validation· ήταν απλώς ο πρώτος κώδικας που χρησιμοποίησε το PDFium μετά τη ζημιά. Οι μετρημένοι αριθμοί κάνουν την κλίμακα φανερή. Ένας Delphi probe που έτρεξε το ίδιο δείγμα μέσω δύο workers απέτυχε 122 από 160 έγγραφα σε ένα τρέξιμο και 138 από 160 σε άλλο, και ένα από εκείνα τα τρεξίματα σηκωσε ευθέως External exception C000001D. Ένα stress case 8 εγγράφων, 4 workers και 5 γύρων απέτυχε ή κατέρρευσε σε 5 από 5 τρεξίματα σε Free Pascal Win64. Μετά τη διόρθωση, ο ίδιος probe απέτυχε 0 από 1.200 έγγραφα

Πώς μένει ασφαλές το ValidatePdfFilesParallel από το v3.125.1

Το ValidatePdfFilesParallel τώρα σειριοποιεί το native μισό κάθε job και κρατά το managed μισό παράλληλο. Κάθε worker παίρνει ένα critical section επιπέδου μονάδας πριν δημιουργήσει το TPdf του, και το κρατά διά μέσου FileName, Active := True, χτίσιμο preflight report, και Free. Δημιουργία και καταστροφή είναι μέσα στο lock επίτηδες: το κλείσιμο εγγράφου καλεί πίσω στο module όπως και η φόρτωση. Μόλις ο worker έχει καταγεγραμμένη εγγραφή TPdfPreflightReport, απελευθερώνει το lock και αποτιμά τους κανόνες validation απέναντι σε εκείνη την εγγραφή, που δεν αγγίζει καμία κατάσταση PDFium, οπότε η εκτίμηση κανόνων για ένα αρχείο επικαλύπτεται με τη δουλειά PDFium για το επόμενο

Διάγραμμα ValidatePdfFilesParallel PDFium Component δείχνει κάθε worker να κρατά ένα critical section όλης της διεργασίας διά μέσου TPdf create, load, preflight και free ενώ η εκτίμηση κανόνων της καταγεγραμμένης report τρέχει έξω από το lock παράλληλα, οπότε το PDFium μισό του batch είναι σειριακό εκ σχεδιασμού
Δημιουργία και καταστροφή μένουν μέσα στο lock επειδή το κλείσιμο εγγράφου καλεί πίσω στο module, ενώ η εκτίμηση report δεν αγγίζει κατάσταση PDFium και επικαλύπτει το επόμενο αρχείο

Δύο μικρότερες αλλαγές ήρθαν με τη διόρθωση. Αστοχία φόρτωσης τώρα σηκώνει EPdfError με LastLoadReport.ErrorMessage, οπότε το ErrorMessage του αντικειμένου ονομάζει το πραγματικό πρόβλημα parsing αντί για δευτερεύον σφάλμα «no active document». Και το κόστος λέγεται τίμια: το PDFium μέρος του batch είναι τώρα σειριακό, οπότε σε batch που κυριαρχείται από parsing και preflight, επιπλέον workers αγοράζουν λίγο. Αν είστε σε version πριν το 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, διαλέξτε μόνοι σας το ταιριαστό profile.
    // Κενή λίστα Profiles τρέχει κάθε καταχωρημένο κανόνα, και κανόνες για
    // standards που δεν κάνατε preflight αναφέρουν «δεν πέρασαν»
    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 τότε δημιουργεί μόνο του το default registry, παράγει τη λίστα profiles από Options.Standards, και απελευθερώνει το registry όταν γυρίσει. Τα αποτελέσματα γυρίζουν πάντα σε σειρά εισόδου, όποια σειρά κι αν τελείωσαν οι workers. Για τα formats report και το command-line wrapper πάνω στην ίδια μηχανή, δείτε batch PDF preflight reports με το PDFium Component CLI, και για το τι καλύπτουν οι ίδιοι οι έλεγχοι PDF/A, PDF/A preflight validation στο Delphi

Πώς τρέχει το RenderPagesParallel σελίδες πραγματικά παράλληλα;

Το TPdf.RenderPagesParallel τρέχει παράλληλα επειδή οι workers του δεν μοιράζονται ποτέ module PDFium. Η μέθοδος πρώτα αποθηκεύει το ενεργό έγγραφο σε source store στο calling thread. Κάθε worker μετά αντιγράφει τη φορτωμένη DLL PDFium σε μοναδικά ονομασμένο αρχείο στον temp κατάλογο, φορτώνει εκείνο το αντίγραφο με LoadLibrary, και το αρχικοποιεί. Τα Windows μεταχειρίζονται DLL φορτωμένη από διαφορετική διαδρομή ως διαφορετικό module, οπότε κάθε αντίγραφο παίρνει δικά του globals: δικό του font cache, δικό του page module, δικό του τα πάντα. Ο worker ανοίγει το αποθηκευμένο έγγραφο στο ιδιωτικό του module, αποδίδει τις σελίδες του προοδευτικά με ελέγχους ακύρωσης ανάμεσα στα βήματα, μετά καταστρέφει τη βιβλιοθήκη, ξεφορτώνει το αντίγραφο και σβήνει το αρχείο

Διάγραμμα RenderPagesParallel PDFium Component όπου το calling thread σώζει snapshot εγγράφου, μετά κάθε worker αντιγράφει τη DLL PDFium σε μοναδικό temp αρχείο, τη φορτώνει ως ξεχωριστό module με δικά του globals, αποδίδει τις σελίδες του με ελέγχους ακύρωσης και ξεφορτώνει το αντίγραφο
Η πραγματική παραλληλία έρχεται από module απομόνωση· τα Windows μεταχειρίζονται κάθε αντίγραφο DLL ως διαφορετικό module, οπότε οι workers δεν μοιράζονται τίποτα εκτός από το snapshot που σώσε το calling thread κάτω από το lock

Η απομόνωση δεν είναι δωρεάν, και οι προεπιλογές το αντανακλούν. Κάθε worker πληρώνει αντίγραφο DLL στον δίσκο, δεύτερο σύνολο PDFium globals στη μνήμη, και φρέσκο parse του εγγράφου. Το MaxWorkers = 0 σημαίνει το πολύ 4 workers, τα MaxPixelsPerPage και MaxTotalOutputBytes πλαφονάρουν την ακατέργαστη έξοδο, και οι επιλογές απόδοσης inverted και night-duotone απορρίπτονται επειδή οι buffers γυρίζουν ωμές. Το αποτέλεσμα είναι TPdfParallelRenderReport του οποίου το array Results κρατά ένα top-down buffer 32 bit ανά ζητούμενη σελίδα, σε σειρά αιτήματος

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;

  // Το source snapshot παίρνεται στο μοιρασμένο module, οπότε κρατήστε το
  // process-wide PDFium lock αν και άλλα threads χρησιμοποιούν 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;

Προσέξτε το lock γύρω από την κλήση. Τα worker modules είναι ιδιωτικά, αλλά το βήμα snapshot στην αρχή τρέχει SaveAs στο μοιρασμένο module από το calling thread. Αν τίποτα άλλο στη διεργασία σας δεν αγγίζει TPdf ταυτόχρονα μπορείτε να ρίξετε το lock· αν κάτι το κάνει, το snapshot χρειάζεται την ίδια προστασία με κάθε άλλη κλήση μοιρασμένου module

ΜοτίβοΑσφαλές μεταξύ εγγράφωνΗ δουλειά PDFium τρέχει παράλληλαΚόστος
Ένα TPdf ανά thread, χωρίς μοιρασμένο lockΌχιΝαι, μέχρι να καταστρέψειΔιαλείπουσες καταρρίψεις, κατεστραμμένη κατάσταση διεργασίας
Ένα lock όλης της διεργασίας γύρω από όλες τις κλήσεις PDFiumΝαιΌχιΤο PDFium μέρος είναι σειριακό
ValidatePdfFilesParallel από το v3.125.1ΝαιΌχι· η εκτίμηση κανόνων είναι παράλληληParsing και preflight είναι σειριακά
TPdf.RenderPagesParallelΝαιΝαιΑντίγραφο DLL, μνήμη και φρέσκο parse ανά worker

Πώς δομείτε τον δικό σας multithreaded κώδικα PDFium;

Τα δικά σας threads πρέπει να μοιράζονται ένα lock όλης της διεργασίας και να το κρατούν για όλη τη ζωή κάθε TPdf που χρησιμοποιούν, ή να χρησιμοποιήσουν component API που απομονώνει το module για εσάς. Το lock πρέπει να είναι ένα μοναδικό object για όλη τη διεργασία, όχι ένα ανά thread, ανά form ή ανά έγγραφο· lock που δύο threads δεν μοιράζονται δεν προστατεύει τίποτα. Το μοτίβο παρακάτω αντικατοπτρίζει τι κάνει εσωτερικά το component από το v3.125.1: δημιουργία, φόρτωση, διάβασμα και απελευθέρωση μέσα στο lock, μετά όλα όσα δεν αγγίζουν PDFium έξω από αυτό

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // ένα lock για όλη τη διεργασία

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 μέσα στο lock, όχι μόνο τις προφανείς κλήσεις. Φόρτωση, κλείσιμο, διαβάσματα ιδιοτήτων όπως PageCount, αλλαγές σελίδας, εξαγωγή κειμένου, απόδοση και αποθήκευση φτάνουν όλα μέσα στο module
  • Τσεκάρετε το Active μετά την ανάθεσή του. Αποτυχημένη φόρτωση αφήνει το Active σε False, και το LastLoadReport.ErrorMessage λέει γιατί
  • Κρατήστε το lock ανά έγγραφο αντί ανά κλήση. Πιο λεπτό locking είναι δυνατό κατ’ αρχήν, αλλά μόνο αν κανένα μέλος TPdf δεν τρέχει ποτέ έξω από αυτό, και η χοντρική εκδοχή είναι εκείνη στην οποία βασίζεται το ίδιο το component
  • Κρατήστε αργή μη-PDFium δουλειά, όπως εγγραφές βάσης δεδομένων, indexing και κλήσεις δικτύου, έξω από το lock, αλλιώς ένας αργός καταναλωτής θα σειριοποιήσει τα πάντα
  • Μην μεταχειριστείτε το ιδιωτικό render lock ανά instance ως υποκατάστατο. Προστατεύει ένα TPdf από τον εαυτό του και τίποτα παραπάνω

Η ίδια προσοχή ισχύει για κώδικα που δεν γράψατε εσείς ως ακατέργαστα threads. Τα background futures είναι καλός τρόπος να κρατήσετε μακριές αποδόσεις μακριά από το UI thread, όπως περιγράφεται στο background απόδοση PDF με ακυρώσιμα futures, αλλά ο executor των futures δεν προσθέτει δικό του καθολικό PDFium lock. Αν πολλά futures μπορούν να οδηγήσουν διαφορετικά instances TPdf την ίδια στιγμή, πάρτε το ίδιο lock όλης της διεργασίας μέσα σε κάθε worker, και μεταχειριστείτε viewer στο main thread ως έναν ακόμα πελάτη του μοιρασμένου module. Η cross-instance χρήση μέσω των asynchronous API δεν έχει ελεγχθεί ξεχωριστά, οπότε η συντηρητική υπόθεση είναι ότι χρειάζεται την ίδια σειριοποίηση με χειρόγραφα threads. Όταν χρειάζεστε πραγματικό PDFium παραλληλισμό για κάτι άλλο εκτός απόδοσης σελίδων, ξεχωριστές worker διεργασίες δίνουν σε κάθε job δικό του module εκ κατασκευής

Σύντομη αναφορά: κανόνες threading PDFium για Delphi

  • Η ανασφαλής κατάσταση του PDFium είναι σε όλο το module· font cache, page module και άλλα globals μοιράζονται από κάθε έγγραφο στη διεργασία
  • Ένα TPdf ανά thread δεν απομονώνει τίποτα· δύο instances σε δύο threads μπορούν ακόμα να αλληλοκαταστρέφονται
  • Τυπικά συμπτώματα είναι αστοχίες φόρτωσης, access violations σε μεταγενέστερο κώδικα, External exception C000001D, και έξοδοι με 0xC0000409 ή 0xC0000374
  • Η καταστροφή επιμένει στη διεργασία, οπότε η αποτυγχάνουσα κλήση συχνά δεν είναι αυτή που την προκάλεσε
  • Το ValidatePdfFilesParallel είναι ασφαλές από το v3.125.1· σε παλαιότερες versions χρησιμοποιήστε WorkerCount := 1
  • Το TPdf.RenderPagesParallel είναι πραγματικά παράλληλο επειδή κάθε worker φορτώνει απομονωμένο αντίγραφο του module PDFium
  • Τα δικά σας threads, tasks και futures χρειάζονται ένα lock όλης της διεργασίας που καλύπτει κάθε TPdf από Create έως Free

Το PDFium Component τυλίγει τη μηχανή PDFium για Delphi με batch preflight και validation, απομονωμένη παράλληλη απόδοση, ακυρώσιμη background δουλειά και αναλυτικά διαγνωστικά φόρτωσης. Λεπτομέρειες και εκδόσεις στη σελίδα προϊόντος PDFium Component