Το 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 οποιαδήποτε στιγμή, ανεξάρτητα από το πόσα έγγραφα είναι ανοιχτά
Πώς φαίνεται 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 για το επόμενο
Δύο μικρότερες αλλαγές ήρθαν με τη διόρθωση. Αστοχία φόρτωσης τώρα σηκώνει 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, αποδίδει τις σελίδες του προοδευτικά με ελέγχους ακύρωσης ανάμεσα στα βήματα, μετά καταστρέφει τη βιβλιοθήκη, ξεφορτώνει το αντίγραφο και σβήνει το αρχείο
Η απομόνωση δεν είναι δωρεάν, και οι προεπιλογές το αντανακλούν. Κάθε 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