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

Αδιέξοδο WaitForIdle σε ασύγχρονη απόδοση PDFium Delphi

Ένα batch render παγώνει στη μέση γιατί ο executor στο PDFium Component δεν θεωρεί μια εργασία ολοκληρωμένη μέχρι να έχει σταλεί η απάντησή της. Κάτω από το padSynchronize αυτή η απάντηση τρέχει στο κύριο νήμα. Αν το κύριο νήμα μπλοκάρει χωρίς να αντλεί το CheckSynchronize, ο worker περιμένει το κύριο νήμα ενώ το κύριο νήμα περιμένει το idle

Η εικόνα του debugger είναι αλάνθαστη μόλις την έχεις δει. Παγώνεις τη διαδικασία και το κύριο νήμα κάθεται μέσα σε μια αναμονή στο συμβάν idle, αρκετά frames κάτω από τον δικό σου βρόχο batch. Γυρίζεις σε οποιοδήποτε νήμα worker και κάθεται μέσα στο TThread.Synchronize, κρατώντας ένα ολοκληρωμένο αποτέλεσμα που δεν μπορεί να παραδώσει. Τίποτα δεν γυρίζει, καμία CPU δεν καίγεται, η διαδικασία απλά έχει παρκάρει. Αυτό το άρθρο αφορά το γιατί υπάρχει καθόλου αυτή η κατάσταση, και τρεις γειτονικούς κανόνες που αποφασίζουν αν μια δεξαμενή workers Delphi πάνω από το PDFium συμπεριφέρεται ή δαγκώνει: τι πραγματικά οριοθετεί το QueueCapacity, με ποια σειρά πρέπει να ακυρώνει ο τερματισμός, και τι δεν σου αγοράζει η παραλληλία όσον αφορά την κυριότητα αντικειμένων PDFium

Γιατί το WaitForIdle κρεμά το κύριο νήμα;

Κρεμά γιατί το idle στο TPdfAsyncExecutor ορίζεται να περιλαμβάνει την αποστολή απάντησης, όχι μόνο την ολοκλήρωση worker. Ο μετρητής τρεχόντων αυξάνεται στο DequeueTask όταν ένας worker παραλαμβάνει μια εργασία, και μειώνεται στο TaskFinished, που ο worker καλεί μόνο αφού η TPdfAsyncTaskOperation.Execute έχει επιστρέψει. Αυτή η μέθοδος τρέχει το σώμα worker, καταγράφει την έκβαση, και μετά αποστέλλει την απάντηση σύμφωνα με το TPdfAsyncDispatchMode. Με το padSynchronize η αποστολή είναι μια κλήση TThread.Synchronize, οπότε η Execute δεν επιστρέφει μέχρι το κύριο νήμα να την έχει τρέξει

Η Delphi βάζει το δεύτερο μισό αυτού του συμβολαίου πάνω σου. Η TThread.Synchronize προσαρτά τη μέθοδο σε μια καθολική ουρά και μπλοκάρει το καλούν νήμα σε ένα συμβάν· κάτι στο κύριο νήμα πρέπει να καλέσει το CheckSynchronize πριν αυτό το συμβάν σημανθεί ποτέ. Ο βρόχος μηνυμάτων VCL το κάνει αυτό για σένα ανάμεσα σε μηνύματα, που είναι ακριβώς γιατί το bug είναι αόρατο κατά τη διαδραστική χρήση και εμφανίζεται τη στιγμή που γράφεις έναν μπλοκάροντα βρόχο batch. Ένα μπλοκαρισμένο κύριο νήμα είναι ένα κύριο νήμα που έχει εγκαταλείψει τον βρόχο μηνυμάτων, και ένα κύριο νήμα έξω από τον βρόχο μηνυμάτων δεν αδειάζει κανέναν

uses
  System.Classes, FPdfAsync, PDFium;

// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // the main thread now parks in a kernel wait

// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.

Η ολοκλήρωση εργασίας και το idle του executor είναι δύο διαφορετικά ορόσημα

Είναι ξεχωρισμένα σκόπιμα, και το να ξέρεις ποιο από τα δύο περιμένεις είναι όλη η διόρθωση. Το IPdfAsyncTask.WaitFor ικανοποιείται τη στιγμή που αποφασίζεται το αποτέλεσμα worker: το Complete γράφει την τελική TPdfAsyncTaskState και θέτει το συμβάν ολοκλήρωσης πριν εξεταστεί οποιαδήποτε απάντηση. Το TPdfAsyncExecutor.WaitForIdle ικανοποιείται αργότερα, μόλις οι μετρητές ουράς και τρεχόντων είναι και οι δύο μηδέν, και το τρέχον δεν πέφτει μέχρι να έχει προσγειωθεί η απάντηση. Οπότε μια εργασία μπορεί να είναι patsSucceeded και παρατηρήσιμη μέσω του Snapshot ενώ ο executor είναι ακόμα νόμιμα απασχολημένος

// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // release any pending padSynchronize reply
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

Μια συνέπεια που αξίζει να εσωτερικευτεί: μια απάντηση που σηκώνει εξαίρεση δεν ξαναγράφει την ιστορία. Το DispatchReply πιάνει την εξαίρεση και την αποθηκεύει στο ReplyErrorMessage, αφήνοντας τα State, CancellationReason και ErrorMessage ακριβώς όπως τα καθόρισε ο worker. Ένα callback UI που τιναχτεί ενώ ζωγραφίζει μια μικρογραφία επομένως ποτέ δεν μετατρέπει ένα επιτυχημένο render σε αποτυχημένο, και η τηλεμετρία σου συνεχίζει να αναφέρει τι πραγματικά έκανε η μηχανή απόδοσης. Αν θέλεις το API σε σχήμα callback γύρω από μία μοναδική λειτουργία αντί για μια δεξαμενή, η απόδοση παρασκηνίου με ακυρώσιμα futures καλύπτει εκείνο το μονοπάτι

Το QueueCapacity οριοθετεί και τους τρέχοντες workers;

Όχι. Το QueueCapacity στο PDFium Component μετρά μόνο εργασίες σε ουρά, ποτέ αυτές που ήδη εκτελούνται σε έναν worker. Αυτό είναι σκόπιμο: η χωρητικότητα προορίζεται να εκφράζει πραγματική αντίθλιψη στη γραμμή αναμονής, και η δίπλωση των σταθερών θέσεων ταυτόχρονης εκτέλεσης στον ίδιο αριθμό θα τις μετρούσε δύο φορές. Με τέσσερις workers και χωρητικότητα οκτώ μπορείς να έχεις δώδεκα εργασίες σε πτήση, και το GetStats αναφέρει τον διαχωρισμό ειλικρινά μέσω των QueuedCount και RunningCount

var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit never raises: it returns False when the waiting line is full or
  // the executor is already shutting down, and bumps RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
    // WorkerCount and is never charged against the capacity.
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

Οι τέσσερις λωρίδες του TPdfAsyncPriority είναι αυστηρές, όχι σταθμισμένες. Το DequeueTask διατρέχει από το papCritical προς τα κάτω μέχρι το papLow και παίρνει την πρώτη μη κενή λωρίδα, διατηρώντας τη σειρά FIFO μέσα σε καθεμία. Αυτό δίνει σε ένα διαδραστικό αίτημα έναν καθαρό τρόπο να προσπεράσει ένα batch που δεν έχει ξεκινήσει ακόμα, αλλά ποτέ δεν διακόπτει εργασία που ήδη τρέχει, και ένας καλών που συνεχίζει να τροφοδοτεί το papCritical μπορεί να λιμοκτονήσει το papLow επ' αόριστον. Κράτησε τις δύο κορυφαίες λωρίδες για πράγματα που ένας άνθρωπος περιμένει ορατά, και άφησε τη μαζική εξαγωγή στο papNormal ή χαμηλότερα. Χρησιμοποίησε το Submit όταν μια γεμάτη ουρά είναι σφάλμα προγραμματισμού που αξίζει ένα EPdfAsyncQueueFull, και το TrySubmit όταν είναι κανονική συνθήκη που σκοπεύεις να χειριστείς

Γιατί το Shutdown ακυρώνει έξω από το κλείδωμα του executor;

Γιατί η ακύρωση μέσα σε αυτό θα αντέστρεφε τη σειρά κλειδώματος και θα κρεμούσε τον τερματισμό που προσπαθείς να κάνεις. Το Shutdown(True) παίρνει το κλείδωμα του executor, γυρίζει τη σημαία τερματισμού, και προσαρτά κάθε εκκρεμή εργασία σε έναν τοπικό πίνακα στιγμιότυπου μέσω του AppendSnapshot. Μετά απελευθερώνει το κλείδωμα και μόνο μετά διατρέχει το στιγμιότυπο καλώντας Cancel σε κάθε καταχώρηση. Η ακύρωση μιας εργασίας πυροδοτεί callbacks χρήστη καταχωρημένα στην πηγή token της, και αυτά τα callbacks είναι συνηθισμένος κώδικας εφαρμογής: μπορεί να ρωτήσουν το GetStats, να υποβάλουν αντισταθμιστική εργασία, ή να περιμένουν idle. Καθένα από αυτά ξαναμπαίνει στο κλείδωμα του executor, και ένα callback που καλείται ενώ αυτό το κλείδωμα κρατείται θα οδηγούσε σε αδιέξοδο έναντι του εαυτού του

Η πηγή token υπακούει στην ίδια πειθαρχία ένα επίπεδο πιο κάτω. Το CancelWithReason παίρνει το κλείδωμα πηγής, αποφασίζει τον μοναδικό νικητή ακυρωτή, γράφει τα Reason, CancellationMessage και CancelledAtTick, και μόνο μετά γυρίζει τη σημαία ακυρωμένου ατομικά. Το «δημοσίευσε πριν γυρίσεις» είναι αυτό που κάνει τα μεταδεδομένα ασφαλή για ανάγνωση: κάθε νήμα που παρατηρεί το IsCancelled ως True είναι εγγυημένο ότι θα βρει έναν πλήρη λόγο πίσω του, και μεταγενέστεροι καλούντες χάνουν τον αγώνα, επιστρέφουν False, και δεν μπορούν να αντικαταστήσουν τον πρώτο λόγο. Τα καταχωρημένα callbacks λαμβάνονται ως στιγμιότυπο και καθαρίζονται μέσα στο κλείδωμα αλλά καλούνται έξω από αυτό, το καθένα τυλιγμένο ώστε ένας αποτυχημένος χειριστής να μην μπορεί να καταστείλει τους υπόλοιπους. Εργασίες που ήδη τρέχουν ποτέ δεν σκοτώνονται· τερματίζουν συνεργατικά όταν το σώμα worker τους καλέσει την επόμενη φορά το ThrowIfCancelled, γι' αυτό το Shutdown τελειώνει με ένα αντλούν WaitForIdle πριν ενώσει τα νήματα

Οι παράλληλοι workers χαλαρώνουν την κυριότητα αντικειμένων PDFium;

Δεν την χαλαρώνουν, και αυτό είναι το όριο που πιθανότερα παρερμηνεύεται. Το TPdfAsyncExecutor προγραμματίζει εργασία· δεν κάνει καμία δήλωση για τη συγγένεια νήματος οποιουδήποτε πράγματος αγγίζεις μέσα σε αυτή την εργασία. Μια ζωντανή περίπτωση TPdf δεν γίνεται ταυτόχρονα προσβάσιμη επειδή δύο workers τυχαίνει να την καλούν, και το εσωτερικό κλείδωμα render είναι φύλακας έναντι επικαλυπτόμενων κλήσεων render, όχι άδεια να μοιραστείς ένα έγγραφο σε νήματα. Παράλληλη απόδοση ή εξαγωγή σημαίνει ένα TPdf ανά worker, δημιουργημένο και καταστρεμμένο μέσα στην εργασία

type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // one document instance per worker, never shared
  Bmp: TBitmap;
begin
  LocalPdf := TPdf.Create(nil);
  try
    LocalPdf.FileName := FFileName;
    LocalPdf.Active := True;
    LocalPdf.PageNumber := FPageIndex;
    AToken.ThrowIfCancelled;
    Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
    try
      HandOffBitmap(FPageIndex, Bmp);   // ownership moves to the reply stage
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

Το κόστος είναι πραγματικό και αξίζει να ονομαστεί: κάθε worker πληρώνει τη δική του ανάλυση και τη δική του cache σελίδων, οπότε η μνήμη κλιμακώνεται με τον αριθμό workers παρά με τον αριθμό εγγράφων. Αυτό είναι το τίμημα ενός μοντέλου όπου ένας worker μπορεί να ακυρωθεί ή να καταρρεύσει χωρίς να διαφθείρει κανέναν άλλον. Αν οι workers σου μοιράζονται αντ' αυτού ένα έγγραφο από την πλευρά του viewer, οι κανόνες κλειδώματος γύρω από αυτό καλύπτονται στο το κλείδωμα render και οι κλήσεις που το χάνουν, και το μονοπάτι ακυρώσιμου μοναδικού εγγράφου βρίσκεται στην ακυρώσιμη προοδευτική απόδοση

Επέκταση ενός δημοσιευμένου interface χωρίς να σπάσει το vtable

Τα IPdfCancellationToken και IPdfCancellationTokenSource είναι interfaces στυλ COM που εξωτερικά binaries μπορεί ήδη να καταναλώνουν, οπότε η προσάρτηση μιας μεθόδου σε οποιοδήποτε από τα δύο θα μετατόπιζε κάθε μεταγενέστερη θέση στο vtable και θα δρομολογούσε σιωπηρά λάθος κλήσεις μεταγλωττισμένες έναντι της παλιάς διάταξης. Οι δυνατότητες διαγνωστικού, αναμονής, αφαιρέσιμου callback και ατομικής ακύρωσης επομένως ζουν στα IPdfCancellationTokenEx και IPdfCancellationTokenSourceEx, που κληρονομούν αντί να τροποποιούν. Τα New και Run κρατούν την αρχική τους σημασιολογία για υπάρχοντες καλούντες· νέος κώδικας απευθύνεται στα NewEx, NewTimeout και RunEx όταν θέλει CancelWithReason, WaitForCancellation ή μια διαχειριζόμενη IPdfCancellationRegistration. Η κληρονομικότητα είναι ο μόνος ασφαλής τρόπος να μεγαλώσεις ένα δημοσιευμένο interface, και κοστίζει έναν επιπλέον τύπο ανά γενιά

Το NewTimeout αξίζει μια ειλικρινή σημείωση. Κάθε πηγή timeout κατέχει ένα ελαφρύ νήμα που περιμένει είτε το συμβάν ακύρωσης είτε την προθεσμία, όποιο έρθει πρώτο. Για μια χούφτα ή μερικές δεκάδες προθεσμίες αυτό είναι απλό, χαμηλής καθυστέρησης και πανομοιότυπο σε Delphi, Lazarus και C++Builder. Για χιλιάδες σύντομες προθεσμίες είναι το λάθος σχήμα, και θα έπρεπε να οδηγείς την ακύρωση από έναν χρονομέτρη επιπέδου εφαρμογής αντί να κρατάς χιλιάδες νήματα σε αναμονή

Κανένας από αυτούς τους κανόνες δεν είναι εξωτικός μόλις γραφτεί, αλλά καθένας τους είναι ένα περιστατικό παραγωγής όταν δεν έχει γραφτεί. Περίμενε στο σωστό ορόσημο και άφησε κάτι να αντλεί την ουρά synchronize, διάβασε το QueueCapacity ως όριο μόνο στη γραμμή αναμονής, ακύρωσε έξω από τα κλειδώματά σου, και δώσε σε κάθε worker το δικό του έγγραφο. Το ασύγχρονο επίπεδο που περιγράφεται εδώ διατίθεται ως μέρος του PDFium Component για Delphi, μαζί με τα APIs απόδοσης, κειμένου και φόρμας που προγραμματίζει