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

Leases ανάγνωσης και guards εγγραφής HotXLS σε Delphi

Ένα background thread εξήγαγε μια αναφορά 40.000 γραμμών όταν το UI thread όρισε ένα κελί, και το αρχείο που κατέληξε στον δίσκο δεν ταίριαζε με κανένα βιβλίο εργασίας που υπήρξε ποτέ. Το HotXLS χειρίζεται αυτή την κατηγορία bug στο lxWorkbookView.pas, όπου το IXLSWorkbookViewCore εκδίδει read leases O(1) και fail-fast write guards: ενώ ένα lease είναι ανοιχτό, κάθε σημείο εισόδου μετάλλαξης προκαλεί εξαίρεση αντί να γράφει

Η αποτυχία που φτάνει χωρίς stack trace

Η ανάγνωση ενός βιβλίου εργασίας δεν είναι ποτέ μία ατομική λειτουργία. Μια διάσχιση αναφοράς είναι δεκάδες χιλιάδες μεμονωμένες αναγνώσεις κελιών απλωμένες σε δευτερόλεπτα, και ένα μοναδικό SetValue που προσγειώνεται ανάμεσα σε δύο από αυτές αρκεί για να αλλάξει αυτό που βλέπει η υπόλοιπη διάσχιση. Η κλασική μηχανή το κάνει συγκεκριμένο: το TXLSCellRef.SetValue μπορεί να καλέσει FSST.Remove για να πετάξει μια εγγραφή shared string, να μηδενίσει το FValueType και να ακυρώσει μια κατάσταση cache τύπου, ενώ ένα άλλο thread βρίσκεται στη μέση της αποαναφοράς ακριβώς αυτών των δομών. Τίποτα δεν καταρρέει επί τόπου. Παίρνετε μια αναφορά της οποίας τα μερικά σύνολα δεν βγαίνουν, ή μια εξαγωγή που διαβάζει σιωπηλά δείκτη string που πλέον δείχνει αλλού

Το HotXLS δεν επιλύει σκόπιμα αυτό κάνοντας τους writers να περιμένουν. Ένας αναγνώστης μπορεί να κρατά ένα βιβλίο εργασίας για αρκετά δευτερόλεπτα, και σε μια εφαρμογή VCL ο writer είναι συχνά ένα UI callback ή ένας handler συμβάντος στο κύριο thread — το μπλοκάρισμα εκείνου του thread μέχρι να τελειώσει μια εξαγωγή στο background είναι χειρότερο αποτέλεσμα από την αποτυχία της επεξεργασίας. Έτσι ο πυρήνας συντονισμού προκαλεί EXLSWorkbookWriteGuardUnavailable τη στιγμή που επιχειρείται εγγραφή εναντίον ανοιχτού lease, πριν αγγιχτεί έστω ένα πεδίο, και ο καλών αποφασίζει αν θα βάλει την επεξεργασία σε ουρά, θα ξαναδοκιμάσει ή θα πει στον χρήστη. Συγκρούσεις fail-fast, όχι σε ουρά

Ένας πίνακας συντονισμού HotXLS που δείχνει ότι τα read leases συνυπάρχουν ελεύθερα, ότι μια εγγραφή εναντίον ανοιχτού lease προκαλεί EXLSWorkbookWriteGuardUnavailable, ότι ένα lease που ζητείται μέσα σε συναλλαγή εγγραφής προκαλεί EXLSWorkbookReadLeaseUnavailable, και ότι δύο writer threads δεν αποκλείονται ποτέ μεταξύ τους
Οι αναγνώστες συνυπάρχουν και οι writers αποτυγχάνουν γρήγορα εναντίον τους, αλλά ο πυρήνας δεν αποκλείει ποτέ ένα writer thread από άλλο

Είναι ασφαλής η ανάγνωση βιβλίου εργασίας από δύο threads;

Ναι, αρκεί και οι δύο αναγνώστες να κρατούν lease και κανείς να μην γράφει. Το IXLSWorkbookViewCore.AcquireReadLease παίρνει ένα TCriticalSection, αυξάνει έναν μετρητή, παίρνει στιγμιότυπο της τρέχουσας γενιάς και επιστρέφει ένα IXLSWorkbookReadLease — σταθερός χρόνος ανεξάρτητα αν το βιβλίο εργασίας κρατά χίλια κελιά ή ένα εκατομμύριο. Οποιοσδήποτε αριθμός leases συνυπάρχει, μπορεί να απελευθερωθούν με οποιαδήποτε σειρά, και το καθένα καρφιτσώνει τον πυρήνα ζωντανό μέσω της δικής του αναφοράς interface, οπότε ένα lease που επιβιώνει του αντικειμένου που το δημιούργησε είναι ασφαλές και όχι κρεμάμενος δείκτης. Και οι δύο μηχανές συμμετέχουν: το TXLSWorkbook στο lxHandle.pas και το TXLSXWorkbook στο lxHandleX.pas χτίζουν το καθένα έναν πυρήνα στον constructor του και εκθέτουν _AcquireReadLease και _AcquireWriteGuard

Εξίσου σημαντικό είναι τι δεν προσθέτει το lease στη διαδρομή ανάγνωσης. Το critical section καλύπτει την απόκτηση lease, την απελευθέρωση lease και τα όρια συναλλαγών εγγραφής — τίποτα άλλο. Η συνηθισμένη ανάγνωση ανά κελί δεν μπαίνει ποτέ σε lock, monitor ή ατομικό μετρητή, οπότε το να κρατάς lease κοστίζει μία απόκτηση και μία απελευθέρωση για ολόκληρη τη σάρωση, όχι μία ανά κελί. Αυτό είναι το ίδιο σχεδιαστικό ένστικτο πίσω από τη παράλληλη ανάλυση XLSX και τον memory allocator: πληρώνεις για συντονισμό στα όρια, ποτέ στον εσωτερικό βρόχο. Ο συμμετρικός κανόνας ισχύει επίσης — το AcquireReadLease προκαλεί EXLSWorkbookReadLeaseUnavailable όποτε το WriteDepth είναι μη μηδενικό, οπότε δεν μπορείς να ανοίξεις lease από μέσα σε συναλλαγή εγγραφής, ούτε καν στο thread που γράφει

Το HotXLS πληρώνει για συντονισμό στα όρια μιας σάρωσης: το critical section καλύπτει μόνο την απόκτηση και απελευθέρωση lease και τα όρια συναλλαγών εγγραφής, ενώ ο write guard αποκτείται μέσα στο TXLSCellRef.SetValue ώστε κάθε convenience API πάνω από αυτό να φράσσεται μία φορά
Μία απόκτηση και μία απελευθέρωση καλύπτουν σάρωση πενήντα χιλιάδων κελιών, και ένας μοναδικός guard μέσα στο TXLSCellRef.SetValue καλύπτει κάθε δημόσια διαδρομή εγγραφής πάνω από αυτό
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Προκαλεί EXLSWorkbookReadLeaseUnavailable αν εγγραφή βρίσκεται σε εξέλιξη
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // Το lease βγαίνει από εμβέλεια εδώ: το reference count του πέφτει στο μηδέν,
  // τρέχει το ReleaseReadLease, και οι writers γίνονται ξανά δυνατοί
end;

Πού κάθεται πραγματικά ο write guard;

Στο χαμηλότερο μεταβαλλόμενο στρώμα, ποτέ στο convenience API από πάνω του. Το _AcquireWriteGuard καλείται από μέσα στο ίδιο το TXLSCellRef.SetValue, που σημαίνει ότι κάθε δημόσια διαδρομή που χωνεύεται σε αυτό — Range.Value, ανάθεση κειμένου φύλλου, αντιγραφή κελί-κελί, επικόλληση — φράσσεται μία φορά αντί κάθε wrapper να επαναλαμβάνει έλεγχο που ένας μελλοντικός wrapper θα ξεχάσει. Η κάλυψη είναι σκόπιμα ευρεία: 55 αποκτήσεις guard στο lxHandle.pas και 37 στο lxHandleX.pas από τη δέσμη που εισήγαγε τον πυρήνα

Η φρασμένη επιφάνεια απλώνεται σε τιμές και μορφοποίηση κελιών, TXLSWorkbook.Open, αντιγραφή και επικόλληση, καθορισμένα ονόματα (Add, μετονομασία, RefersTo, Visible, IsMacro, Comment, Delete), μεταδεδομένα φύλλου όπως Name, Zoom, Visible, StandardHeight, FreezePanes, Protect και Activate, page setup, αλλαγές σελίδας και Calculate. Η τοποθέτηση είναι όλο το νόημα: ο guard αποκτείται πριν γραφτεί το πρώτο πεδίο, όχι επαληθεύεται μετά από ένα hook ειδοποίησης, οπότε μια απορριφθείσα μετάλλαξη αφήνει το μοντέλο byte-πανομοιότυπο. Η σουίτα regressions ισχυρίζεται ακριβώς αυτό, ξαναδιαβάζοντας όνομα φύλλου, zoom, ορατότητα, standard height, περιθώρια, προσανατολισμό και πλήθος αλλαγών σελίδας μετά από κάθε απορριφθείσα κλήση. Οι διαδρομές φόρτωσης παίρνουν την ίδια μεταχείριση ένα στρώμα κάτω, όπου η πύλη ανάγνωσης ZIP συντονίζει ταυτόχρονο inflate για μορφές πακέτων

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Αποκτείται πριν αγγιχτεί το πρώτο πεδίο, ποτέ μετά
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Μόνο ένας συμπληρωμένος εξωτερικός guard προωθεί τη γενιά
  WriteGuard.Complete;
end;

Γιατί μια εμφωλευμένη εγγραφή προωθεί τη γενιά μόνο μία φορά;

Επειδή μια συναλλαγή εγγραφής ορίζεται από τον εξωτερικό guard σε ένα thread, όχι από κάθε guard ξεχωριστά. Ο πυρήνας κρατά κατάσταση writer ανά thread που συγκρατεί thread id, βάθος και σημαία ολοκλήρωσης. Ένα δεύτερο AcquireWriteGuard στο ίδιο thread βρίσκει αυτή την κατάσταση και αυξάνει το Depth αντί να δημιουργήσει νέα συναλλαγή, και μόνο όταν το Depth γυρίσει στο μηδέν — με τον εξωτερικό guard να έχει σημειωθεί Complete — προχωρά το FGeneration. Αυτό επιτρέπει σε μια λειτουργία υψηλού επιπέδου όπως το Calculate ή το Open να καλέσει δέκα φρασμένες primitives από κάτω και να καταγραφεί ακόμη ως μία αλλαγή. Οι εσωτερικές κλήσεις Complete καταγράφονται αλλά δεν μετακινούν τον μετρητή μόνες τους, και οι guards μπορούν να απελευθερωθούν εκτός σειράς χωρίς να χαλάσει η λογιστική

Η κατεύθυνση αποτυχίας είναι εξίσου ρητή. Αν ένας guard απελευθερωθεί χωρίς Complete — η συνηθισμένη συνέπεια μιας εξαίρεσης που ξετυλίγει την αναφορά interface — η γενιά δεν προχωρά, επειδή η συναλλαγή εγγραφής δεν διεκδίκησε ποτέ επιτυχία. Δείτε καθαρά τι σημαίνει αυτό: το HotXLS δεν αναιρεί τη μερική επεξεργασία. Ο μετρητής καταγράφει ότι καμία επιτυχής συναλλαγή δεν ολοκληρώθηκε, που είναι ακριβώς το σήμα που χρειάζεται μια cache, αλλά η επαναφορά του μοντέλου στην προηγούμενη κατάστασή του δεν είναι κάτι που μπορεί να κάνει για εσάς ένας guard με μέτρηση αναφορών. Αν μια αποτυχία μέσο συναλλαγής μπορεί να αφήσει το βιβλίο εργασίας σε σχήμα που δεν μπορείτε να στείλετε, κρατήστε το αρχείο πηγής και ξανανοίξτε το, αντί να εμπιστευτείτε το αντικείμενο στη μνήμη

Δύο χρονοδιαγράμματα συναλλαγής εγγραφής HotXLS σε σύγκριση: εμφωλευμένοι guards σε ένα thread αυξάνουν το βάθος και προωθούν τον μετρητή γενιάς μόνο όταν ο εξωτερικός guard συμπληρωθεί, ενώ μια εξαίρεση που ξετυλίγει τους guards χωρίς Complete αφήνει τη γενιά αμετάβλητη και τη μερική επεξεργασία στη θέση της
Το Depth παρακολουθεί την εμφώλευση, αλλά μόνο μια συμπληρωμένη εξωτερική συναλλαγή προωθεί τη γενιά, και μια ματαιωμένη αφήνει τον μετρητή και τη μερική επεξεργασία ακριβώς εκεί που ήταν

Τι σας αγοράζει ο μετρητής γενιάς

Φθηνή ανίχνευση παρωχημένων χωρίς σάρωση. Το Generation είναι ένα UInt64 που ξεκινά από 1 και προσπερνά το 0 σε αναδίπλωση, οπότε το 0 δεν είναι ποτέ τιμή που εκδίδει ο πυρήνας και δουλεύει ως αξιόπιστος φρουρός "ποτέ παρατηρημένο". Δύο αμετάβλητα το κάνουν χρήσιμο: η γενιά δεν μπορεί να κινηθεί ενώ υπάρχει οποιοδήποτε read lease, και κάθε επιτυχής συναλλαγή εγγραφής το αυξάνει ακριβώς μία φορά. Έτσι το IXLSWorkbookReadLease.Generation είναι στιγμιότυπο που μένει σταθερό για όλη τη ζωή του lease, και το IXLSWorkbookWriteGuard.StartGeneration λέει σε έναν writer πώς έμοιαζε το μοντέλο όταν άνοιξε η συναλλαγή του. Ένα grid, μια προεπισκόπηση εκτύπωσης ή ένας παράγωγος δείκτης μπορούν να συγκρίνουν έναν ακέραιο αντί να συγκρίνουν γραμμές

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // Το FCachedGeneration ξεκινά από 0, τιμή που ο πυρήνας δεν εκδίδει ποτέ,
  // ώστε το πρώτο πέρασμα να ξαναχτίζει πάντα
end;

Τι δεν υπόσχεται αυτός ο συντονισμός

Τρία όρια αξίζουν να δηλωθούν ευθέως, επειδή η υπόθεση του αντίθετου είναι ο τρόπος με τον οποίο καταχράται ο μηχανισμός. Πρώτον, ένας write guard δεν είναι αμοιβαίος αποκλεισμός μεταξύ writers: ο πυρήνας αποκλείει αναγνώστες εναντίον writers, και δύο διαφορετικά threads μπορούν το καθένα να κρατά write guard ταυτόχρονα, το καθένα προωθώντας τη γενιά ανεξάρτητα — ένα regression test ισχυρίζεται ακριβώς αυτή τη συμπεριφορά. Η σειριοποίηση των δικών σας writer threads παραμένει δική σας δουλειά. Δεύτερον, τίποτα εδώ δεν είναι file lock ή cross-process mutex· συντονίζει threads μέσα σε μία διεργασία εναντίον ενός στιγμιοτύπου βιβλίου εργασίας, και δύο διεργασίες που ανοίγουν το ίδιο .xlsx δεν ξέρουν τίποτα η μία για την άλλη. Τρίτον, η εγγύηση φτάνει μόνο σε καλούντες που πραγματικά παίρνουν lease — μια ανάγνωση χωρίς lease διασχίζει ακόμη μια ξεκλείδωτη hot path, που είναι γρήγορη και εντελώς απροστάτευτη. Αυτός είναι πυρήνας συντονισμού, όχι συναλλαγματική βάση δεδομένων

Χρησιμοποιημένος μέσα σε αυτά τα όρια είναι μια μικρή, ειλικρινής primitive: εννιά dedicated regression tests καλύπτουν πολλαπλούς αναγνώστες, και τις δύο κατευθύνσεις σύγκρουσης, reentrancy, απελευθέρωση εκτός σειράς, ματαιωμένες συναλλαγές, και αγώνες cross-thread read/write και write/write, μέσα σε σουίτα 1.328 tests που περνούν σε Win32 και Win64. Ζευγαρώστε το με τη διαδρομή αποθήκευσης crash-safe με staged temp-file και μια εξαγωγή στο background γίνεται κάτι που μπορείς να συλλογιστεί από άκρη σε άκρη — συνεπές όσο διαβάζει, ατομικό όταν γράφει. Τα read leases, οι write guards και ο μετρητής γενιάς έρχονται ως μέρος της κλασικής και της μηχανής πακέτων στο HotXLS Delphi Component για Delphi και C++Builder, χωρίς καμία διαμόρφωση για να ενεργοποιηθούν