Προγραμματισμός

Αποφύγετε τα αδιέξοδα συγχρονισμού

Στο προηγούμενο άρθρο μου "Double-Checked Locking: Clever, but Broken" (JavaWorld, Φεβρουάριος 2001), περιέγραψα πώς πολλές κοινές τεχνικές για την αποφυγή του συγχρονισμού είναι στην πραγματικότητα ανασφαλείς και πρότεινα μια στρατηγική «Σε περίπτωση αμφιβολίας, συγχρονίστε». Σε γενικές γραμμές, θα πρέπει να συγχρονίζετε κάθε φορά που διαβάζετε οποιαδήποτε μεταβλητή που μπορεί να έχει γραφτεί προηγουμένως από διαφορετικό νήμα ή όποτε γράφετε οποιαδήποτε μεταβλητή που ενδέχεται να διαβαστεί στη συνέχεια από άλλο νήμα. Επιπλέον, ενώ ο συγχρονισμός φέρει ποινή απόδοσης, η ποινή που σχετίζεται με τον ανεξέλεγκτο συγχρονισμό δεν είναι τόσο μεγάλη όσο έχουν προτείνει ορισμένες πηγές και έχει μειωθεί σταθερά με κάθε διαδοχική υλοποίηση του JVM. Φαίνεται λοιπόν ότι υπάρχει τώρα λιγότερος λόγος από ποτέ να αποφευχθεί ο συγχρονισμός. Ωστόσο, ένας άλλος κίνδυνος σχετίζεται με υπερβολικό συγχρονισμό: αδιέξοδο.

Τι είναι το αδιέξοδο;

Λέμε ότι είναι ένα σύνολο διαδικασιών ή νημάτων αδιέξοδο όταν κάθε νήμα περιμένει ένα συμβάν που μπορεί να προκαλέσει μόνο μια άλλη διαδικασία στο σετ. Ένας άλλος τρόπος για να απεικονιστεί ένα αδιέξοδο είναι να δημιουργήσετε ένα κατευθυνόμενο γράφημα του οποίου οι κορυφές είναι νήματα ή διαδικασίες και των οποίων τα άκρα αντιπροσωπεύουν τη σχέση «είναι-αναμονή». Εάν αυτό το γράφημα περιέχει έναν κύκλο, το σύστημα είναι αδιέξοδο. Εάν το σύστημα δεν έχει σχεδιαστεί για ανάκτηση από αδιέξοδα, ένα αδιέξοδο προκαλεί την αναστολή του προγράμματος ή του συστήματος.

Αδιέξοδα συγχρονισμού σε προγράμματα Java

Τα αδιέξοδα μπορούν να προκύψουν στην Java επειδή το συγχρονισμένος Η λέξη-κλειδί αναγκάζει το νήμα εκτέλεσης να μπλοκάρει ενώ περιμένει το κλείδωμα ή την οθόνη που σχετίζεται με το καθορισμένο αντικείμενο. Δεδομένου ότι το νήμα μπορεί να έχει ήδη κλειδαριές που σχετίζονται με άλλα αντικείμενα, δύο νήματα θα μπορούσαν το καθένα να περιμένουν το άλλο να απελευθερώσει μια κλειδαριά. σε μια τέτοια περίπτωση, θα καταλήξουν να περιμένουν για πάντα. Το ακόλουθο παράδειγμα δείχνει ένα σύνολο μεθόδων που έχουν τη δυνατότητα για αδιέξοδο. Και οι δύο μέθοδοι αποκτούν κλειδαριές σε δύο αντικείμενα κλειδώματος, cacheLock και tableLock, προτού προχωρήσουν. Σε αυτό το παράδειγμα, τα αντικείμενα που λειτουργούν ως κλειδαριές είναι καθολικές (στατικές) μεταβλητές, μια κοινή τεχνική για την απλοποίηση της συμπεριφοράς κλειδώματος εφαρμογών εκτελώντας το κλείδωμα σε πιο χονδροειδές επίπεδο κοκκώδους:

Λίστα 1. Ένα πιθανό αδιέξοδο συγχρονισμού

 δημόσιο στατικό αντικείμενο cacheLock = νέο αντικείμενο (); public static Object tableLock = νέο αντικείμενο (); ... public void oneMethod () {synchronized (cacheLock) {synchronized (tableLock) {doSomething (); }}} public void anotherMethod () {synchronized (tableLock) {synchronized (cacheLock) {doSomethingElse (); }}} 

Τώρα, φανταστείτε ότι το νήμα Α καλεί μέθοδος () ενώ το νήμα Β καλεί ταυτόχρονα άλλη μέθοδος (). Φανταστείτε περαιτέρω ότι το νήμα Α αποκτά την κλειδαριά cacheLockκαι, ταυτόχρονα, το νήμα Β αποκτά το κλείδωμα tableLock. Τώρα τα νήματα είναι αδιέξοδο: κανένα νήμα δεν θα εγκαταλείψει την κλειδαριά του έως ότου αποκτήσει το άλλο κλείδωμα, αλλά κανένα δεν θα μπορεί να αποκτήσει το άλλο κλείδωμα έως ότου το άλλο νήμα το εγκαταλείψει. Όταν ένα πρόγραμμα Java αδιέξοδο, τα νήματα αδιέξοδο απλώς περιμένουν για πάντα. Ενώ άλλα νήματα ενδέχεται να συνεχίσουν να εκτελούνται, θα πρέπει τελικά να σκοτώσετε το πρόγραμμα, να το επανεκκινήσετε και να ελπίζετε ότι δεν θα αδιέξοδο ξανά.

Η δοκιμή για αδιέξοδα είναι δύσκολη, καθώς τα αδιέξοδα εξαρτώνται από το χρόνο, το φορτίο και το περιβάλλον, και επομένως μπορεί να συμβούν σπάνια ή μόνο υπό ορισμένες συνθήκες. Ο κώδικας μπορεί να έχει τη δυνατότητα για αδιέξοδο, όπως η Λίστα 1, αλλά δεν εμφανίζει αδιέξοδο έως ότου εμφανιστεί κάποιος συνδυασμός τυχαίων και μη τυχαίων συμβάντων, όπως το πρόγραμμα που υπόκειται σε ένα ορισμένο επίπεδο φόρτωσης, εκτελείται σε μια συγκεκριμένη διαμόρφωση υλικού ή εκτίθεται σε ένα συγκεκριμένο συνδυασμός ενεργειών χρήστη και περιβαλλοντικών συνθηκών. Τα αδιέξοδα μοιάζουν με ωρολογιακές βόμβες που περιμένουν να εκραγούν στον κώδικά μας. όταν το κάνουν, τα προγράμματά μας απλώς κρέμονται.

Η ασυνεπής παραγγελία κλειδώματος προκαλεί αδιέξοδα

Ευτυχώς, μπορούμε να επιβάλουμε μια σχετικά απλή απαίτηση στην απόκτηση κλειδώματος που μπορεί να αποτρέψει τα αδιέξοδα συγχρονισμού. Οι μέθοδοι της λίστας 1 έχουν τη δυνατότητα για αδιέξοδο, επειδή κάθε μέθοδος αποκτά τις δύο κλειδαριές με διαφορετική σειρά. Εάν η Καταχώριση 1 είχε γραφτεί έτσι ώστε κάθε μέθοδος να αποκτήσει τις δύο κλειδαριές με την ίδια σειρά, δύο ή περισσότερα νήματα που εκτελούν αυτές τις μεθόδους δεν θα μπορούσαν να αδιέξοδο, ανεξάρτητα από το χρονισμό ή άλλους εξωτερικούς παράγοντες, επειδή κανένα νήμα δεν θα μπορούσε να αποκτήσει το δεύτερο κλείδωμα χωρίς να έχει ήδη κρατήσει το πρώτα. Εάν μπορείτε να εγγυηθείτε ότι οι κλειδαριές θα αποκτώνται πάντα με συνεπή σειρά, τότε το πρόγραμμά σας δεν θα έχει αδιέξοδο.

Τα αδιέξοδα δεν είναι πάντα τόσο προφανή

Μόλις προσαρμοστεί στη σημασία της παραγγελίας κλειδώματος, μπορείτε εύκολα να αναγνωρίσετε το πρόβλημα της καταχώρισης 1. Ωστόσο, ανάλογα προβλήματα μπορεί να αποδειχθούν λιγότερο προφανή: ίσως οι δύο μέθοδοι βρίσκονται σε ξεχωριστές τάξεις, ή ίσως οι εμπλεκόμενες κλειδαριές αποκτώνται σιωπηρά μέσω κλήσης συγχρονισμένων μεθόδων αντί για ρητά μέσω συγχρονισμένου μπλοκ. Εξετάστε αυτές τις δύο συνεργαζόμενες τάξεις, Μοντέλο και Θέα, σε ένα απλοποιημένο πλαίσιο MVC (Model-View-Controller):

Λίστα 2. Ένα πιο λεπτό αδιέξοδο συγχρονισμού

 δημόσια τάξη Μοντέλο {private View myView; δημόσιο συγχρονισμένο void updateModel (Object someArg) {doSomething (someArg); myView.somethingChanged (); } δημόσιο συγχρονισμένο αντικείμενο getSomething () {return someMethod (); }} προβολή δημόσιας τάξης {ιδιωτικό μοντέλο underlyingModel; δημόσιο συγχρονισμένο άκυρο κάτιChanged () {doSomething (); } δημόσιο συγχρονισμένο void updateView () {Object o = myModel.getSomething (); }} 

Η Λίστα 2 έχει δύο συνεργαζόμενα αντικείμενα που έχουν συγχρονισμένες μεθόδους. κάθε αντικείμενο καλεί τις συγχρονισμένες μεθόδους του άλλου. Αυτή η κατάσταση μοιάζει με την Καταχώριση 1 - δύο μέθοδοι αποκτούν κλειδαριές στα ίδια δύο αντικείμενα, αλλά σε διαφορετικές παραγγελίες. Ωστόσο, η ασυνεπής εντολή κλειδώματος σε αυτό το παράδειγμα είναι πολύ λιγότερο εμφανής από αυτήν της λίστας 1 επειδή η απόκτηση κλειδαριάς είναι ένα έμμεσο μέρος της κλήσης μεθόδου. Εάν καλεί ένα νήμα Model.updateModel () ενώ ένα άλλο νήμα καλεί ταυτόχρονα View.updateView (), το πρώτο νήμα θα μπορούσε να αποκτήσει το Μοντέλοκλειδαριά και περιμένετε το Θέακλειδαριά, ενώ ο άλλος αποκτά το Θέακλειδαριά και περιμένει για πάντα το Μοντέλοκλειδαριά.

Μπορείτε να θάψετε τη δυνατότητα για αδιέξοδο συγχρονισμού ακόμη πιο βαθιά. Εξετάστε αυτό το παράδειγμα: Έχετε μια μέθοδο μεταφοράς χρημάτων από έναν λογαριασμό σε έναν άλλο. Θέλετε να αποκτήσετε κλειδαριές και στους δύο λογαριασμούς πριν από την εκτέλεση της μεταφοράς για να βεβαιωθείτε ότι η μεταφορά είναι ατομική. Εξετάστε αυτήν την αβλαβή εφαρμογή:

Λίστα 3. Ένα ακόμη πιο λεπτό αδιέξοδο συγχρονισμού

 public void transferMoney (Account fromAccount, Account toAccount, DollarAmount ποσόToTransfer) {synchronized (fromAccount) {synchronized (toAccount) {if (fromAccount.hasSufficientBalance (numberToTransfer) {fromAccount.debit (ποσόToTransfer)} προςAccount} } 

Ακόμα κι αν όλες οι μέθοδοι που λειτουργούν σε δύο ή περισσότερους λογαριασμούς χρησιμοποιούν την ίδια παραγγελία, η Λίστα 3 περιέχει τους σπόρους του ίδιου προβλήματος αδιεξόδου με τις καταχωρίσεις 1 και 2, αλλά με έναν ακόμη πιο λεπτό τρόπο. Σκεφτείτε τι συμβαίνει όταν το νήμα Α εκτελεί:

 transferMoney (accountOne, accountTwo, ποσό); 

Ενώ ταυτόχρονα, το νήμα Β εκτελεί:

 transferMoney (accountTwo, accountOne, anotherAmount); 

Και πάλι, τα δύο νήματα προσπαθούν να αποκτήσουν τις ίδιες δύο κλειδαριές, αλλά με διαφορετικές παραγγελίες. ο κίνδυνος αδιεξόδου εξακολουθεί να υφίσταται, αλλά σε πολύ λιγότερο εμφανή μορφή.

Πώς να αποφύγετε τα αδιέξοδα

Ένας από τους καλύτερους τρόπους για να αποφευχθεί το ενδεχόμενο αδιεξόδου είναι να αποφύγετε την απόκτηση περισσότερων από ένα κλειδαριών κάθε φορά, κάτι που είναι συχνά πρακτικό. Ωστόσο, εάν αυτό δεν είναι δυνατό, χρειάζεστε μια στρατηγική που θα σας εξασφαλίζει την απόκτηση πολλαπλών κλειδαριών με συνεπή και καθορισμένη σειρά.

Ανάλογα με τον τρόπο με τον οποίο το πρόγραμμά σας χρησιμοποιεί κλειδαριές, ενδέχεται να μην είναι περίπλοκο να διασφαλίσετε ότι χρησιμοποιείτε μια συνεπή σειρά κλειδώματος. Σε ορισμένα προγράμματα, όπως στην Καταχώριση 1, όλες οι κρίσιμες κλειδαριές που ενδέχεται να συμμετέχουν σε πολλαπλό κλείδωμα προέρχονται από ένα μικρό σύνολο αντικειμένων κλειδώματος singleton. Σε αυτήν την περίπτωση, μπορείτε να ορίσετε μια παραγγελία κλειδώματος κλειδώματος στο σύνολο κλειδαριών και να διασφαλίσετε ότι αποκτάτε πάντα κλειδαριές με αυτήν τη σειρά. Μόλις καθοριστεί η σειρά κλειδώματος, πρέπει απλώς να τεκμηριωθεί καλά για να ενθαρρύνει τη συνεπή χρήση σε όλο το πρόγραμμα.

Συρρίκνωση συγχρονισμένων μπλοκ για αποφυγή πολλαπλού κλειδώματος

Στην Λίστα 2, το πρόβλημα γίνεται πιο περίπλοκο, επειδή, ως αποτέλεσμα της κλήσης μιας συγχρονισμένης μεθόδου, οι κλειδαριές αποκτώνται σιωπηρά. Συνήθως μπορείτε να αποφύγετε το είδος των πιθανών αδιεξόδων που προκύπτουν από περιπτώσεις όπως η Λίστα 2, περιορίζοντας το πεδίο του συγχρονισμού σε όσο το δυνατόν μικρότερο μπλοκ. Κάνει Model.updateModel () πραγματικά πρέπει να κρατήσετε το Μοντέλο κλειδώστε ενώ καλεί View.somethingChanged (); Συχνά δεν συμβαίνει. ολόκληρη η μέθοδος πιθανότατα συγχρονίστηκε ως συντόμευση, παρά επειδή ολόκληρη η μέθοδος έπρεπε να συγχρονιστεί. Ωστόσο, εάν αντικαταστήσετε τις συγχρονισμένες μεθόδους με μικρότερα συγχρονισμένα μπλοκ μέσα στη μέθοδο, πρέπει να τεκμηριώσετε αυτήν τη συμπεριφορά κλειδώματος ως μέρος του Javadoc της μεθόδου. Οι καλούντες πρέπει να γνωρίζουν ότι μπορούν να καλέσουν τη μέθοδο με ασφάλεια χωρίς εξωτερικό συγχρονισμό. Οι καλούντες πρέπει επίσης να γνωρίζουν τη συμπεριφορά κλειδώματος της μεθόδου, ώστε να μπορούν να διασφαλίσουν ότι οι κλειδαριές αποκτώνται με συνεπή σειρά.

Μια πιο εξελιγμένη τεχνική κλειδώματος παραγγελίας

Σε άλλες περιπτώσεις, όπως το παράδειγμα τραπεζικού λογαριασμού της Καταχώρισης 3, η εφαρμογή του κανόνα σταθερής παραγγελίας γίνεται ακόμη πιο περίπλοκη. πρέπει να ορίσετε μια συνολική παραγγελία στο σύνολο αντικειμένων που είναι κατάλληλα για κλείδωμα και να χρησιμοποιήσετε αυτήν την παραγγελία για να επιλέξετε την ακολουθία της απόκτησης κλειδώματος. Αυτό ακούγεται ακατάστατο, αλλά στην πραγματικότητα είναι απλό. Η λίστα 4 απεικονίζει αυτήν την τεχνική. χρησιμοποιεί έναν αριθμητικό αριθμό λογαριασμού για να προκαλέσει μια παραγγελία λογαριασμός αντικείμενα. (Εάν το αντικείμενο που πρέπει να κλειδώσετε δεν διαθέτει ιδιότητα φυσικής ταυτότητας όπως έναν αριθμό λογαριασμού, μπορείτε να χρησιμοποιήσετε το Object.identityHashCode () μέθοδος για τη δημιουργία ενός.)

Λίστα 4. Χρησιμοποιήστε μια παραγγελία για να αποκτήσετε κλειδαριές σε μια σταθερή ακολουθία

 public void transferMoney (Λογαριασμός απόAccount, Account toAccount, DollarAmount ποσόToTransfer) {Account firstLock, secondLock; εάν (fromAccount.accountNumber () == toAccount.accountNumber ()) ρίξτε νέα εξαίρεση ("Δεν είναι δυνατή η μεταφορά από λογαριασμό στον εαυτό του"); αλλιώς εάν (fromAccount.accountNumber () <toAccount.accountNumber ()) {firstLock = fromAccount; secondLock = toAccount; } αλλιώς {firstLock = toAccount; secondLock = fromAccount; } synchronized (firstLock) {synchronized (secondLock) {if (fromAccount.hasSufficientBalance (ποσόToTransfer) {fromAccount.debit (numberToTransfer); toAccount.credit (ποσόToTransfer);}}}}} 

Τώρα η σειρά με την οποία καθορίζονται οι λογαριασμοί στην κλήση προς μεταφορά χρημάτων () δεν έχει σημασία? οι κλειδαριές αποκτώνται πάντα με την ίδια σειρά.

Το πιο σημαντικό μέρος: Τεκμηρίωση

Ένα κρίσιμο - αλλά συχνά παραβλέπεται - στοιχείο οποιασδήποτε στρατηγικής κλειδώματος είναι η τεκμηρίωση. Δυστυχώς, ακόμη και σε περιπτώσεις όπου απαιτείται μεγάλη προσοχή στο σχεδιασμό μιας στρατηγικής κλειδώματος, συχνά καταβάλλεται πολύ λιγότερη προσπάθεια για την τεκμηρίωσή της. Εάν το πρόγραμμά σας χρησιμοποιεί ένα μικρό σύνολο κλειδαριών singleton, θα πρέπει να τεκμηριώσετε τις παραδοχές παραγγελίας κλειδώματος όσο το δυνατόν σαφέστερα, έτσι ώστε οι μελλοντικοί συντηρητές να μπορούν να πληρούν τις απαιτήσεις παραγγελίας κλειδώματος. Εάν μια μέθοδος πρέπει να αποκτήσει ένα κλείδωμα για να εκτελέσει τη λειτουργία της ή πρέπει να καλείται με ένα συγκεκριμένο κλείδωμα, το Javadoc της μεθόδου θα πρέπει να σημειώσει αυτό το γεγονός. Με αυτόν τον τρόπο, οι μελλοντικοί προγραμματιστές θα γνωρίζουν ότι η κλήση μιας δεδομένης μεθόδου μπορεί να συνεπάγεται την απόκτηση κλειδαριάς.

Λίγα προγράμματα ή βιβλιοθήκες τάξης τεκμηριώνουν επαρκώς τη χρήση κλειδώματος. Τουλάχιστον, κάθε μέθοδος πρέπει να τεκμηριώνει τις κλειδαριές που αποκτά και εάν οι καλούντες πρέπει να κρατούν κλειδαριά για να καλέσουν τη μέθοδο με ασφάλεια. Επιπλέον, τα μαθήματα πρέπει να τεκμηριώνουν εάν ή όχι, ή υπό ποιες συνθήκες, είναι ασφαλή για νήματα.

Εστίαση στη συμπεριφορά κλειδώματος κατά το σχεδιασμό

Επειδή τα αδιέξοδα συχνά δεν είναι προφανή και συμβαίνουν σπάνια και απρόβλεπτα, μπορούν να προκαλέσουν σοβαρά προβλήματα στα προγράμματα Java. Δίνοντας προσοχή στη συμπεριφορά κλειδώματος του προγράμματος σας κατά τη διάρκεια του σχεδιασμού και καθορίζοντας κανόνες για το πότε και πώς να αποκτήσετε πολλαπλές κλειδαριές, μπορείτε να μειώσετε σημαντικά την πιθανότητα αδιεξόδων. Θυμηθείτε να τεκμηριώσετε προσεκτικά τους κανόνες απόκτησης κλειδώματος του προγράμματος και τη χρήση του συγχρονισμού προσεκτικά. Ο χρόνος που αφιερώνεται στην τεκμηρίωση των απλών υποθέσεων κλειδώματος θα αποδώσει σημαντικά μειώνοντας αργότερα την πιθανότητα αδιεξόδου και άλλων προβλημάτων ταυτότητας αργότερα.

Ο Brian Goetz είναι επαγγελματίας προγραμματιστής λογισμικού με περισσότερα από 15 χρόνια εμπειρίας. Είναι κύριος σύμβουλος της Quiotix, μιας εταιρείας ανάπτυξης λογισμικού και συμβούλων που βρίσκεται στο Los Altos της Καλιφόρνια.
$config[zx-auto] not found$config[zx-overlay] not found