Philosophes bien nourris ou programmation .NET compétitive

Philosophes bien nourris ou programmation .NET compétitive

Voyons comment la programmation simultanée et parallèle fonctionne dans .Net, en utilisant le problème de la salle à manger des philosophes comme exemple. Le plan est présent, depuis la synchronisation des threads/processus, jusqu'au modèle d'acteur (dans les parties suivantes). L'article peut être utile pour la première connaissance ou pour rafraîchir vos connaissances.

Pourquoi le faire du tout ? Les transistors atteignent leur taille minimale, la loi de Moore repose sur la limitation de la vitesse de la lumière et donc on observe une augmentation du nombre, plus de transistors peuvent être réalisés. Dans le même temps, la quantité de données augmente et les utilisateurs attendent une réponse immédiate des systèmes. Dans une telle situation, la programmation "normale", lorsque nous avons un thread en cours d'exécution, n'est plus efficace. Vous devez en quelque sorte résoudre le problème de l'exécution simultanée ou simultanée. De plus, ce problème existe à différents niveaux : au niveau des threads, au niveau des processus, au niveau des machines du réseau (systèmes distribués). .NET dispose de technologies éprouvées de haute qualité pour résoudre rapidement et efficacement ces problèmes.

Tâche

Edsger Dijkstra a posé ce problème à ses étudiants dès 1965. La formulation établie est la suivante. Il y a un certain nombre (généralement cinq) de philosophes et le même nombre de fourches. Ils sont assis à une table ronde, des fourchettes entre eux. Les philosophes peuvent manger dans leurs assiettes de nourriture sans fin, réfléchir ou attendre. Pour manger un philosophe, il faut prendre deux fourchettes (la dernière partage la fourchette avec la première). Prendre et poser une fourchette sont deux actions distinctes. Tous les philosophes se taisent. La tâche est de trouver un tel algorithme que tous penseraient et seraient pleins même après 54 ans.

Essayons d'abord de résoudre ce problème en utilisant un espace partagé. Les fourchettes reposent sur la table commune et les philosophes les prennent simplement quand elles sont et les remettent en place. Ici il y a des problèmes de synchronisation, quand exactement prendre des surebets ? et s'il n'y a pas de fourche ? etc. Mais d'abord, commençons les philosophes.

Pour démarrer les threads, nous utilisons un pool de threads via Task.Run méthode:

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);
}

Le pool de threads est conçu pour optimiser la création et la suppression de threads. Ce pool a une file d'attente avec des tâches et le CLR crée ou supprime des threads en fonction du nombre de ces tâches. Un pool pour tous les AppDomains. Cette piscine doit être utilisée presque toujours, car. pas besoin de s'embêter à créer, supprimer des threads, leurs files d'attente, etc. C'est possible sans pool, mais il faut ensuite l'utiliser directement Thread, cela est utile dans les cas où vous devez changer la priorité d'un thread, lorsque nous avons une longue opération, pour un thread de premier plan, etc.

En d'autres termes, System.Threading.Tasks.Task la classe est la même Thread, mais avec toutes sortes de commodités : la possibilité d'exécuter une tâche après un bloc d'autres tâches, de les renvoyer à partir de fonctions, de les interrompre facilement, etc. etc. Ils sont nécessaires pour prendre en charge les constructions asynchrones/en attente (Task-based Asynchronous Pattern, sucre syntaxique pour attendre les opérations d'E/S). Nous en reparlerons plus tard.

CancelationTokenSource ici, il est nécessaire pour que le thread puisse se terminer au signal du thread appelant.

Problèmes de synchro

Philosophes bloqués

Bon, on sait créer des fils de discussion, essayons de déjeuner :

// Кто какие вилки взял. К примеру: 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();
    }
}

Ici, nous essayons d'abord de prendre la fourche gauche, puis la fourche droite, et si cela fonctionne, nous mangeons et les remettons. Prendre une fourchette est atomique, c'est-à-dire deux threads ne peuvent pas en prendre un en même temps (incorrect : le premier lit que la fourche est libre, le second - aussi, le premier prend, le second prend). Pour ça Interlocked.CompareExchange, qui doit être implémentée avec une instruction processeur (TSL, XCHG), qui verrouille une partie de la mémoire pour la lecture et l'écriture séquentielles atomiques. Et SpinWait est équivalent à la construction while(true) seulement avec un peu de "magie" - le fil prend le processeur (Thread.SpinWait), mais transfère parfois le contrôle à un autre thread (Thread.Yeild) ou s'endort (Thread.Sleep).

Mais cette solution ne fonctionne pas, car les flux bientôt (pour moi en une seconde) sont bloqués : tous les philosophes prennent leur fourche gauche, mais pas celle de droite. Le tableau forks a alors les valeurs : 1 2 3 4 5.

Philosophes bien nourris ou programmation .NET compétitive

Dans la figure, blocage des threads (deadlock). Vert - exécution, rouge - synchronisation, gris - le thread dort. Les losanges indiquent l'heure de début des Tâches.

La faim des philosophes

Bien qu'il ne soit pas nécessaire de penser particulièrement à la nourriture, mais la faim fait que n'importe qui abandonne la philosophie. Essayons de simuler la situation de famine des threads dans notre problème. La famine, c'est quand un fil est en cours d'exécution, mais sans travail significatif, en d'autres termes, c'est la même impasse, seulement maintenant le fil ne dort pas, mais cherche activement quelque chose à manger, mais il n'y a pas de nourriture. Afin d'éviter des blocages fréquents, nous remettrons la fourche si nous n'avons pas pu en prendre une autre.

// То же что и в 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)
    );
}

L'important dans ce code est que deux philosophes sur quatre oublient de poser leur fourche gauche. Et il s'avère qu'ils mangent plus de nourriture, tandis que d'autres commencent à mourir de faim, bien que les fils aient la même priorité. Ici, ils ne sont pas complètement affamés, car. les mauvais philosophes remettent parfois leur fourchette. Il s'avère que les bonnes personnes mangent environ 5 fois moins que les mauvaises. Ainsi, une petite erreur dans le code entraîne une baisse des performances. Il convient également de noter ici qu'une situation rare est possible lorsque tous les philosophes prennent la fourche gauche, il n'y en a pas de droite, ils mettent la gauche, attendent, reprennent la gauche, etc. Cette situation est aussi une famine, plus comme une impasse. Je n'ai pas réussi à le répéter. Ci-dessous, une image d'une situation où deux mauvais philosophes ont pris les deux fourchettes et deux bons sont affamés.

Philosophes bien nourris ou programmation .NET compétitive

Ici, vous pouvez voir que les threads se réveillent parfois et essaient d'obtenir la ressource. Deux des quatre cœurs ne font rien (graphique vert ci-dessus).

Mort d'un philosophe

Eh bien, un autre problème qui peut interrompre un glorieux dîner de philosophes est si l'un d'eux meurt soudainement avec des fourchettes dans les mains (et ils l'enterreront comme ça). Ensuite, les voisins seront laissés sans dîner. Vous pouvez trouver vous-même un exemple de code pour ce cas, par exemple, il est jeté NullReferenceException après que le philosophe ait pris les fourchettes. Et, soit dit en passant, l'exception ne sera pas gérée et le code appelant ne se contentera pas de l'attraper (pour cela AppDomain.CurrentDomain.UnhandledException et etc.). Par conséquent, les gestionnaires d'erreurs sont nécessaires dans les threads eux-mêmes et avec une terminaison gracieuse.

Serveur

D'accord, comment résoudre ce problème d'impasse, de famine et de mort ? Nous ne permettrons qu'à un seul philosophe d'atteindre les fourches, rajoutons une exclusion mutuelle de fils pour ce lieu. Comment faire? Supposons qu'un serveur se tient à côté des philosophes, qui donne la permission à n'importe quel philosophe de prendre les fourchettes. Comment fait-on ce garçon et comment les philosophes vont lui poser, les questions sont intéressantes.

Le moyen le plus simple est lorsque les philosophes demanderont constamment au serveur l'accès aux fourchettes. Ceux. maintenant les philosophes n'attendront pas une fourchette à proximité, mais attendront ou demanderont au serveur. Au début, nous n'utilisons que l'espace utilisateur pour cela, dans lequel nous n'utilisons pas d'interruptions pour appeler des procédures du noyau (à leur sujet ci-dessous).

Solutions en espace utilisateur

Ici, nous ferons la même chose que nous avions l'habitude de faire avec une fourchette et deux philosophes, nous allons tourner dans un cycle et attendre. Mais maintenant, ce seront tous les philosophes et, pour ainsi dire, une seule fourche, c'est-à-dire on peut dire que seul le philosophe qui a pris cette "fourchette d'or" au serveur mangera. Pour cela, nous utilisons 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 c'est un bloqueur, avec, grosso modo, le même while(true) { if (!lock) break; }, mais avec encore plus de "magie" que dans SpinWait (qui y est utilisé). Maintenant, il sait compter ceux qui attendent, les endormir un peu, et plus encore. etc. En général, fait tout son possible pour optimiser. Mais il faut se rappeler qu'il s'agit toujours du même cycle actif qui bouffe les ressources du processeur et maintient le débit, ce qui peut conduire à la famine si l'un des philosophes devient plus prioritaire que les autres, mais n'a pas de fourchette dorée (problème d'inversion de priorité) . Par conséquent, nous ne l'utilisons que pour des changements très très courts dans la mémoire partagée, sans appels tiers, verrous imbriqués et autres surprises.

Philosophes bien nourris ou programmation .NET compétitive

Dessin pour SpinLock. Les ruisseaux "se battent" constamment pour la fourchette d'or. Il y a des échecs - dans la figure, la zone sélectionnée. Les cœurs ne sont pas pleinement utilisés : seulement environ 2/3 par ces quatre threads.

Une autre solution consisterait à n'utiliser que Interlocked.CompareExchange avec la même attente active comme indiqué dans le code ci-dessus (dans les philosophes affamés), mais cela, comme déjà dit, pourrait théoriquement conduire à un blocage.

Sur Interlocked Il convient de noter qu'il n'y a pas que CompareExchange, mais aussi d'autres méthodes de lecture ET d'écriture atomiques. Et grâce à la répétition des modifications, au cas où un autre thread aurait le temps d'effectuer ses modifications (lecture 1, lecture 2, écriture 2, écriture 1 est mauvaise), il peut être utilisé pour des modifications complexes d'une seule valeur (modèle Interlocked Anything).

Solutions en mode noyau

Pour éviter de gaspiller des ressources dans une boucle, voyons comment nous pouvons bloquer un thread. En d'autres termes, poursuivant notre exemple, voyons comment le serveur endort le philosophe et ne le réveille qu'en cas de nécessité. Voyons d'abord comment procéder via le mode noyau du système d'exploitation. Toutes les structures y sont souvent plus lentes que celles de l'espace utilisateur. Plusieurs fois plus lent, par exemple AutoResetEvent peut-être 53 fois plus lent SpinLock [Richter]. Mais avec leur aide, vous pouvez synchroniser les processus dans tout le système, géré ou non.

La construction de base ici est le sémaphore proposé par Dijkstra il y a plus d'un demi-siècle. Un sémaphore est, en termes simples, un entier positif géré par le système, et deux opérations dessus, incrémenter et décrémenter. S'il ne diminue pas, zéro, alors le thread appelant est bloqué. Lorsque le nombre est incrémenté par un autre thread/processus actif, les threads sont ignorés et le sémaphore est à nouveau décrémenté du nombre passé. On peut imaginer des trains dans un goulot d'étranglement avec un sémaphore. .NET propose plusieurs constructions avec des fonctionnalités similaires : AutoResetEvent, ManualResetEvent, Mutex et moi Semaphore. Nous utiliserons AutoResetEvent, c'est la plus simple de ces constructions : seulement deux valeurs 0 et 1 (faux, vrai). Sa méthode WaitOne() bloque le thread appelant si la valeur était 0, et si 1, l'abaisse à 0 et l'ignore. Une méthode Set() monte à 1 et laisse passer un serveur, qui redescend à 0. Agit comme un tourniquet de métro.

Compliquons la solution et utilisons le cadenas pour chaque philosophe, et non pour tous à la fois. Ceux. or il peut y avoir plusieurs philosophes à la fois, et non un seul. Mais nous bloquons à nouveau l'accès à la table afin de correctement, en évitant les courses (conditions de course), prendre des surebets.

// Для блокирования отдельного философа.
// Инициализируется: 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();
}

Pour comprendre ce qui se passe ici, considérons le cas où le philosophe n'a pas réussi à prendre les fourches, alors ses actions seront les suivantes. Il attend l'accès à la table. Après l'avoir reçu, il essaie de prendre les fourchettes. N'a pas fonctionné. Il donne accès à la table (exclusion mutuelle). Et passe son "tourniquet" (AutoResetEvent) (ils sont initialement ouverts). Il entre à nouveau dans le cycle, parce que il n'a pas de fourches. Il essaie de les prendre et s'arrête à son "tourniquet". Un voisin plus chanceux de droite ou de gauche, ayant fini de manger, déverrouille notre philosophe, « ouvrant son tourniquet ». Notre philosophe le passe (et il se referme derrière lui) pour la seconde fois. Il essaie pour la troisième fois de prendre les fourchettes. Bonne chance. Et il passe son tourniquet pour dîner.

Lorsqu'il y a des erreurs aléatoires dans un tel code (elles existent toujours), par exemple, un voisin est incorrectement spécifié ou le même objet est créé AutoResetEvent pour tous (Enumerable.Repeat), alors les philosophes attendront les développeurs, car Trouver des erreurs dans un tel code est une tâche assez difficile. Un autre problème avec cette solution est qu'elle ne garantit pas qu'un philosophe ne mourra pas de faim.

Solutions hybrides

Nous avons examiné deux approches de la synchronisation, lorsque nous restons en mode utilisateur et en boucle, et lorsque nous bloquons le thread via le noyau. La première méthode est bonne pour les mèches courtes, la seconde pour les longues. Il est souvent nécessaire d'attendre d'abord brièvement qu'une variable change dans une boucle, puis de bloquer le thread lorsque l'attente est longue. Cette approche est mise en œuvre dans le soi-disant. structures hybrides. Voici les mêmes constructions que pour le mode noyau, mais maintenant avec une boucle en mode utilisateur : SemaphorSlim, ManualResetEventSlim etc. La conception la plus populaire ici est Monitor, parce que en C # il y a un bien connu lock syntaxe. Monitor c'est le même sémaphore avec une valeur maximale de 1 (mutex), mais avec la prise en charge de l'attente dans une boucle, de la récursivité, du modèle Condition Variable (plus d'informations ci-dessous), etc. Regardons une solution avec.

// Спрячем объект для Монитора от всех, чтобы без дедлоков.
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);
    }
}

Ici, nous bloquons à nouveau toute la table pour l'accès aux fourches, mais maintenant nous débloquons tous les threads à la fois, et non les voisins lorsque quelqu'un a fini de manger. Ceux. d'abord, quelqu'un mange et bloque les voisins, et quand ce quelqu'un a fini, mais veut encore manger tout de suite, il se met à bloquer et réveille ses voisins, parce que. son temps d'attente est moindre.

C'est ainsi qu'on évite les impasses et la famine de quelque philosophe. Nous utilisons une boucle pour une courte attente et bloquons le fil pour une longue. Débloquer tout le monde à la fois est plus lent que si seul le voisin était débloqué, comme dans la solution avec AutoResetEvent, mais la différence ne devrait pas être grande, car les threads doivent d'abord rester en mode utilisateur.

У lock La syntaxe réserve de mauvaises surprises. Recommander d'utiliser Monitor directement [Richter] [Eric Lippert]. L'un d'eux est que lock toujours hors de Monitor, même s'il y avait une exception, auquel cas un autre thread pourrait modifier l'état de la mémoire partagée. Dans de tels cas, il est souvent préférable d'aller dans l'impasse ou de mettre fin au programme en toute sécurité. Autre surprise, Monitor utilise des blocs de synchronisation (SyncBlock), qui sont présents dans tous les objets. Par conséquent, si un objet inapproprié est sélectionné, vous pouvez facilement obtenir un interblocage (par exemple, si vous verrouillez sur une chaîne interne). Nous utilisons l'objet toujours caché pour cela.

Le modèle Variable de condition vous permet d'implémenter de manière plus concise l'attente d'une condition complexe. En .NET, il est incomplet, à mon avis, car en théorie, il devrait y avoir plusieurs files d'attente sur plusieurs variables (comme dans Posix Threads), et non sur un lok. Alors on pourrait les faire pour tous les philosophes. Mais même sous cette forme, cela permet de réduire le code.

de nombreux philosophes ou async / await

Bon, maintenant nous pouvons bloquer efficacement les threads. Et si nous avions beaucoup de philosophes ? 100 ? 10000 ? Par exemple, nous avons reçu 100000 4 demandes au serveur Web. Il sera inutile de créer un thread pour chaque requête, car autant de threads ne fonctionneront pas en parallèle. Ne fonctionnera qu'autant qu'il y a de cœurs logiques (j'en ai XNUMX). Et tout le monde enlèvera simplement des ressources. Une solution à ce problème est le modèle asynchrone/attente. Son idée est que la fonction ne retient pas le thread si elle doit attendre que quelque chose continue. Et quand il fait quelque chose, il reprend son exécution (mais pas forcément sur le même thread !). Dans notre cas, nous attendrons le fork.

SemaphoreSlim a pour cela WaitAsync() méthode. Voici une implémentation utilisant ce modèle.

// Запуск такой же, как раньше. Где-нибудь в программе:
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();
}

Méthode avec async / await est traduit en une machine d'état délicate qui renvoie immédiatement sa valeur interne Task. Grâce à cela, vous pouvez attendre la fin de la méthode, l'annuler et tout ce que vous pouvez faire avec Task. A l'intérieur de la méthode, la machine d'état contrôle l'exécution. L'essentiel est que s'il n'y a pas de retard, l'exécution est synchrone, et s'il y en a, le thread est libéré. Pour mieux comprendre cela, il est préférable de regarder cette machine d'état. Vous pouvez créer des chaînes à partir de ces async / await méthodes.

Testons. Travail de 100 philosophes sur une machine à 4 cœurs logiques, 8 secondes. La solution précédente avec Monitor n'exécutait que les 4 premiers threads et le reste ne fonctionnait pas du tout. Chacun de ces 4 threads était inactif pendant environ 2 ms. Et la solution asynchrone/attente a fonctionné tous les 100, avec une attente moyenne de 6.8 secondes chacune. Bien sûr, dans les systèmes réels, une inactivité de 6 secondes est inacceptable et il vaut mieux ne pas traiter autant de requêtes comme celle-ci. La solution avec Monitor s'est avérée pas du tout évolutive.

Conclusion

Comme vous pouvez le voir sur ces petits exemples, .NET prend en charge de nombreuses constructions de synchronisation. Cependant, leur utilisation n'est pas toujours évidente. J'espère que cet article a été utile. Pour l'instant, c'est la fin, mais il reste encore beaucoup de choses intéressantes, par exemple, les collections thread-safe, le flux de données TPL, la programmation réactive, le modèle de transaction logicielle, etc.

sources

Source: habr.com

Ajouter un commentaire