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

Κλείδωμα διπλού ελέγχου: Έξυπνο, αλλά σπασμένο

Από τον ιδιαίτερα σεβαστό Στοιχεία του στυλ Java στις σελίδες του JavaWorld (βλ. Συμβουλή Java 67), πολλοί καλοί γκουρού Java ενθαρρύνουν τη χρήση του ιδίου διπλού ελέγχου κλειδώματος (DCL). Υπάρχει μόνο ένα πρόβλημα με αυτό - αυτό το έξυπνο ιδίωμα μπορεί να μην λειτουργεί.

Το διπλό έλεγχο κλειδώματος μπορεί να είναι επικίνδυνο για τον κωδικό σας!

Αυτή την εβδομάδα JavaWorld επικεντρώνεται στους κινδύνους του διπλού ελέγχου κλειδώματος ιδιωματισμού. Διαβάστε περισσότερα σχετικά με το πώς αυτή η φαινομενικά ακίνδυνη συντόμευση μπορεί να καταστρέψει τον κώδικά σας:
  • "Προειδοποίηση! Νήμα σε έναν κόσμο πολλαπλών επεξεργαστών", Allen Holub
  • Κλείδωμα διπλού ελέγχου: Έξυπνο, αλλά σπασμένο, "Brian Goetz
  • Για να μιλήσετε περισσότερα σχετικά με το διπλό έλεγχο κλειδώματος, μεταβείτε στο Allen Holub's Συζήτηση Θεωρίας & Πρακτικής Προγραμματισμού

Τι είναι το DCL;

Το ιδίωμα DCL σχεδιάστηκε για να υποστηρίζει την τεμπέλη αρχικοποίηση, η οποία συμβαίνει όταν μια τάξη αποτρέπει την αρχικοποίηση ενός ιδιόκτητου αντικειμένου έως ότου πραγματικά χρειαστεί:

τάξη SomeClass {private Resource resource = null; δημόσιος πόρος getResource () {if (πόρος == null) πόρος = νέος πόρος (); πόρος επιστροφής; }} 

Γιατί θα θέλατε να αναβάλλετε την αρχικοποίηση; Ίσως δημιουργώντας ένα Πόρος είναι μια ακριβή λειτουργία και οι χρήστες του SomeClass μπορεί να μην τηλεφωνήσει getResource () σε οποιαδήποτε δεδομένη εκτέλεση. Σε αυτήν την περίπτωση, μπορείτε να αποφύγετε τη δημιουργία του Πόρος εξ ολοκλήρου. Ανεξάρτητα, το SomeClass το αντικείμενο μπορεί να δημιουργηθεί γρηγορότερα εάν δεν χρειάζεται επίσης να δημιουργήσει ένα Πόρος κατά την κατασκευή. Η καθυστέρηση ορισμένων λειτουργιών προετοιμασίας έως ότου ένας χρήστης πραγματικά χρειαστεί τα αποτελέσματά του μπορεί να βοηθήσει τα προγράμματα να ξεκινήσουν γρηγορότερα.

Τι γίνεται αν προσπαθήσετε να χρησιμοποιήσετε SomeClass σε μια εφαρμογή πολλαπλών νημάτων; Στη συνέχεια προκύπτει μια κατάσταση αγώνα: δύο νήματα θα μπορούσαν ταυτόχρονα να εκτελέσουν τη δοκιμή για να δουν αν πόρος είναι μηδενική και, ως εκ τούτου, αρχικοποιείται πόρος εις διπλούν. Σε ένα περιβάλλον πολλαπλών νημάτων, θα πρέπει να δηλώσετε getResource () να είναι συγχρονισμένος.

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

Η DCL σκοπεύει να μας δώσει το καλύτερο και των δύο κόσμων. Χρησιμοποιώντας το DCL, το getResource () Η μέθοδος θα μοιάζει με αυτήν:

τάξη SomeClass {private Resource resource = null; public Resource getResource () {if (πόρος == null) {συγχρονισμένος {if (πόρος == null) πόρος = νέος πόρος (); }} πόρος επιστροφής; }} 

Μετά την πρώτη κλήση προς getResource (), πόρος έχει ήδη αρχικοποιηθεί, αποφεύγοντας την επίσκεψη συγχρονισμού στην πιο κοινή διαδρομή κώδικα. Το DCL αποτρέπει επίσης την κατάσταση του αγώνα ελέγχοντας πόρος μια δεύτερη φορά μέσα στο συγχρονισμένο μπλοκ? που διασφαλίζει ότι μόνο ένα νήμα θα προσπαθήσει να αρχικοποιήσει πόρος. Το DCL φαίνεται σαν μια έξυπνη βελτιστοποίηση - αλλά δεν λειτουργεί.

Γνωρίστε το Java Memory Model

Ακριβέστερα, το DCL δεν είναι εγγυημένο ότι λειτουργεί. Για να καταλάβουμε γιατί, πρέπει να εξετάσουμε τη σχέση μεταξύ του JVM και του περιβάλλοντος υπολογιστή στο οποίο λειτουργεί. Συγκεκριμένα, πρέπει να εξετάσουμε το Java Memory Model (JMM), που ορίζεται στο Κεφάλαιο 17 του Προδιαγραφή γλώσσας Java, από τους Bill Joy, Guy Steele, James Gosling και Gilad Bracha (Addison-Wesley, 2000), που περιγράφει λεπτομερώς τον τρόπο με τον οποίο η Java χειρίζεται την αλληλεπίδραση μεταξύ νημάτων και μνήμης.

Σε αντίθεση με τις περισσότερες άλλες γλώσσες, η Java ορίζει τη σχέση της με το υποκείμενο υλικό μέσω ενός τυπικού μοντέλου μνήμης που αναμένεται να διατηρηθεί σε όλες τις πλατφόρμες Java, επιτρέποντας την υπόσχεση της Java για "Γράψε μια φορά, εκτελέστε οπουδήποτε" Συγκριτικά, άλλες γλώσσες όπως τα C και C ++ δεν διαθέτουν επίσημο μοντέλο μνήμης. Σε τέτοιες γλώσσες, τα προγράμματα κληρονομούν το μοντέλο μνήμης της πλατφόρμας υλικού στην οποία εκτελείται το πρόγραμμα.

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

Στην πραγματικότητα, η αλήθεια είναι αρκετά διαφορετική, αλλά μια περίπλοκη ψευδαίσθηση που διατηρείται από τον μεταγλωττιστή, το JVM και το υλικό την κρύβει από εμάς. Αν και πιστεύουμε ότι τα προγράμματα εκτελούνται διαδοχικά - με τη σειρά που καθορίζεται από τον κώδικα προγράμματος - αυτό δεν συμβαίνει πάντα. Οι μεταγλωττιστές, οι επεξεργαστές και οι κρυφές μνήμες είναι ελεύθεροι να πάρουν κάθε είδους ελευθερία με τα προγράμματα και τα δεδομένα μας, αρκεί να μην επηρεάζουν το αποτέλεσμα του υπολογισμού. Για παράδειγμα, οι μεταγλωττιστές μπορούν να δημιουργήσουν οδηγίες με διαφορετική σειρά από την προφανή ερμηνεία που προτείνει το πρόγραμμα και να αποθηκεύσουν μεταβλητές σε καταχωρητές αντί για μνήμη. οι επεξεργαστές μπορούν να εκτελούν οδηγίες παράλληλα ή εκτός λειτουργίας. και οι κρυφές μνήμες ενδέχεται να διαφέρουν με τη σειρά με την οποία γράφει δεσμεύονται στην κύρια μνήμη. Η JMM λέει ότι όλες αυτές οι διάφορες αναδιατάξεις και βελτιστοποιήσεις είναι αποδεκτές, αρκεί να διατηρεί το περιβάλλον σαν-εάν-σειριακό σημασιολογία - δηλαδή, αρκεί να επιτύχετε το ίδιο αποτέλεσμα που θα έχετε εάν οι οδηγίες εκτελέστηκαν σε ένα αυστηρά διαδοχικό περιβάλλον.

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

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

Τι σημαίνει πραγματικά το συγχρονισμένο;

Η Java αντιμετωπίζει κάθε νήμα σαν να τρέχει στον δικό του επεξεργαστή με τη δική του τοπική μνήμη, ο καθένας μιλάει και συγχρονίζεται με μια κοινή κύρια μνήμη. Ακόμη και σε ένα σύστημα επεξεργαστή, αυτό το μοντέλο έχει νόημα λόγω των επιπτώσεων των μνήμης cache και της χρήσης καταχωρητών επεξεργαστή για την αποθήκευση μεταβλητών. Όταν ένα νήμα τροποποιεί μια θέση στην τοπική του μνήμη, αυτή η τροποποίηση θα πρέπει τελικά να εμφανίζεται και στην κύρια μνήμη και το JMM καθορίζει τους κανόνες για το πότε το JVM πρέπει να μεταφέρει δεδομένα μεταξύ τοπικής και κύριας μνήμης. Οι αρχιτέκτονες Java συνειδητοποίησαν ότι ένα υπερβολικά περιοριστικό μοντέλο μνήμης θα υπονόμευε σοβαρά την απόδοση του προγράμματος. Προσπάθησαν να δημιουργήσουν ένα μοντέλο μνήμης που θα επέτρεπε στα προγράμματα να έχουν καλή απόδοση σε σύγχρονο υλικό υπολογιστή, παρέχοντας παράλληλα εγγυήσεις που θα επέτρεπαν στα νήματα να αλληλεπιδρούν με προβλέψιμους τρόπους.

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

Η σημασιολογία του συγχρονισμένος περιλαμβάνουν όντως τον αμοιβαίο αποκλεισμό της εκτέλεσης βάσει της κατάστασης ενός σηματοφόρου, αλλά περιλαμβάνουν επίσης κανόνες σχετικά με την αλληλεπίδραση του συγχρονισμένου νήματος με την κύρια μνήμη. Συγκεκριμένα, η απόκτηση ή η απελευθέρωση μιας κλειδαριάς ενεργοποιεί ένα φράγμα μνήμης - αναγκαστικός συγχρονισμός μεταξύ της τοπικής μνήμης του νήματος και της κύριας μνήμης. (Ορισμένοι επεξεργαστές - όπως το Alpha - έχουν ρητές οδηγίες μηχανής για την εκτέλεση φραγμών μνήμης.) Όταν ένα νήμα βγαίνει α συγχρονισμένος μπλοκ, εκτελεί ένα φράγμα εγγραφής - πρέπει να ξεπλένει τυχόν μεταβλητές που έχουν τροποποιηθεί σε αυτό το μπλοκ στην κύρια μνήμη πριν απελευθερώσει το κλείδωμα. Ομοίως, όταν εισάγετε ένα συγχρονισμένος μπλοκ, εκτελεί φραγμό ανάγνωσης - είναι σαν να έχει ακυρωθεί η τοπική μνήμη και πρέπει να ανακτήσει τυχόν μεταβλητές που θα αναφέρονται στο μπλοκ από την κύρια μνήμη.

Η σωστή χρήση του συγχρονισμού εγγυάται ότι ένα νήμα θα δει τα αποτελέσματα ενός άλλου με προβλέψιμο τρόπο. Μόνο όταν τα νήματα Α και Β συγχρονίζονται στο ίδιο αντικείμενο, το JMM εγγυάται ότι το νήμα Β βλέπει τις αλλαγές που γίνονται από το νήμα Α και ότι οι αλλαγές που γίνονται από το νήμα Α μέσα στο συγχρονισμένος εμφανίζεται το μπλοκ ατομικά στο νήμα Β (είτε εκτελείται ολόκληρο το μπλοκ ή κανένα από αυτά.) Επιπλέον, το JMM το διασφαλίζει αυτό συγχρονισμένος μπλοκ που συγχρονίζονται στο ίδιο αντικείμενο φαίνεται να εκτελούνται με την ίδια σειρά όπως και στο πρόγραμμα.

Τι λείπει λοιπόν το DCL;

Το DCL βασίζεται σε μια μη συγχρονισμένη χρήση του πόρος πεδίο. Αυτό φαίνεται να είναι ακίνδυνο, αλλά δεν είναι. Για να δείτε γιατί, φανταστείτε ότι το νήμα Α βρίσκεται μέσα στο συγχρονισμένος μπλοκ, εκτελώντας τη δήλωση πόρος = νέος πόρος (); ενώ το νήμα Β μόλις μπαίνει getResource (). Εξετάστε την επίδραση στη μνήμη αυτής της αρχικοποίησης. Μνήμη για το νέο Πόρος θα διατεθεί αντικείμενο? ο κατασκευαστής για Πόρος θα κληθεί, αρχικοποιώντας τα πεδία μέλους του νέου αντικειμένου. και το πεδίο πόρος του SomeClass θα εκχωρηθεί μια αναφορά στο νέο αντικείμενο.

Ωστόσο, δεδομένου ότι το νήμα Β δεν εκτελείται μέσα σε ένα συγχρονισμένος μπλοκ, μπορεί να δει αυτές τις λειτουργίες μνήμης με διαφορετική σειρά από εκείνη που εκτελεί το ένα νήμα Α. Θα μπορούσε να συμβαίνει ότι ο Β βλέπει αυτά τα συμβάντα με την ακόλουθη σειρά (και ο μεταγλωττιστής είναι επίσης ελεύθερος να αναδιατάξει τις οδηγίες όπως αυτό): εκχώρηση μνήμης, εκχώρηση αναφοράς σε πόρος, κατασκευαστής κλήσεων. Ας υποθέσουμε ότι το νήμα Β έρχεται μετά την κατανομή της μνήμης και το πόρος έχει οριστεί το πεδίο, αλλά πριν από την κλήση του κατασκευαστή. Το βλέπει αυτό πόρος δεν είναι μηδενικό, παραλείπει το συγχρονισμένος μπλοκ και επιστρέφει μια αναφορά σε ένα μερικώς κατασκευασμένο Πόρος! Περιττό να πούμε ότι το αποτέλεσμα δεν είναι ούτε αναμενόμενο ούτε επιθυμητό.

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

Άλλοι κίνδυνοι ταυτόχρονης ενσωμάτωσης στο DCL - και σε οποιαδήποτε μη συγχρονισμένη αναφορά στη μνήμη που γράφεται από άλλο νήμα, ακόμη και με αβλαβείς ενδείξεις. Ας υποθέσουμε ότι το νήμα Α έχει ολοκληρώσει την προετοιμασία του Πόρος και βγαίνει από το συγχρονισμένος μπλοκ καθώς μπαίνει το νήμα Β getResource (). Τώρα το Πόρος έχει πλήρως αρχικοποιηθεί και το νήμα Α ξεπλένει την τοπική του μνήμη στην κύρια μνήμη. ο πόροςΤα πεδία ενδέχεται να αναφέρουν άλλα αντικείμενα που είναι αποθηκευμένα στη μνήμη μέσω των πεδίων μελών της, τα οποία επίσης θα εξαφανιστούν. Ενώ το νήμα Β μπορεί να δει μια έγκυρη αναφορά στο πρόσφατα δημιουργημένο Πόρος, επειδή δεν πραγματοποίησε εμπόδιο ανάγνωσης, θα μπορούσε ακόμα να δει τις παλιές τιμές του πόροςπεδία μέλους.

Το Volatile δεν σημαίνει αυτό που νομίζετε

Μια συνήθως προτεινόμενη μη επιδιόρθωση είναι να δηλώσετε το πόρος πεδίο της SomeClass όπως και πτητικός. Ωστόσο, ενώ το JMM εμποδίζει την αναδιάταξη των εγγραφών σε πτητικές μεταβλητές σε σχέση μεταξύ τους και διασφαλίζει ότι μεταφέρονται αμέσως στην κύρια μνήμη, εξακολουθεί να επιτρέπει την αναδιάταξη των αναγνωστών και των εγγραφών πτητικών μεταβλητών σε σχέση με τις μη πτητικές αναγνώσεις και εγγραφές. Αυτό σημαίνει - εκτός αν όλα Πόρος τα πεδία είναι πτητικός επίσης - το νήμα Β μπορεί ακόμα να αντιληφθεί το αποτέλεσμα του κατασκευαστή όπως συμβαίνει μετά πόρος έχει ρυθμιστεί να αναφέρεται στο νεοδημιουργημένο Πόρος.

Εναλλακτικές λύσεις για το DCL

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

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

$config[zx-auto] not found$config[zx-overlay] not found