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

Έξι κλήσεις PDFium που ξέχασαν το κλείδωμα απόδοσης σε Delphi

Το κλείδωμα απόδοσης του PDFiumPas είναι ένα κρίσιμο τμήμα ανά έγγραφο — EnterRenderLock και LeaveRenderLock, υποστηριζόμενο από ένα πεδίο TRTLCriticalSection στο TPdf — που προορίζεται να τυλίγει κάθε κλήση στον ραστεροποιητή του PDFium ώστε μια σελίδα να μην μπορεί να αποφορτωθεί ή να επαναφορτωθεί κάτω από μια απόδοση εν εξελίξει. Έξι μέθοδοι μοιρασμένες εξίσου μεταξύ TPdf και TPdfView καλούσαν τα API εξαγωγής bitmap και μικρογραφίας του PDFium απευθείας και παρέλειπαν εντελώς εκείνο το κλείδωμα, ένα κενό που η PDFiumPas v2.26.0 έκλεισε τυλίγοντας και τις έξι στο ίδιο ζεύγος κλειδώματος που ήδη χρησιμοποιούσε κάθε άλλο σημείο εισόδου απόδοσης

Το κενό που καλύπτεται εδώ δεν είναι το πέρασμα ενίσχυσης ABI που καλύπτεται αλλού σε αυτό το blog, το οποίο διέτρεξε μια ασυμφωνία σύμβασης κλήσης cdecl και μια περικοπή πλάτους δείκτη FPC Win64 στην ίδια σύνδεση PDFium. Αυτό που ακολουθεί είναι στενότερο και πιο μηχανικό: μια λίστα ελέγχου κάλυψης κλειδώματος για έξι σημεία κλήσης που όλα φτάνουν στη διαδρομή απόδοσης του PDFium, γιατί το καθένα ήταν εύκολο να παραλειφθεί, και γιατί η συνθήκη αγώνα που ακολουθεί από την απουσία του κλειδώματος είναι ένα από τα πιο δύσκολα ελαττώματα σε αυτή τη βάση κώδικα να αναπαραχθεί κατ' απαίτηση

Τι πράγματι προστατεύει το κλείδωμα απόδοσης

Το PDFiumPas σειριοποιεί την απόδοση επειδή η φορτωμένη σελίδα του PDFium δεν είναι ασφαλής προς ανάγνωση από ένα νήμα ενώ ένα άλλο νήμα είναι ελεύθερο να την απελευθερώσει. Το TPdf κατέχει ένα TRTLCriticalSection στο FRenderLock, αρχικοποιημένο στον κατασκευαστή και φρουρούμενο από μια σημαία FRenderLockReady ώστε μια κλήση που φτάνει μετά την αποσυναρμολόγηση να γίνεται μια σιωπηλή μη-λειτουργία αντί να εισέλθει σε ένα διαγραμμένο κρίσιμο τμήμα. Τα EnterRenderLock και LeaveRenderLock είναι ο μόνος εγκεκριμένος τρόπος εισόδου και εξόδου από εκείνο το τμήμα

procedure TPdf.EnterRenderLock;
begin
  if FRenderLockReady then
    EnterCriticalSection(FRenderLock);
end;

procedure TPdf.LeaveRenderLock;
begin
  if FRenderLockReady then
    LeaveCriticalSection(FRenderLock);
end;

Τα TPdf.RenderPage, RenderTile, και RenderPageProgressive ήδη ακολουθούσαν εκείνη την πειθαρχία πριν ξεκινήσει καθόλου αυτός ο συγκεκριμένος έλεγχος, καθένα παίρνοντας το κλείδωμα πριν καλέσει το PDFium και απελευθερώνοντάς το σε ένα μπλοκ finally ώστε μια προαπόδοση στο παρασκήνιο και ένα UnloadPage στο προσκήνιο πάνω στο ίδιο στιγμιότυπο TPdf να μην μπορούν να επικαλυφθούν. Το κενό που βρήκε η PDFiumPas v2.26.0 δεν ήταν σε εκείνα τα προφανή σημεία εισόδου — εμφανίστηκε σε έξι μεθόδους που διαβάζονται σαν αξεσοράρ αντί για αποδόσεις, παρότι καθεμία τους ζητά από το PDFium να ραστεροποιήσει pixel πριν μπορέσει να επιστρέψει οτιδήποτε

Ποιες έξι κλήσεις παρέλειψαν το κλείδωμα απόδοσης;

Τα TPdf.GetObjectBitmap, TPdf.GetBitmap, και TPdf.GetThumbnail αποτελούσαν το μισό της λίστας, και τα TPdfView.GetObjectBitmap, TPdfView.GetBitmap, και TPdfView.GetThumbnail αποτελούσαν το άλλο μισό — οι ίδιες τρεις λειτουργίες, διπλασιασμένες στις δύο κλάσεις εξαρτημάτων που εκθέτουν την ίδια υποκείμενη σελίδα. Και οι έξι τελικά καλούν είτε FPDFImageObj_GetBitmap είτε FPDFPage_GetThumbnailAsBitmap, και αμφότερα εκείνα τα σημεία εισόδου PDFium ραστεροποιούν επί τόπου αντί να δίνουν πίσω μια αναφορά σε κάτι ήδη αποδοθέν. Τίποτα σε κανένα από τα έξι ονόματα μεθόδων δεν λέει render, κάτι που είναι μια λογική εξήγηση για το γιατί δεν γράφτηκαν έναντι της ίδιας λίστας ελέγχου με τα RenderPage και RenderTile την πρώτη φορά

function TPdf.GetObjectBitmap(Index: Integer): TBitmap;
var
  Bitmap: FPDF_BITMAP;
begin
  Result:= nil;
  EnterRenderLock;
  try
    Bitmap:= FPDFImageObj_GetBitmap(GetObjectHandle(Index));
  finally
    LeaveRenderLock;
  end;
  if Bitmap<> nil then
    try
      Result:= ToBitmap(Bitmap);
    finally
      FPDFBitmap_Destroy(Bitmap);
    end;
end;

Γιατί το TPdfView φρουρεί την κλήση κλειδώματός του με έναν έλεγχο nil

Το TPdfView δεν κατέχει δικό του κρίσιμο τμήμα — καθεμία από τις έξι κλήσεις κλειδώματός του προωθεί στα FPdf.EnterRenderLock και FPdf.LeaveRenderLock, τυλιγμένη σε έναν έλεγχο ότι η συσχετισμένη αναφορά TPdf δεν είναι nil πρώτα. Εκείνη η φρουρά υπάρχει επειδή ένα TPdfView μπορεί να κάθεται σε μια φόρμα κατά τον χρόνο σχεδιασμού, ή στιγμιαία μεταξύ του κλεισίματος ενός εγγράφου και του ανοίγματος του επόμενου, χωρίς κανένα TPdf ανατεθειμένο ακόμη στο FPdf. Η παράλειψη της φρουράς θα αντάλλασσε μία κατάρρευση για μία άλλη, αφού μια κλήση κλειδώματος έναντι μιας αναφοράς nil αποτυγχάνει όχι πιο κομψά από τη συνθήκη αγώνα που υπάρχει το κλείδωμα για να αποτρέψει

function TPdfView.GetThumbnail: TBitmap;
var
  PdfBitmap: FPDF_BITMAP;
begin
  CheckActive;
  Result:= nil;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  try
    PdfBitmap:= FPDFPage_GetThumbnailAsBitmap(Page);
  finally
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
  if PdfBitmap<> nil then
    try
      Result:= ToBitmap(PdfBitmap);
    finally
      FPDFBitmap_Destroy(PdfBitmap);
    end;
end;

Γιατί ανήκει το RenderPage(HDC) στον ίδιο έλεγχο;

Το TPdfView.RenderPage έναντι ενός context συσκευής δεν είναι ένα από τα έξι — εμφανίστηκε μια έκδοση νωρίτερα, στην PDFiumPas v2.25.0, και κερδίζει μια θέση σε αυτή τη λίστα ελέγχου επειδή είναι το ίδιο ελάττωμα που φορά διαφορετική υπογραφή. Εκείνη η υπερφόρτωση καλούσε το FPDF_RenderPage απευθείας χωρίς ούτε το EnterRenderLock ούτε την κλήση SetArithmeticMask που φρουρεί έναντι εξαιρέσεων FPU σε παλαιότερους compiler Delphi, ενώ η υπερφόρτωση TBitmap που κάθεται λίγες γραμμές πιο κάτω στην ίδια κλάση ήδη έφερε και τα δύο. Δύο περάσματα ελέγχου που πιάνουν την ίδια λειτουργία αποτυχίας μια έκδοση διαφορά λένε λιγότερα για οποιαδήποτε μεμονωμένη μέθοδο και περισσότερα για το σχήμα του σφάλματος: κρύβεται σε όποια υπερφόρτωση κανείς δεν ξαναδιαβάζει μόλις το αδελφικό της φαίνεται σωστό

procedure TPdfView.RenderPage(DeviceContext: HDC; Left, Top, Width,
  Height: Integer; Rotation: TRotation; Options: TRenderOptions);
var
  ArithmeticMask: TArithmeticMask;
begin
  CheckActive;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  ArithmeticMask:= SetArithmeticMask;
  try
    FPDF_RenderPage(DeviceContext, FPage, Left, Top, Width, Height,
      Ord(Rotation), EncodeRenderOptions(Options));
  finally
    RestoreArithmeticMask(ArithmeticMask);
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
end;

Γιατί αυτή η συνθήκη αγώνα είναι σχεδόν αδύνατο να αναπαραχθεί;

Το κενό κλειδώματος απόδοσης του PDFiumPas δεν αποτυγχάνει σε κάθε εκτέλεση, ούτε καν στις περισσότερες εκτελέσεις, επειδή χρειάζεται δύο συγκεκριμένα πράγματα να προσγειωθούν στο ίδιο στιγμιότυπο TPdf ταυτόχρονα: μια κλήση ραστεροποίησης ήδη σε εξέλιξη, και ένα ταυτόχρονο UnloadPage ή ReloadPage που φτάνει μέσα σε εκείνο το ίδιο παράθυρο. Η δοκιμή ενός νήματος ποτέ δεν ασκεί καθόλου τη διαδρομή, και ακόμη και γνησίως πολυνηματικά φορτία εργασίας το πυροδοτούν μόνο όταν μια απόδοση στο παρασκήνιο και ένα συμβάν κύκλου ζωής εγγράφου τυχαίνει να επικαλύπτονται μέσα στη διάρκεια ζωής μιας σελίδας. Ο πιο ρεαλιστικός σκανδαλιστής είναι η προαπόδοση PDF στο παρασκήνιο χτισμένη πάνω σε ακυρώσιμα futures, όπου ένα νήμα εργασίας ραστεροποιεί την επόμενη σελίδα ενώ το νήμα UI επαναφορτώνει ή αποφορτώνει την τρέχουσα με είσοδο χρήστη

Τα FPDFImageObj_GetBitmap και FPDFPage_GetThumbnailAsBitmap διατρέχουν δομές αντικειμένων σελίδας που το UnloadPage είναι ελεύθερο να απελευθερώσει στη μέση της διάτρεξης, οπότε μια συνθήκη αγώνα που πράγματι πυροδοτείται δεν παράγει πάντα ούτε μια άμεση παραβίαση πρόσβασης. Μια δομή που διαβάστηκε μια στιγμή πολύ αργά μπορεί εξίσου εύκολα να δώσει πίσω σκουπίδια pixel, ή να αλλοιώσει μεταδεδομένα σωρού που καταρρέουν μόνο αρκετές ασύνδετες εκχωρήσεις αργότερα, σε μια συνάρτηση που ποτέ δεν άγγιξε μια σελίδα PDF. Αυτός είναι ο ειλικρινής λόγος που αυτή η κατηγορία σφάλματος μπορεί να επιβιώσει σε μια βάση κώδικα σε αρκετούς κύκλους έκδοσης: το ίχνος στοίβας στο σημείο αποτυχίας σπάνια δείχνει κάπου κοντά στις έξι γραμμές στις οποίες πράγματι έλειπε ένα κλείδωμα

Τι αλλάζει για τους καλούντες

Τα GetBitmap, GetObjectBitmap, GetThumbnail, και η υπερφόρτωση HDC του RenderPage κρατούν τις δημόσιες υπογραφές τους ακριβώς όπως ήταν, αφού η διόρθωση είναι εσωτερικό κλείδωμα προστιθέμενο γύρω από υπάρχουσες κλήσεις αντί για μετάβαση. Αξίζει να θυμάστε ότι το κλείδωμα απόδοσης έχει εμβέλεια ανά στιγμιότυπο TPdf, όχι καθολικό στη διεργασία, οπότε δύο νήματα που αποδίδουν δύο ξεχωριστά φορτωμένα έγγραφα εξακολουθούν να τρέχουν πλήρως παράλληλα — το κλείδωμα σειριοποιεί μόνο λειτουργίες έναντι του ενός εγγράφου που τυχαίνει να μοιράζονται και τα δύο νήματα. Αν το δικό σας κλείδωμα είναι ήδη σωστό και οι αποδόσεις εξακολουθούν να αισθάνονται αργές κάτω από zoom ή κύλιση, αυτό είναι διαφορετικό ζήτημα, απαντημένο στο άρθρο τακτικών προσωρινής μνήμης απόδοσης PDFium και απόδοσης zoom — ορθότητα και ταχύτητα είναι ξεχωριστοί άξονες εδώ, και αυτή η διόρθωση αγγίζει μόνο τον πρώτο

Έξι μέθοδοι και μία αδελφική υπερφόρτωση είναι ένα μικρό κλάσμα της επιφάνειας PDFium που εκθέτει το PDFiumPas, αλλά ήταν το κλάσμα που συμπεριφερόταν λάθος μόνο κάτω από φορτίο που κανείς δεν έτυχε να τρέχει σε debugger. Το ίδιο το κλείδωμα απόδοσης, και το πλήρες σύνολο σημείων εισόδου απόδοσης που τώρα καλύπτει, διατίθενται ως μέρος του εξαρτήματος PDFium για Delphi, C++Builder, και Lazarus/FPC