Filósofos bem alimentados ou programação .NET competitiva

Filósofos bem alimentados ou programação .NET competitiva

Vamos ver como a programação concorrente e paralela funciona em .Net, usando o Philosophers Dining Problem como exemplo. O plano é este, desde a sincronização de threads/processos, até o modelo de ator (nas partes seguintes). O artigo pode ser útil para o primeiro contato ou para atualizar seus conhecimentos.

Por que fazer isso? Os transistores atingem seu tamanho mínimo, a lei de Moore repousa na limitação da velocidade da luz e, portanto, observa-se um aumento no número, mais transistores podem ser feitos. Ao mesmo tempo, a quantidade de dados está crescendo e os usuários esperam uma resposta imediata dos sistemas. Em tal situação, a programação "normal", quando temos um thread em execução, não é mais eficaz. Você precisa resolver de alguma forma o problema de execução simultânea ou concorrente. Além disso, este problema existe a vários níveis: ao nível das threads, ao nível dos processos, ao nível das máquinas da rede (sistemas distribuídos). O .NET possui tecnologias testadas e de alta qualidade para resolver esses problemas de forma rápida e eficiente.

Tarefa

Edsger Dijkstra colocou esse problema para seus alunos já em 1965. A formulação estabelecida é a seguinte. Existe um certo número (geralmente cinco) de filósofos e o mesmo número de garfos. Eles se sentam em uma mesa redonda, com garfos entre eles. Os filósofos podem comer de seus pratos de comida sem fim, pensar ou esperar. Para comer um filósofo, você precisa pegar dois garfos (o último divide o garfo com o primeiro). Pegar e largar um garfo são duas ações separadas. Todos os filósofos estão em silêncio. A tarefa é encontrar um algoritmo que todos pensem e fiquem cheios mesmo depois de 54 anos.

Primeiro, vamos tentar resolver esse problema através do uso de um espaço compartilhado. Os garfos estão na mesa comum e os filósofos simplesmente os pegam quando estão e os colocam de volta. Aqui há problemas com sincronização, quando exatamente fazer apostas certas? e se não houver garfo? etc. Mas primeiro, vamos começar os filósofos.

Para iniciar threads, usamos um pool de threads por meio de Task.Run método:

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

O pool de encadeamentos foi projetado para otimizar a criação e exclusão de encadeamentos. Este pool possui uma fila com tarefas e o CLR cria ou remove threads dependendo do número dessas tarefas. Um pool para todos os AppDomains. Essa piscina deve ser usada quase sempre, pq. não há necessidade de se preocupar em criar, excluir threads, suas filas, etc. É possível sem um pool, mas você deve usá-lo diretamente Thread, isso é útil para casos em que você precisa alterar a prioridade de um thread, quando temos uma operação longa, para um thread de primeiro plano, etc.

Em outras palavras, System.Threading.Tasks.Task aula é a mesma Thread, mas com todos os tipos de conveniências: a capacidade de executar uma tarefa após um bloco de outras tarefas, retorná-las de funções, interrompê-las convenientemente e muito mais. etc. Eles são necessários para dar suporte a construções async/await (padrão assíncrono baseado em tarefa, açúcar sintático para aguardar operações IO). Falaremos sobre isso mais tarde.

CancelationTokenSource aqui é necessário para que o thread possa terminar ao sinal do thread de chamada.

Problemas de sincronização

Filósofos bloqueados

Ok, sabemos como criar tópicos, vamos tentar almoçar:

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

Aqui, primeiro tentamos pegar o garfo esquerdo, depois o garfo direito e, se der certo, comemos e os colocamos de volta. Pegar um garfo é atômico, ou seja, dois threads não podem receber um ao mesmo tempo (incorreto: o primeiro lê que o garfo está livre, o segundo - também, o primeiro leva, o segundo leva). Por esta Interlocked.CompareExchange, que deve ser implementado com uma instrução do processador (TSL, XCHG), que bloqueia um pedaço de memória para leitura e escrita sequencial atômica. E SpinWait é equivalente à construção while(true) apenas com um pouco de "mágica" - o thread leva o processador (Thread.SpinWait), mas às vezes transfere o controle para outro thread (Thread.Yeild) ou adormece (Thread.Sleep).

Mas esta solução não funciona, porque os fluxos logo (para mim em um segundo) são bloqueados: todos os filósofos pegam a bifurcação da esquerda, mas não a da direita. A matriz forks então tem os valores: 1 2 3 4 5.

Filósofos bem alimentados ou programação .NET competitiva

Na figura, bloqueio de threads (deadlock). Verde - execução, vermelho - sincronização, cinza - o thread está dormindo. Os losangos indicam a hora de início das Tarefas.

A Fome dos Filósofos

Embora não seja necessário pensar especialmente em muita comida, mas a fome faz com que alguém desista da filosofia. Vamos tentar simular a situação de starvation de threads em nosso problema. A fome é quando um thread está rodando, mas sem trabalho significativo, ou seja, é o mesmo impasse, só que agora o thread não está dormindo, mas está procurando ativamente algo para comer, mas não tem comida. Para evitar bloqueios frequentes, colocaremos o garfo de volta se não pudermos pegar outro.

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

O importante sobre esse código é que dois em cada quatro filósofos se esquecem de largar o garfo esquerdo. E acontece que eles comem mais comida, enquanto outros começam a passar fome, embora os fios tenham a mesma prioridade. Aqui eles não estão morrendo de fome, porque. maus filósofos colocam seus garfos de volta às vezes. Acontece que as pessoas boas comem cerca de 5 vezes menos do que as más. Portanto, um pequeno erro no código leva a uma queda no desempenho. Também é importante notar aqui que uma situação rara é possível quando todos os filósofos pegam a bifurcação esquerda, não há direita, eles colocam a esquerda, esperam, pegam a esquerda novamente, etc. Essa situação também é uma fome, mais como um impasse. Eu falhei em repeti-lo. Abaixo está uma imagem de uma situação em que dois maus filósofos pegaram os dois garfos e dois bons estão morrendo de fome.

Filósofos bem alimentados ou programação .NET competitiva

Aqui você pode ver que os threads acordam às vezes e tentam obter o recurso. Dois dos quatro núcleos não fazem nada (gráfico verde acima).

Morte de um filósofo

Bem, outro problema que pode interromper um glorioso jantar de filósofos é se um deles morrer repentinamente com garfos nas mãos (e eles o enterrarão assim). Então os vizinhos ficarão sem jantar. Você mesmo pode criar um código de exemplo para este caso, por exemplo, ele é descartado NullReferenceException depois que o filósofo pega os garfos. E, a propósito, a exceção não será tratada e o código de chamada não irá apenas capturá-la (para isso AppDomain.CurrentDomain.UnhandledException e etc). Portanto, os manipuladores de erro são necessários nos próprios encadeamentos e com finalização elegante.

Garçom

Ok, como resolvemos esse problema de impasse, fome e morte? Permitiremos que apenas um filósofo alcance as bifurcações, adicione uma exclusão mútua de tópicos para este local. Como fazer isso? Suponha que um garçom esteja ao lado dos filósofos, que dá permissão a qualquer um dos filósofos para pegar os garfos. Como fazemos esse garçom e como os filósofos vão perguntar a ele, as questões são interessantes.

A maneira mais simples é quando os filósofos simplesmente pedem constantemente ao garçom acesso aos garfos. Aqueles. agora os filósofos não esperam por uma bifurcação próxima, mas esperam ou perguntam ao garçom. A princípio utilizamos apenas o User Space para isso, nele não utilizamos interrupções para chamar quaisquer procedures do kernel (sobre elas abaixo).

Soluções no espaço do usuário

Aqui faremos o mesmo que fazíamos com um garfo e dois filósofos, vamos girar em um ciclo e esperar. Mas agora serão todos os filósofos e, por assim dizer, apenas um garfo, ou seja. pode-se dizer que só comerá o filósofo que pegou este “garfo de ouro” do garçom. Para isso, usamos o 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 este é um bloqueador, com, grosso modo, o mesmo while(true) { if (!lock) break; }, mas com ainda mais "mágica" do que em SpinWait (que é usado lá). Agora ele sabe contar os que estão esperando, colocá-los para dormir um pouco e muito mais. etc. Em geral, faz todo o possível para otimizar. Mas devemos lembrar que este ainda é o mesmo ciclo ativo que consome recursos do processador e mantém o fluxo, o que pode levar à inanição se um dos filósofos se tornar mais prioritário que os outros, mas não tiver um garfo dourado (problema de inversão de prioridade) . Portanto, nós o usamos apenas para alterações muito curtas na memória compartilhada, sem chamadas de terceiros, bloqueios aninhados e outras surpresas.

Filósofos bem alimentados ou programação .NET competitiva

Desenho para SpinLock. Os riachos estão constantemente "lutando" pelo garfo dourado. Existem falhas - na figura, a área selecionada. Os núcleos não são totalmente utilizados: apenas cerca de 2/3 por esses quatro threads.

Outra solução aqui seria usar apenas Interlocked.CompareExchange com a mesma espera ativa mostrada no código acima (nos filósofos famintos), mas isso, como já foi dito, poderia teoricamente levar ao bloqueio.

Про Interlocked Deve-se notar que não há apenas CompareExchange, mas também outros métodos para leitura E gravação atômica. E através da repetição da alteração, caso outra thread tenha tempo de fazer suas alterações (ler 1, ler 2, escrever 2, escrever 1 está ruim), ela pode ser usada para alterações complexas em um único valor (padrão Interlocked Anything) .

Soluções de Modo Kernel

Para evitar o desperdício de recursos em um loop, vamos ver como podemos bloquear um thread. Ou seja, continuando nosso exemplo, vejamos como o garçom faz o filósofo dormir e só o acorda quando necessário. Primeiro, vamos ver como fazer isso por meio do modo kernel do sistema operacional. Todas as estruturas geralmente são mais lentas do que aquelas no espaço do usuário. Várias vezes mais lento, por exemplo AutoResetEvent talvez 53 vezes mais lento SpinLock [Richter]. Mas com a ajuda deles, você pode sincronizar processos em todo o sistema, gerenciados ou não.

A construção básica aqui é o semáforo proposto por Dijkstra há mais de meio século. Um semáforo é, simplesmente, um inteiro positivo gerenciado pelo sistema e duas operações nele, incremento e decremento. Se não diminuir, zero, o thread de chamada será bloqueado. Quando o número é incrementado por algum outro thread/processo ativo, os threads são ignorados e o semáforo é novamente decrementado pelo número passado. Pode-se imaginar trens em um gargalo com um semáforo. O .NET oferece várias construções com funcionalidades semelhantes: AutoResetEvent, ManualResetEvent, Mutex e eu mesmo Semaphore. Nós vamos usar AutoResetEvent, esta é a mais simples dessas construções: apenas dois valores 0 e 1 (falso, verdadeiro). Seu método WaitOne() bloqueia o thread de chamada se o valor for 0 e, se 1, o reduz para 0 e o ignora. Um método Set() sobe para 1 e deixa passar um garçom, que novamente abaixa para 0. Funciona como uma catraca de metrô.

Vamos complicar a solução e usar a trava para cada filósofo, e não para todos de uma vez. Aqueles. agora pode haver vários filósofos ao mesmo tempo, e não um. Mas voltamos a bloquear o acesso à mesa para poder corretamente, evitando corridas (condições de corrida), fazer apostas seguras.

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

Para entender o que está acontecendo aqui, considere o caso em que o filósofo falhou em pegar os garfos, então suas ações serão as seguintes. Ele está esperando o acesso à mesa. Tendo recebido, ele tenta pegar os garfos. Não funcionou. Dá acesso à mesa (exclusão mútua). E passa sua "catraca" (AutoResetEvent) (estão inicialmente abertos). Ele entra no ciclo novamente, porque ele não tem garfos. Ele tenta pegá-los e para em sua "catraca". Algum vizinho mais afortunado da direita ou da esquerda, tendo acabado de comer, destranca nosso filósofo, "abrindo sua catraca". Nosso filósofo passa por ela (e se fecha atrás dela) pela segunda vez. Ele tenta pela terceira vez pegar os garfos. Boa sorte. E passa na catraca para jantar.

Quando há erros aleatórios em tal código (eles sempre existem), por exemplo, um vizinho é especificado incorretamente ou o mesmo objeto é criado AutoResetEvent para todos (Enumerable.Repeat), então os filósofos estarão esperando pelos desenvolvedores, porque Encontrar erros em tal código é uma tarefa bastante difícil. Outro problema com essa solução é que ela não garante que algum filósofo não passará fome.

Soluções Híbridas

Vimos duas abordagens de temporização, quando permanecemos no modo de usuário e loop e quando bloqueamos o thread por meio do kernel. O primeiro método é bom para fechaduras curtas, o segundo para longas. Freqüentemente, é necessário primeiro esperar brevemente que uma variável mude em um loop e, em seguida, bloquear o thread quando a espera for longa. Esta abordagem é implementada no chamado. estruturas híbridas. Aqui estão as mesmas construções do modo kernel, mas agora com um loop de modo de usuário: SemaphorSlim, ManualResetEventSlim etc. O design mais popular aqui é Monitor, porque em C# existe um conhecido lock sintaxe. Monitor este é o mesmo semáforo com um valor máximo de 1 (mutex), mas com suporte para espera em um loop, recursão, o padrão Condition Variable (mais sobre isso abaixo), etc. Vejamos uma solução com ele.

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

Aqui estamos novamente bloqueando toda a mesa para acesso aos forks, mas agora estamos desbloqueando todos os threads de uma vez, e não os vizinhos quando alguém terminar de comer. Aqueles. primeiro, alguém come e bloqueia os vizinhos, e quando esse alguém termina, mas quer comer de novo na hora, entra em bloqueio e acorda os vizinhos, porque. seu tempo de espera é menor.

Assim evitamos os impasses e a fome de algum filósofo. Usamos um loop para uma espera curta e bloqueamos o thread para uma espera longa. Desbloquear todos de uma vez é mais lento do que desbloquear apenas o vizinho, como na solução com AutoResetEvent, mas a diferença não deve ser grande, porque os threads devem permanecer no modo de usuário primeiro.

У lock sintaxe tem surpresas desagradáveis. Recomendo usar Monitor diretamente [Richter] [Eric Lippert]. Um deles é que lock sempre fora de Monitor, mesmo que houvesse uma exceção, caso em que outro encadeamento poderia alterar o estado da memória compartilhada. Nesses casos, geralmente é melhor entrar em um impasse ou, de alguma forma, encerrar o programa com segurança. Outra surpresa é que o Monitor usa blocos de sincronização (SyncBlock), que estão presentes em todos os objetos. Portanto, se um objeto inapropriado for selecionado, você pode facilmente obter um impasse (por exemplo, se você travar em uma string interna). Usamos o objeto sempre oculto para isso.

O padrão Condition Variable permite que você implemente de forma mais concisa a expectativa de alguma condição complexa. Em .NET, está incompleto, na minha opinião, porque em teoria, deveria haver várias filas em várias variáveis ​​(como em Posix Threads), e não em um lok. Então alguém poderia fazê-los para todos os filósofos. Mas mesmo nesta forma, permite reduzir o código.

muitos filósofos ou async / await

Ok, agora podemos efetivamente bloquear threads. Mas e se tivermos muitos filósofos? 100? 10000? Por exemplo, recebemos 100000 solicitações para o servidor web. Será uma sobrecarga criar um thread para cada solicitação, porque tantos threads não serão executados em paralelo. Só executará tantos quantos forem os núcleos lógicos (eu tenho 4). E todos os outros apenas tirarão recursos. Uma solução para esse problema é o padrão async/await. Sua ideia é que a função não segure o thread se precisar esperar algo para continuar. E quando ele faz algo, ele retoma sua execução (mas não necessariamente no mesmo thread!). No nosso caso, vamos esperar pela bifurcação.

SemaphoreSlim tem para isso WaitAsync() método. Aqui está uma implementação usando esse padrão.

// Запуск такой же, как раньше. Где-нибудь в программе:
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étodo com async / await é traduzido em uma complicada máquina de estado que imediatamente retorna seu Task. Por meio dele, você pode aguardar a conclusão do método, cancelá-lo e tudo mais que puder fazer com o Task. Dentro do método, a máquina de estado controla a execução. O resultado final é que, se não houver atraso, a execução é síncrona e, se houver, o thread será liberado. Para uma melhor compreensão disso, é melhor olhar para esta máquina de estado. Você pode criar cadeias a partir desses async / await métodos.

Vamos testar. Trabalho de 100 filósofos em uma máquina com 4 núcleos lógicos, 8 segundos. A solução anterior com o Monitor executava apenas os primeiros 4 threads e o restante não era executado. Cada um desses 4 threads ficou ocioso por cerca de 2 ms. E a solução async/await executou todas as 100, com uma espera média de 6.8 segundos cada. Obviamente, em sistemas reais, ficar inativo por 6 segundos é inaceitável e é melhor não processar tantas solicitações como essa. A solução com o Monitor acabou não sendo escalável.

Conclusão

Como você pode ver nesses pequenos exemplos, o .NET suporta muitas construções de sincronização. No entanto, nem sempre é óbvio como usá-los. Espero que este artigo tenha sido útil. Por enquanto, este é o fim, mas ainda faltam muitas coisas interessantes, por exemplo, coleções thread-safe, TPL Dataflow, programação reativa, modelo de transação de software, etc.

fontes

Fonte: habr.com

Adicionar um comentário