Καλοτροφισμένοι φιλόσοφοι ή ανταγωνιστικός προγραμματισμός .NET

Καλοτροφισμένοι φιλόσοφοι ή ανταγωνιστικός προγραμματισμός .NET

Ας δούμε πώς λειτουργεί ο ταυτόχρονος και παράλληλος προγραμματισμός στο .Net, χρησιμοποιώντας ως παράδειγμα το Philosophers Dining Problem. Το σχέδιο είναι αυτό, από τον συγχρονισμό των νημάτων / διεργασιών, στο μοντέλο του ηθοποιού (στα επόμενα μέρη). Το άρθρο μπορεί να είναι χρήσιμο για την πρώτη γνωριμία ή για να ανανεώσετε τις γνώσεις σας.

Γιατί να το κάνεις καθόλου; Τα τρανζίστορ φτάνουν στο ελάχιστο μέγεθός τους, ο νόμος του Μουρ βασίζεται στον περιορισμό της ταχύτητας του φωτός και επομένως παρατηρείται αύξηση στον αριθμό, μπορούν να κατασκευαστούν περισσότερα τρανζίστορ. Ταυτόχρονα, ο όγκος των δεδομένων αυξάνεται και οι χρήστες αναμένουν άμεση ανταπόκριση από τα συστήματα. Σε μια τέτοια κατάσταση, ο "κανονικός" προγραμματισμός, όταν έχουμε ένα νήμα εκτέλεσης, δεν είναι πλέον αποτελεσματικός. Πρέπει να λύσετε με κάποιο τρόπο το πρόβλημα της ταυτόχρονης ή ταυτόχρονης εκτέλεσης. Επιπλέον, αυτό το πρόβλημα υπάρχει σε διαφορετικά επίπεδα: σε επίπεδο νημάτων, σε επίπεδο διεργασιών, σε επίπεδο μηχανών στο δίκτυο (κατανεμημένα συστήματα). Το .NET διαθέτει τεχνολογίες υψηλής ποιότητας, δοκιμασμένες στο χρόνο για γρήγορη και αποτελεσματική επίλυση τέτοιων προβλημάτων.

Έργο

Ο Edsger Dijkstra έθεσε αυτό το πρόβλημα στους μαθητές του ήδη από το 1965. Η καθιερωμένη διατύπωση είναι η εξής. Υπάρχει ένας συγκεκριμένος (συνήθως πέντε) αριθμός φιλοσόφων και ο ίδιος αριθμός πιρουνιών. Κάθονται σε ένα στρογγυλό τραπέζι, πιρούνια ανάμεσά τους. Οι φιλόσοφοι μπορούν να τρώνε από τα πιάτα τους ατελείωτο φαγητό, να σκεφτούν ή να περιμένουν. Για να φας έναν φιλόσοφο, πρέπει να πάρεις δύο πιρούνια (το τελευταίο μοιράζεται το πιρούνι με το πρώτο). Το να σηκώνετε και να αφήνετε κάτω ένα πιρούνι είναι δύο ξεχωριστές ενέργειες. Όλοι οι φιλόσοφοι σιωπούν. Το καθήκον είναι να βρεθεί ένας τέτοιος αλγόριθμος που όλοι θα σκέφτονταν και θα ήταν γεμάτοι ακόμα και μετά από 54 χρόνια.

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

Για να ξεκινήσουμε τα νήματα, χρησιμοποιούμε μια ομάδα νημάτων Task.Run μέθοδος:

var cancelTokenSource = new CancellationTokenSource();
Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i < philosophersAmount; i++) 
{
    int icopy = i;
    // Поместить задачу в очередь пула потоков. Метод RunDeadlock не запускаеться 
    // сразу, а ждет своего потока. Асинхронный запуск.
    philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}

Η ομάδα νημάτων έχει σχεδιαστεί για να βελτιστοποιεί τη δημιουργία και τη διαγραφή νημάτων. Αυτό το pool έχει μια ουρά με εργασίες και το CLR δημιουργεί ή αφαιρεί νήματα ανάλογα με τον αριθμό αυτών των εργασιών. Μία πισίνα για όλους τους AppDomains. Αυτή η πισίνα πρέπει να χρησιμοποιείται σχεδόν πάντα, γιατί. δεν χρειάζεται να ασχοληθείτε με τη δημιουργία, τη διαγραφή νημάτων, τις ουρές τους κ.λπ. Είναι δυνατό χωρίς πισίνα, αλλά στη συνέχεια πρέπει να το χρησιμοποιήσετε απευθείας Thread, αυτό είναι χρήσιμο για περιπτώσεις που πρέπει να αλλάξετε την προτεραιότητα ενός νήματος, όταν έχουμε μια μακρά λειτουργία, για ένα νήμα προσκηνίου κ.λπ.

Με άλλα λόγια System.Threading.Tasks.Task η τάξη είναι η ίδια Thread, αλλά με κάθε είδους ευκολίες: τη δυνατότητα εκτέλεσης μιας εργασίας μετά από ένα μπλοκ άλλων εργασιών, επιστροφής από λειτουργίες, εύκολης διακοπής τους και πολλά άλλα. κ.λπ. Απαιτούνται για την υποστήριξη ασύγχρονων / αναμονής κατασκευών (Ασύγχρονο μοτίβο βάσει εργασιών, συντακτικό σάκχαρο για αναμονή για λειτουργίες IO). Θα μιλήσουμε για αυτό αργότερα.

CancelationTokenSource Εδώ χρειάζεται για να μπορεί το νήμα να τερματιστεί μόνο του στο σήμα του καλούντος νήματος.

Ζητήματα συγχρονισμού

Αποκλεισμένοι Φιλόσοφοι

Εντάξει, ξέρουμε πώς να δημιουργούμε νήματα, ας προσπαθήσουμε να γευματίσουμε:

// Кто какие вилки взял. К примеру: 1 1 3 3 - 1й и 3й взяли первые две пары.
private int[] forks = Enumerable.Repeat(0, philosophersAmount).ToArray();

// То же, что RunPhilosopher()
private void RunDeadlock(int i, CancellationToken token) 
{
    // Ждать вилку, взять её. Эквивалентно: 
    // while(true) 
    //     if forks[fork] == 0 
    //          forks[fork] = i+1
    //          break
    //     Thread.Sleep() или Yield() или SpinWait()
    void TakeFork(int fork) =>
        SpinWait.SpinUntil(() => 
            Interlocked.CompareExchange(ref forks[fork], i+1, 0) == 0);

    // Для простоты, но можно с Interlocked.Exchange:
    void PutFork(int fork) => forks[fork] = 0;

    while (true)
    {
        TakeFork(Left(i));
        TakeFork(Right(i));
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        PutFork(Left(i));
        PutFork(Right(i));
        Think(i);

        // Завершить работу по-хорошему.
        token.ThrowIfCancellationRequested();
    }
}

Εδώ προσπαθούμε πρώτα να πάρουμε το αριστερό πιρούνι, και μετά το δεξί πιρούνι, και αν πετύχει, τότε τα τρώμε και τα ξαναβάζουμε. Η λήψη ενός πιρουνιού είναι ατομική, δηλ. δύο νήματα δεν μπορούν να πάρουν ένα ταυτόχρονα (λανθασμένο: το πρώτο λέει ότι το πιρούνι είναι ελεύθερο, το δεύτερο - επίσης, το πρώτο παίρνει, το δεύτερο παίρνει). Για αυτό Interlocked.CompareExchange, το οποίο πρέπει να υλοποιηθεί με μια εντολή επεξεργαστή (TSL, XCHG), το οποίο κλειδώνει ένα κομμάτι μνήμης για ατομική διαδοχική ανάγνωση και γραφή. Και το SpinWait είναι ισοδύναμο με την κατασκευή while(true) μόνο με λίγη "μαγεία" - το νήμα παίρνει τον επεξεργαστή (Thread.SpinWait), αλλά μερικές φορές μεταφέρει τον έλεγχο σε άλλο νήμα (Thread.Yeild) ή αποκοιμιέται (Thread.Sleep).

Αλλά αυτή η λύση δεν λειτουργεί, γιατί οι ροές σύντομα (για μένα μέσα σε ένα δευτερόλεπτο) μπλοκάρονται: όλοι οι φιλόσοφοι παίρνουν την αριστερή τους διχάλα, αλλά όχι τη σωστή. Ο πίνακας πιρουνιών έχει τότε τις τιμές: 1 2 3 4 5.

Καλοτροφισμένοι φιλόσοφοι ή ανταγωνιστικός προγραμματισμός .NET

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

Η πείνα των φιλοσόφων

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

// То же что и в RunDeadlock, но теперь кладем вилку назад и добавляем плохих философов.
private void RunStarvation(int i, CancellationToken token)
{
    while (true)
    {
        bool hasTwoForks = false;
        var waitTime = TimeSpan.FromMilliseconds(50);
        // Плохой философов может уже иметь вилку:
        bool hasLeft = forks[Left(i)] == i + 1;
        if (hasLeft || TakeFork(Left(i), i + 1, waitTime))
        {
            if (TakeFork(Right(i), i + 1, TimeSpan.Zero))
                hasTwoForks = true;
            else
                PutFork(Left(i)); // Иногда плохой философ отдает вилку назад.
        } 
        if (!hasTwoForks)
        {
            if (token.IsCancellationRequested) break;
            continue;
        }
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        bool goodPhilosopher = i % 2 == 0;
        // А плохой философ забывает положить свою вилку обратно:
        if (goodPhilosopher)
            PutFork(Left(i));
        // А если и правую не положит, то хорошие будут вообще без еды.
        PutFork(Right(i));

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

// Теперь можно ждать определенное время.
bool TakeFork(int fork, int philosopher, TimeSpan? waitTime = null)
{
    return SpinWait.SpinUntil(
        () => Interlocked.CompareExchange(ref forks[fork], philosopher, 0) == 0,
              waitTime ?? TimeSpan.FromMilliseconds(-1)
    );
}

Το σημαντικό με αυτόν τον κώδικα είναι ότι δύο στους τέσσερις φιλοσόφους ξεχνούν να αφήσουν κάτω το αριστερό τους πιρούνι. Και αποδεικνύεται ότι τρώνε περισσότερο φαγητό, ενώ άλλοι αρχίζουν να λιμοκτονούν, αν και οι κλωστές έχουν την ίδια προτεραιότητα. Εδώ δεν λιμοκτονούν εντελώς, γιατί. οι κακοί φιλόσοφοι βάζουν τα πιρούνια τους πίσω μερικές φορές. Αποδεικνύεται ότι οι καλοί άνθρωποι τρώνε περίπου 5 φορές λιγότερο από τους κακούς. Έτσι, ένα μικρό σφάλμα στον κώδικα οδηγεί σε πτώση της απόδοσης. Αξίζει επίσης να σημειωθεί εδώ ότι μια σπάνια κατάσταση είναι πιθανή όταν όλοι οι φιλόσοφοι παίρνουν την αριστερή διχάλα, δεν υπάρχει δεξιά, βάζουν την αριστερή, περιμένουν, ξαναπάρουν την αριστερή κ.λπ. Αυτή η κατάσταση είναι επίσης μια πείνα, περισσότερο σαν αδιέξοδο. Δεν κατάφερα να το επαναλάβω. Παρακάτω είναι μια εικόνα για μια κατάσταση όπου δύο κακοί φιλόσοφοι έχουν πάρει και τα δύο πιρούνια και δύο καλοί λιμοκτονούν.

Καλοτροφισμένοι φιλόσοφοι ή ανταγωνιστικός προγραμματισμός .NET

Εδώ μπορείτε να δείτε ότι τα νήματα ξυπνούν μερικές φορές και προσπαθούν να αποκτήσουν τον πόρο. Δύο από τους τέσσερις πυρήνες δεν κάνουν τίποτα (πράσινο γράφημα παραπάνω).

Θάνατος ενός φιλοσόφου

Λοιπόν, ένα άλλο πρόβλημα που μπορεί να διακόψει ένα ένδοξο δείπνο φιλοσόφων είναι αν κάποιος από αυτούς πεθάνει ξαφνικά με τα πιρούνια στα χέρια (και θα τον θάψουν έτσι). Τότε οι γείτονες θα μείνουν χωρίς δείπνο. Μπορείτε να βρείτε μόνοι σας ένα παράδειγμα κώδικα για αυτήν την περίπτωση, για παράδειγμα, πετάγεται έξω NullReferenceException αφού ο φιλόσοφος παίρνει τα πιρούνια. Και, παρεμπιπτόντως, η εξαίρεση δεν θα αντιμετωπιστεί και ο κωδικός κλήσης δεν θα την πιάσει απλώς (για αυτό AppDomain.CurrentDomain.UnhandledException και τα λοιπά.). Επομένως, χρειάζονται χειριστές σφαλμάτων στα ίδια τα νήματα και με χαριτωμένο τερματισμό.

Σερβιτόρος

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

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

Λύσεις στο χώρο του χρήστη

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

private static SpinLock spinLock = new SpinLock();  // Наш "официант"
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Взаимная блокировка через busy waiting. Вызываем до try, чтобы
        // выбрасить исключение в случае ошибки в самом SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Здесь может быть только один поток (mutual exclusion).
            forks[Left(i)] = i + 1;  // Берем вилку сразу, без ожидания.
            forks[Right(i)] = i + 1;
            eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
            forks[Left(i)] = 0;
            forks[Right(i)] = 0;
        }
        finally
        {
            if(hasLock) spinLock.Exit();  // Избегаем проблемы со смертью философа.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock αυτός είναι ένας αποκλεισμός, με, χονδρικά μιλώντας, το ίδιο while(true) { if (!lock) break; }, αλλά με ακόμη περισσότερη «μαγεία» από το in SpinWait (που χρησιμοποιείται εκεί). Τώρα ξέρει να μετράει αυτούς που περιμένουν, να τους κοιμίζει λίγο και πολλά άλλα. κτλ. Γενικά, κάνει ό,τι είναι δυνατό για τη βελτιστοποίηση. Αλλά πρέπει να θυμόμαστε ότι αυτός είναι ακόμα ο ίδιος ενεργός κύκλος που καταναλώνει τους πόρους του επεξεργαστή και διατηρεί τη ροή, η οποία μπορεί να οδηγήσει σε λιμοκτονία εάν ένας από τους φιλόσοφους έχει μεγαλύτερη προτεραιότητα από άλλους, αλλά δεν έχει μια χρυσή διχάλα (πρόβλημα αντιστροφής προτεραιότητας) . Επομένως, το χρησιμοποιούμε μόνο για πολύ σύντομες αλλαγές στην κοινόχρηστη μνήμη, χωρίς κλήσεις τρίτων, ένθετες κλειδαριές και άλλες εκπλήξεις.

Καλοτροφισμένοι φιλόσοφοι ή ανταγωνιστικός προγραμματισμός .NET

Σχέδιο για SpinLock. Τα ρέματα «μάχονται» διαρκώς για τη χρυσή διχάλα. Υπάρχουν αποτυχίες - στο σχήμα, η επιλεγμένη περιοχή. Οι πυρήνες δεν χρησιμοποιούνται πλήρως: μόνο περίπου τα 2/3 από αυτά τα τέσσερα νήματα.

Μια άλλη λύση εδώ θα ήταν η χρήση μόνο Interlocked.CompareExchange με την ίδια ενεργή αναμονή όπως φαίνεται στον παραπάνω κώδικα (στους λιμοκτονούντες φιλοσόφους), αλλά αυτό, όπως ήδη ειπώθηκε, θα μπορούσε θεωρητικά να οδηγήσει σε μπλοκάρισμα.

επί Interlocked Πρέπει να σημειωθεί ότι δεν υπάρχει μόνο CompareExchange, αλλά και άλλες μεθόδους ατομικής ανάγνωσης ΚΑΙ εγγραφής. Και μέσω της επανάληψης της αλλαγής, σε περίπτωση που ένα άλλο νήμα έχει χρόνο να κάνει τις αλλαγές του (διαβάστε 1, διαβάστε 2, γράψτε 2, γράψτε 1 είναι κακό), μπορεί να χρησιμοποιηθεί για σύνθετες αλλαγές σε μία μόνο τιμή (μοτίβο Interlocked Anything) .

Λύσεις λειτουργίας πυρήνα

Για να αποφύγετε τη σπατάλη πόρων σε έναν βρόχο, ας δούμε πώς μπορούμε να αποκλείσουμε ένα νήμα. Με άλλα λόγια, συνεχίζοντας το παράδειγμά μας, ας δούμε πώς ο σερβιτόρος κοιμίζει τον φιλόσοφο και τον ξυπνάει μόνο όταν χρειάζεται. Αρχικά, ας δούμε πώς να το κάνουμε αυτό μέσω της λειτουργίας πυρήνα του λειτουργικού συστήματος. Όλες οι δομές εκεί είναι συχνά πιο αργές από αυτές στο χώρο χρήστη. Αρκετές φορές πιο αργά, για παράδειγμα AutoResetEvent ίσως 53 φορές πιο αργά SpinLock [Ρίχτερ]. Αλλά με τη βοήθειά τους, μπορείτε να συγχρονίσετε διαδικασίες σε όλο το σύστημα, διαχειριζόμενες ή μη.

Η βασική κατασκευή εδώ είναι ο σηματοφόρος που πρότεινε ο Dijkstra πριν από μισό αιώνα. Ένας σηματοφόρος είναι, με απλά λόγια, ένας θετικός ακέραιος που διαχειρίζεται το σύστημα, και δύο λειτουργίες σε αυτόν, η αύξηση και η μείωση. Εάν αποτύχει να μειωθεί, μηδέν, τότε το νήμα κλήσης μπλοκάρεται. Όταν ο αριθμός αυξάνεται με κάποιο άλλο ενεργό νήμα/διεργασία, τότε τα νήματα παραλείπονται και ο σηματοφόρος μειώνεται ξανά κατά τον αριθμό που έχει περάσει. Μπορεί κανείς να φανταστεί τα τρένα σε μια συμφόρηση με ένα σηματοφόρο. Το .NET προσφέρει πολλές κατασκευές με παρόμοια λειτουργικότητα: AutoResetEvent, ManualResetEvent, Mutex και τον εαυτό μου Semaphore. Θα το χρησιμοποιησουμε AutoResetEvent, αυτή είναι η απλούστερη από αυτές τις κατασκευές: μόνο δύο τιμές 0 και 1 (false, true). Η Μέθοδός της WaitOne() μπλοκάρει το νήμα κλήσης εάν η τιμή ήταν 0, και αν 1, το μειώνει στο 0 και το παρακάμπτει. Μια μέθοδος Set() ανεβάζει στο 1 και αφήνει έναν σερβιτόρο να περάσει, ο οποίος πάλι χαμηλώνει στο 0. Λειτουργεί σαν τουρνικέ του μετρό.

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

// Для блокирования отдельного философа.
// Инициализируется: new AutoResetEvent(true) для каждого.
private AutoResetEvent[] philosopherEvents;

// Для доступа к вилкам / доступ к столу.
private AutoResetEvent tableEvent = new AutoResetEvent(true);

// Рождение философа.
public void Run(int i, CancellationToken token)
{
    while (true)
    {
        TakeForks(i); // Ждет вилки.
        // Обед. Может быть и дольше.
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        PutForks(i); // Отдать вилки и разблокировать соседей.
        Think(i);
        if (token.IsCancellationRequested) break;
    }
}

// Ожидать вилки в блокировке.
void TakeForks(int i)
{
    bool hasForks = false;
    while (!hasForks) // Попробовать еще раз (блокировка не здесь).
    {
        // Исключающий доступ к столу, без гонок за вилками.
        tableEvent.WaitOne();
        if (forks[Left(i)] == 0 && forks[Right(i)] == 0)
            forks[Left(i)] = forks[Right(i)] = i + 1;
        hasForks = forks[Left(i)] == i + 1 && forks[Right(i)] == i + 1;
        if (hasForks)
            // Теперь философ поест, выйдет из цикла. Если Set 
            // вызван дважды, то значение true.
            philosopherEvents[i].Set();
        // Разблокировать одного ожидающего. После него значение tableEvent в false.
        tableEvent.Set(); 
        // Если имеет true, не блокируется, а если false, то будет ждать Set от соседа.
        philosopherEvents[i].WaitOne();
    }
}

// Отдать вилки и разблокировать соседей.
void PutForks(int i)
{
    tableEvent.WaitOne(); // Без гонок за вилками.
    forks[Left(i)] = 0;
    // Пробудить левого, а потом и правого соседа, либо AutoResetEvent в true.
    philosopherEvents[LeftPhilosopher(i)].Set();
    forks[Right(i)] = 0;
    philosopherEvents[RightPhilosopher(i)].Set();
    tableEvent.Set();
}

Για να καταλάβετε τι συμβαίνει εδώ, σκεφτείτε την περίπτωση που ο φιλόσοφος δεν κατάφερε να πάρει τα πιρούνια, τότε οι ενέργειές του θα είναι οι εξής. Περιμένει πρόσβαση στο τραπέζι. Έχοντας το λάβει, προσπαθεί να πάρει τα πιρούνια. Δεν λειτούργησε. Δίνει πρόσβαση στον πίνακα (αμοιβαία εξαίρεση). Και περνά το «τουρνικέ» του (AutoResetEvent) (αρχικά είναι ανοιχτά). Μπαίνει ξανά στον κύκλο, γιατί δεν έχει πιρούνια. Προσπαθεί να τα πάρει και σταματά στο «τουρνικέ» του. Κάποιος πιο τυχερός γείτονας δεξιά ή αριστερά, έχοντας τελειώσει το φαγητό, ξεκλειδώνει τον φιλόσοφό μας, «ανοίγοντας το τουρνικέ του». Ο φιλόσοφος μας το προσπερνά (και κλείνει πίσω του) για δεύτερη φορά. Προσπαθεί για τρίτη φορά να πάρει τα πιρούνια. Καλή τύχη. Και περνάει το τουρνικέ του για να δειπνήσει.

Όταν υπάρχουν τυχαία σφάλματα σε έναν τέτοιο κώδικα (υπάρχουν πάντα), για παράδειγμα, ένας γείτονας καθορίζεται εσφαλμένα ή δημιουργείται το ίδιο αντικείμενο AutoResetEvent για όλα (Enumerable.Repeat), τότε οι φιλόσοφοι θα περιμένουν τους προγραμματιστές, γιατί Η εύρεση σφαλμάτων σε τέτοιο κώδικα είναι αρκετά δύσκολη εργασία. Ένα άλλο πρόβλημα με αυτή τη λύση είναι ότι δεν εγγυάται ότι κάποιος φιλόσοφος δεν θα πεινάσει.

Υβριδικές λύσεις

Εξετάσαμε δύο προσεγγίσεις για τον χρονισμό, όταν παραμένουμε σε λειτουργία χρήστη και βρόχο, και όταν αποκλείουμε το νήμα μέσω του πυρήνα. Η πρώτη μέθοδος είναι καλή για κοντές κλειδαριές, η δεύτερη για μακριές. Είναι συχνά απαραίτητο πρώτα να περιμένουμε για λίγο να αλλάξει μια μεταβλητή σε έναν βρόχο και μετά να μπλοκάρουμε το νήμα όταν η αναμονή είναι μεγάλη. Αυτή η προσέγγιση εφαρμόζεται στο λεγόμενο. υβριδικές δομές. Ακολουθούν οι ίδιες δομές όπως για τη λειτουργία πυρήνα, αλλά τώρα με βρόχο λειτουργίας χρήστη: SemaphorSlim, ManualResetEventSlim κλπ. Το πιο δημοφιλές σχέδιο εδώ είναι Monitor, επειδή στο C# υπάρχει ένα γνωστό lock σύνταξη. Monitor Αυτός είναι ο ίδιος σηματοφόρος με μέγιστη τιμή 1 (mutex), αλλά με υποστήριξη για αναμονή σε βρόχο, αναδρομή, το μοτίβο της μεταβλητής συνθήκης (περισσότερα για αυτό παρακάτω) κ.λπ. Ας δούμε μια λύση με αυτό.

// Спрячем объект для Монитора от всех, чтобы без дедлоков.
private readonly object _lock = new object();
// Время ожидания потока.
private DateTime?[] _waitTimes = new DateTime?[philosophersAmount];

public void Run(int i, CancellationToken token)
{
    while (true)
    {
        TakeForks(i);
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        PutForks(i);
        Think(i);
        if (token.IsCancellationRequested) break;
    }
}

// Наше сложное условие для Condition Variable паттерна.
bool CanIEat(int i)
{
    // Если есть вилки:
    if (forks[Left(i)] != 0 && forks[Right(i)] != 0)
        return false;
    var now = DateTime.Now;
    // Может, если соседи не более голодные, чем текущий.
    foreach(var p in new int[] {LeftPhilosopher(i), RightPhilosopher(i)})
        if (_waitTimes[p] != null && now - _waitTimes[p] > now - _waitTimes[i])
            return false;
    return true;
}

void TakeForks(int i)
{
    // Зайти в Монитор. То же самое: lock(_lock) {..}.
    // Вызываем вне try, чтобы возможное исключение выбрасывалось выше.
    bool lockTaken = false;
    Monitor.Enter(_lock, ref lockTaken);
    try
    {
        _waitTimes[i] = DateTime.Now;
        // Condition Variable паттерн. Освобождаем лок, если не выполненно 
        // сложное условие. И ждем пока кто-нибудь сделает Pulse / PulseAll.
        while (!CanIEat(i))
            Monitor.Wait(_lock); 
        forks[Left(i)] = i + 1;
        forks[Right(i)] = i + 1;
        _waitTimes[i] = null;
    }
    finally
    {
        if (lockTaken) Monitor.Exit(_lock);
    }
}

void PutForks(int i)
{
    // То же самое: lock (_lock) {..}.
    bool lockTaken = false;
    Monitor.Enter(_lock, ref lockTaken);
    try
    {
        forks[Left(i)] = 0;
        forks[Right(i)] = 0;
        // Освободить все потоки в очереди ПОСЛЕ вызова Monitor.Exit.
        Monitor.PulseAll(_lock); 
    }
    finally
    {
        if (lockTaken) Monitor.Exit(_lock);
    }
}

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

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

У lock η σύνταξη έχει δυσάρεστες εκπλήξεις. Συνιστάται η χρήση Monitor απευθείας [Ρίχτερ] [Έρικ Λίπερτ]. Ένα από αυτά είναι αυτό lock πάντα έξω από Monitor, ακόμα κι αν υπήρχε εξαίρεση, οπότε ένα άλλο νήμα θα μπορούσε να αλλάξει την κατάσταση της κοινής μνήμης. Σε τέτοιες περιπτώσεις, είναι συχνά καλύτερο να πάτε σε αδιέξοδο ή με κάποιο τρόπο να τερματίσετε με ασφάλεια το πρόγραμμα. Μια άλλη έκπληξη είναι ότι η οθόνη χρησιμοποιεί μπλοκ συγχρονισμού (SyncBlock), που υπάρχουν σε όλα τα αντικείμενα. Επομένως, εάν επιλεγεί ένα ακατάλληλο αντικείμενο, μπορείτε εύκολα να λάβετε ένα αδιέξοδο (για παράδειγμα, εάν κλειδώσετε σε μια παρεμβαλλόμενη συμβολοσειρά). Χρησιμοποιούμε το πάντα κρυφό αντικείμενο για αυτό.

Το μοτίβο Condition Variable σάς επιτρέπει να εφαρμόσετε πιο συνοπτικά την προσδοκία κάποιας περίπλοκης συνθήκης. Στο .NET είναι ελλιπής, κατά τη γνώμη μου, γιατί θεωρητικά, θα πρέπει να υπάρχουν πολλές ουρές σε πολλές μεταβλητές (όπως στο Posix Threads), και όχι σε ένα lok. Τότε θα μπορούσε κανείς να τα φτιάξει για όλους τους φιλοσόφους. Αλλά ακόμα και σε αυτή τη μορφή, σας επιτρέπει να μειώσετε τον κώδικα.

πολλοί φιλόσοφοι ή async / await

Εντάξει, τώρα μπορούμε να αποκλείσουμε αποτελεσματικά τα νήματα. Τι γίνεται όμως αν έχουμε πολλούς φιλοσόφους; 100; 10000? Για παράδειγμα, λάβαμε 100000 αιτήματα στον διακομιστή ιστού. Θα είναι υπερβολικό να δημιουργήσετε ένα νήμα για κάθε αίτημα, επειδή τόσα πολλά νήματα δεν θα τρέχουν παράλληλα. Θα τρέξει μόνο όσοι υπάρχουν λογικοί πυρήνες (έχω 4). Και όλοι οι άλλοι απλώς θα αφαιρέσουν πόρους. Μια λύση σε αυτό το πρόβλημα είναι το μοτίβο async / await. Η ιδέα της είναι ότι η συνάρτηση δεν κρατά το νήμα εάν χρειάζεται να περιμένει να συνεχιστεί κάτι. Και όταν κάνει κάτι, ξαναρχίζει την εκτέλεσή του (αλλά όχι απαραίτητα στο ίδιο νήμα!). Στην περίπτωσή μας, θα περιμένουμε το πιρούνι.

SemaphoreSlim έχει για αυτό WaitAsync() μέθοδος. Εδώ είναι μια υλοποίηση που χρησιμοποιεί αυτό το μοτίβο.

// Запуск такой же, как раньше. Где-нибудь в программе:
Task.Run(() => Run(i, cancelTokenSource.Token));

// Запуск философа.
// Ключевое слово async -- компилятор транслирует этот метот в асинхронный.
public async Task Run(int i, CancellationToken token)
{
    while (true)
    {
        // await -- будем ожидать какого-то события.
        await TakeForks(i);
        // После await, продолжение возможно в другом потоке.
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        // Может быть несколько событий для ожидания.
        await PutForks(i);

        Think(i);

        if (token.IsCancellationRequested) break;
    }
}

async Task TakeForks(int i)
{
    bool hasForks = false;
    while (!hasForks)
    {
        // Взаимоисключающий доступ к столу:
        await _tableSemaphore.WaitAsync();
        if (forks[Left(i)] == 0 && forks[Right(i)] == 0)
        {
            forks[Left(i)] = i+1;
            forks[Right(i)] = i+1;
            hasForks = true;
        }
        _tableSemaphore.Release();
        // Будем ожидать, чтобы сосед положил вилки:
        if (!hasForks)
            await _philosopherSemaphores[i].WaitAsync();
    }
}

// Ждем доступа к столу и кладем вилки.
async Task PutForks(int i)
{
    await _tableSemaphore.WaitAsync();
    forks[Left(i)] = 0;
    // "Пробудить" соседей, если они "спали".
    _philosopherSemaphores[LeftPhilosopher(i)].Release();
    forks[Right(i)] = 0;
    _philosopherSemaphores[RightPhilosopher(i)].Release();
    _tableSemaphore.Release();
}

Μέθοδος με async / await μεταφράζεται σε μια δύσκολη μηχανή κατάστασης που επιστρέφει αμέσως το εσωτερικό της Task. Μέσω αυτής, μπορείτε να περιμένετε την ολοκλήρωση της μεθόδου, να την ακυρώσετε και ό,τι άλλο μπορείτε να κάνετε με το Task. Μέσα στη μέθοδο, η μηχανή κατάστασης ελέγχει την εκτέλεση. Η ουσία είναι ότι εάν δεν υπάρχει καθυστέρηση, τότε η εκτέλεση είναι σύγχρονη και εάν υπάρχει, τότε το νήμα απελευθερώνεται. Για καλύτερη κατανόηση αυτού, είναι καλύτερο να κοιτάξουμε αυτήν την κρατική μηχανή. Μπορείτε να δημιουργήσετε αλυσίδες από αυτά async / await μεθόδους.

Ας δοκιμάσουμε. Εργασία 100 φιλοσόφων σε μια μηχανή με 4 λογικούς πυρήνες, 8 δευτερόλεπτα. Η προηγούμενη λύση με το Monitor έτρεχε μόνο τα 4 πρώτα νήματα και τα υπόλοιπα δεν έτρεχαν καθόλου. Κάθε ένα από αυτά τα 4 νήματα ήταν αδρανές για περίπου 2 ms. Και η λύση async / await έτρεξε και τα 100, με μέση αναμονή 6.8 δευτερόλεπτα το καθένα. Φυσικά, σε πραγματικά συστήματα, η αδράνεια για 6 δευτερόλεπτα είναι απαράδεκτη και είναι καλύτερα να μην επεξεργάζονται τόσα πολλά αιτήματα όπως αυτό. Η λύση με το Monitor αποδείχθηκε ότι δεν ήταν καθόλου επεκτάσιμη.

Συμπέρασμα

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

πηγές

Πηγή: www.habr.com

Προσθέστε ένα σχόλιο