Filósofos alimentados o programación competitiva en .NET

Filósofos alimentados o programación competitiva en .NET

Veamos cómo funciona la programación concurrente y paralela en .Net, utilizando el ejemplo del problema de los filósofos que cenan. El plan es abordar desde la sincronización de hilos/procesos hasta el modelo de actores (en partes posteriores). Este artículo puede ser útil para un primer contacto o para refrescar tus conocimientos.

¿Por qué es importante saber esto? Los transistores están alcanzando su tamaño mínimo, la ley de Moore se enfrenta a la limitación de la velocidad de la luz y, por lo tanto, el crecimiento se observa en la cantidad; se pueden fabricar más transistores. Al mismo tiempo, la cantidad de datos está en aumento y los usuarios esperan una respuesta inmediata de los sistemas. En esta situación, la programación 'convencional', donde tenemos un único hilo de ejecución, ya no es eficiente. Hay que encontrar una manera de resolver el problema de la ejecución simultánea o concurrente. Este problema existe a diferentes niveles: a nivel de hilos, a nivel de procesos, a nivel de máquinas en red (sistemas distribuidos). En .NET hay tecnologías de calidad, probadas a lo largo del tiempo, para resolver rápidamente y de manera efectiva estas tareas.

Tarea

Edsger Dijkstra planteó este problema a sus estudiantes en 1965. La formulación establecida es la siguiente. Hay una cierta cantidad (generalmente cinco) de filósofos y el mismo número de tenedores. Están sentados alrededor de una mesa redonda, con los tenedores entre ellos. Los filósofos pueden comer de sus platos con comida infinita, pensar o esperar. Para comer, un filósofo necesita tomar dos tenedores (el último comparte un tenedor con el primero). Tomar y dejar un tenedor son dos acciones separadas. Todos los filósofos son silenciosos. La tarea es encontrar un algoritmo que permita que todos ellos piensen y estén saciados, incluso después de 54 años.

Primero intentaremos resolver este problema mediante el uso de espacios compartidos. Los tenedores están en una mesa común y los filósofos simplemente los toman cuando están disponibles y los devuelven. Aquí surgen problemas de sincronización: ¿cuándo es el momento adecuado para tomar los tenedores? ¿qué hacer si no hay tenedores disponibles? y demás. Pero primero, pongamos en marcha a los filósofos.

Para iniciar hilos, utilizamos un grupo de hilos a través de Task.Run método:

var cancelTokenSource = new CancellationTokenSource();
Action create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i  create(icopy), cancelTokenSource.Token);
}

El grupo de hilos se creó para optimizar la creación y eliminación de hilos. Este grupo tiene una cola de tareas y CLR crea o elimina hilos dependiendo de la cantidad de estas tareas. Hay un grupo para todos los AppDomain. Este grupo debe usarse casi siempre, ya que no es necesario preocuparse por la creación, eliminación de hilos, sus colas, etc. Se puede hacer sin el grupo, pero entonces tendrás que usar directamente Hilo, es razonable en situaciones donde se necesita cambiar la prioridad de un hilo, cuando tenemos una operación larga, para un hilo de primer plano, etc.

En otras palabras, System.Threading.Tasks.Task clase es la misma Hilo, pero con diversas comodidades: la capacidad de ejecutar una tarea después de un bloque de otras tareas, devolverlas desde funciones, interrumpirlas convenientemente, entre muchas otras. Son necesarias para soportar estructuras async/await (Patrón Asíncrono Basado en Tareas, azúcar sintáctico para esperar operaciones de IO). Hablaremos más de esto después.

CancelationTokenSource es necesario aquí para que el hilo pueda finalizar por sí mismo al recibir una señal del hilo que lo invoca.

Problemas de sincronización

Filósofos bloqueados

Bien, sabemos cómo crear hilos, probemos a almorzar:

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

Aquí, primero intentamos tomar el tenedor izquierdo y luego el derecho; si lo conseguimos, comemos y los devolvemos. Tomar un tenedor es atómico, es decir, dos hilos no pueden tomar uno al mismo tiempo (incorrecto: el primero lee que el tenedor está libre, el segundo también, el primero toma, el segundo toma). Para esto, Interlocked.CompareExchange, que debe implementarse usando la instrucción del procesador (TSL, XCHG), que bloquea la sección de memoria para una lectura y escritura atómica secuencial. Y SpinWait es equivalente a la construcción while(true) , solo que con un poco de «magia» — el hilo ocupa el procesador (Thread.SpinWait), pero a veces cede el control a otro hilo (Thread.Yeild) o duerme (Thread.Sleep).

Pero esta solución no funciona, ya que los hilos pronto (en mi caso, en un segundo) se bloquean: todos los filósofos toman su tenedor izquierdo, pero no el derecho. El arreglo forks entonces tiene los valores: 1 2 3 4 5.

Filósofos alimentados o programación competitiva en .NET

En la imagen, el bloqueo de hilos (deadlock). En verde, ejecución; en rojo, sincronización; en gris, el hilo está durmiendo. Los rombos indican el tiempo de inicio de las tareas.

El hambre de los filósofos

Aunque para pensar no se necesita especialmente mucha comida, el hambre puede hacer que cualquiera abandone la filosofía. Intentemos modelar una situación de inanición de hilos en nuestra tarea. La inanición es cuando un hilo está activo, pero sin trabajo sustancial; en otras palabras, es similar a un deadlock, solo que ahora el hilo no está durmiendo, sino que busca activamente cómo comer, pero no hay comida. Para evitar bloqueos frecuentes, dejaremos el tenedor atrás si no podemos tomar otro.

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

En este código es importante notar que dos de los cuatro filósofos olvidan devolver su tenedor izquierdo. Así que resulta que comen más, mientras que otros comienzan a pasar hambre, a pesar de que los hilos tienen la misma prioridad. Aquí no están completamente hambrientos, ya que los malos filósofos a veces devuelven sus tenedores. Según mis observaciones, los buenos comen alrededor de cinco veces menos que los malos. Así que un pequeño error en el código conduce a una caída en el rendimiento. También es importante destacar que puede haber una situación rara en la que todos los filósofos toman el tenedor izquierdo, pero no el derecho, lo dejan, esperan y vuelven a tomar el izquierdo, y así sucesivamente. Esta situación también es inanición, más parecida a un bloqueo mutuo. No pude reproducirla. A continuación, hay una imagen que ilustra la situación en la que dos filósofos malos han tomado ambos tenedores, mientras que los dos buenos pasan hambre.

Filósofos alimentados o programación competitiva en .NET

Aquí se puede ver que los hilos a veces se despiertan y tratan de obtener recursos. Dos núcleos de cuatro no hacen nada (gráfico verde en la parte superior).

Muerte de un filósofo

Y otro problema que puede interrumpir el glorioso almuerzo de los filósofos es si uno de ellos muere de repente con los tenedores en las manos (y así lo entierran). Entonces, los vecinos se quedarán sin almuerzo. Puedes imaginar un ejemplo de código para este caso, por ejemplo, se lanza NullReferenceException después de que el filósofo toma los tenedores. Y, por cierto, la excepción no será manejada, y el código que la llama no la capturará (para esto AppDomain.CurrentDomain.UnhandledException etc.). Por lo tanto, los manejadores de errores son necesarios en los propios hilos y para una finalización adecuada.

Camarero

Bien, ¿cómo podemos resolver este problema de bloqueo mutuo, inanición y muertes? Permitiremos que solo un filósofo tenga acceso a los tenedores, añadiendo una exclusión mutua (mutual exclusion) para este lugar. ¿Cómo podemos hacer esto? Supongamos que hay un camarero junto a los filósofos, que otorga permiso a un solo filósofo para tomar los tenedores. Interesantes son las preguntas de cómo configurar a este camarero y cómo los filósofos le solicitarán esto.

La forma más sencilla es cuando los filósofos simplemente piden constantemente al camarero acceso a los tenedores. Es decir, ahora los filósofos no esperarán a tener un tenedor al lado, sino que esperarán o pedirán al camarero. Primero usaremos solo el espacio de usuario, en el cual no utilizamos interrupciones para invocar ningún procedimiento del núcleo (de esto hablaremos más adelante).

Soluciones en el espacio de usuario

Aquí haremos lo mismo que hacíamos antes con un tenedor y dos filósofos, giraremos en un ciclo y esperaremos. Pero ahora serán todos los filósofos y solo habrá un tenedor, es decir, se puede decir que solo comerá aquel filósofo que tome este "tenedor dorado" del camarero. Para esto utilizaremos SpinLock.

private static SpinLock spinLock = new SpinLock();  // Nuestro "camarero"
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Bloqueo mutuo a través de busy waiting. Llamamos hasta try, para
        // lanzar una excepción en caso de error en el mismo SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Aquí solo puede haber un hilo (mutual exclusion).
            forks[Left(i)] = i + 1;  // Tomamos el tenedor directo, sin esperar.
            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();  // Evitamos el problema de la muerte del filósofo.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock es un bloqueador, con, a grandes rasgos, lo mismo while(true) { if (!lock) break; }, pero con aún más "magia" que en SpinWait (que se utiliza allí). Ahora puede contar los que están a la espera, calmarlos un poco y mucho más. En general, hace todo lo posible por optimizar. Pero hay que recordar que sigue siendo el mismo ciclo activo, que consume recursos de CPU y mantiene el flujo, lo que puede llevar a la inanición si uno de los filósofos se prioriza sobre los demás, pero no tiene el tenedor dorado (problema de inversión de prioridad). Por lo tanto, lo usamos solo para cambios muy, muy cortos en la memoria compartida, sin llamadas externas, bloqueos anidados ni demás sorpresas.

Filósofos alimentados o programación competitiva en .NET

Ilustración para SpinLock. Los hilos están constantemente «en guerra» por el tenedor dorado. Ocurren fallos: en la ilustración, el área marcada. Los núcleos no están completamente utilizados: solo alrededor de 2/3 por esos cuatro hilos.

Otra solución aquí sería usar solo Interlocked.CompareExchange con la misma espera activa, como se muestra en el código anterior (en filósofos hambrientos), pero esto, como ya se mencionó, teóricamente puede llevar a un bloqueo.

Sobre Interlocked Vale la pena decir que allí no solo hay CompareExchange, sino también otros métodos para lectura y escritura atómica. Y a través de la repetición de cambios en caso de que otro hilo logre realizar sus modificaciones (lectura 1, lectura 2, escritura 2, escritura 1 mala), puede ser utilizado para cambios complejos de un solo valor (patrón Interlocked Anything).

Soluciones en modo núcleo

Para evitar la pérdida de recursos en el ciclo, veamos cómo se puede bloquear un hilo. En otras palabras, continuando con nuestro ejemplo, veamos cómo el camarero puede calmar al filósofo y despertarlo solo cuando sea necesario. Primero, consideremos cómo hacerlo a través del modo núcleo del sistema operativo. Todas las estructuras allí suelen ser más lentas que las que están en el espacio de usuario. Más lentas varias veces, por ejemplo, AutoResetEvent puede ser hasta 53 veces más lento SpinLock [Richter]. Pero con su ayuda se pueden sincronizar procesos en todo el sistema, gestionados o no.

La construcción principal aquí es el semáforo, propuesto por Dijkstra hace más de medio siglo. Un semáforo es, simplificando, un número entero positivo, controlado por el sistema, con dos operaciones sobre él: incrementar y decrementar. Si no se puede decrementar, es cero, el hilo que llama se bloquea. Cuando el número es incrementado por otro hilo/proceso activo, entonces los hilos son permitidos, y el semáforo vuelve a decrementar en el número que han pasado. Se puede imaginar trenes en un lugar estrecho con un semáforo. .NET ofrece varias construcciones con funciones similares: AutoResetEvent, ManualResetEvent, Mutex y mismo Semaphore. Vamos a utilizar AutoResetEvent, esta es la más simple de estas construcciones: solo tiene dos valores 0 y 1 (falso, verdadero). Su método WaitOne() bloquea el hilo que llama, si el valor era 0, y si es 1, lo baja a 0 y lo permite. Y el método Set() lo incrementa a 1 y permite a uno que está esperando, quien lo vuelve a bajar a 0. Funciona como un torniquete en el metro.

Complicaremos la solución y usaremos un bloqueo para cada filósofo, no para todos a la vez. Es decir, ahora puede haber varios filósofos simultáneamente, no solo uno. Pero nuevamente bloqueamos el acceso a la mesa, para tomar los tenedores correctamente, evitando condiciones de carrera.

// Для блокирования отдельного философа.
// Инициализируется: 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 qué está ocurriendo aquí, consideremos el caso en que al filósofo no le fue posible tomar los tenedores, entonces sus acciones serán las siguientes. Él espera acceso a la mesa. Al obtenerlo, intenta tomar los tenedores. No tuvo éxito. Libera el acceso a la mesa (exclusión mutua). Y pasa por su "torniquete" (AutoResetEvent) (al principio están abiertos). Vuelve a entrar en el ciclo, ya que no tiene tenedores. Intenta tomarlos y se detiene en su "torniquete". Algún vecino más afortunado a la derecha o a la izquierda, al terminar de comer, desbloquea a nuestro filósofo, "abriendo su torniquete". Nuestro filósofo lo pasa (y se cierra detrás de él) por segunda vez. Intenta por tercera vez tomar los tenedores. Tiene éxito. Y pasa por su torniquete para almorzar.

Cuando en tal código ocurran errores aleatorios (siempre los hay), por ejemplo, si se indica incorrectamente un vecino o se crea el mismo objeto AutoResetEvent para todos (Enumerable.Repeat), entonces los filósofos estarán esperando a los desarrolladores, ya que encontrar errores en tal código es una tarea bastante complicada. Otro problema con esta solución es que no garantiza que algún filósofo no empiece a pasar hambre.

Soluciones híbridas

Hemos considerado dos enfoques para la sincronización, cuando permanecemos en modo usuario y giramos en un ciclo, y cuando bloqueamos el hilo a través del núcleo. El primer método es bueno para bloqueos cortos, el segundo para largos. A menudo se requiere esperar brevemente un cambio de variable en un ciclo y luego bloquear el hilo cuando la espera es larga. Este enfoque se implementa en las llamadas estructuras híbridas. Aquí hay las mismas estructuras que había para el modo núcleo, pero ahora con un ciclo en modo usuario: SemaphoreSlim, ManualResetEventSlim y otros. La estructura más popular aquí es Monitor, ya que en C# hay un conocido lock sintaxis. Monitor Es el mismo semáforo con un valor máximo de 1 (mutex), pero con soporte para espera en ciclo, recursión, patrón de Variable de Condición (del que hablaremos a continuación) y más. Vamos a ver la solución con él.

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

Aquí bloqueamos nuevamente toda la mesa para acceder a los tenedores, pero ahora desbloqueamos a todos los hilos simultáneamente, no solo a los vecinos, cuando alguien termina de comer. Es decir, primero, alguien come y bloquea a los vecinos, y cuando esa persona termina, pero quiere comer de nuevo de inmediato, entra en bloqueo y despierta a sus vecinos, ya que su tiempo de espera es menor.

De esta manera evitamos los deadlocks y el hambre de algún filósofo. Usamos un ciclo para una breve espera y bloqueamos el hilo para lo largo. Desbloquear a todos inmediatamente es más lento que si se desbloqueara solo al vecino, como en la solución con AutoResetEvent, pero la diferencia no debería ser grande, ya que los hilos deberían permanecer en modo usuario primero.

En lock La sintaxis tiene sorpresas desagradables. Se recomienda usar Monitor directamente [Richter] [Eric Lippert]. Una de ellas es que lock siempre sale de Monitor, incluso si hubo una excepción, y entonces otro hilo puede cambiar el estado de la memoria compartida. En tales casos, a menudo es mejor entrar en un deadlock o finalizar el programa de manera segura. Otra sorpresa es que Monitor utiliza bloques de sincronización (SyncBlock), que están en todos los objetos. Por lo tanto, si se elige un objeto inadecuado, se puede fácilmente obtener un deadlock (por ejemplo, si se bloquea una cadena internada). Siempre usamos un objeto oculto para esto.

El patrón Condition Variable permite implementar de manera más concisa la espera de una condición compleja. En .NET, es incompleto a mi entender, ya que en teoría debería haber varias colas para múltiples variables (como en Posix Threads), en lugar de una sola. Entonces se podría hacer para todos los filósofos. Pero, incluso en esta forma, permite reducir el código.

Muchos filósofos o async / await

Bien, ahora sabemos cómo bloquear hilos de manera eficiente. Pero, ¿qué pasa si tenemos muchos filósofos? ¿100? ¿10,000? Por ejemplo, tenemos 100,000 solicitudes en el servidor web. Crear un hilo para cada solicitud resultará en un costo elevado, ya que no se podrán ejecutar tantas hilos en paralelo. Solo se ejecutarán tantos como núcleos lógicos haya (yo tengo 4). Y todos los demás simplemente consumirán recursos. Una de las soluciones a este problema es el patrón async / await. La idea es que la función no mantiene un hilo, si necesita esperar por algo para continuar. Y cuando eso sucede, retoma su ejecución (¡pero no necesariamente en el mismo hilo!). En nuestro caso, nosotros esperaremos el tenedor.

SemaphoreSlim tiene para esto WaitAsync() método. Aquí está la implementación utilizando este patrón.

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

El método con async / await se traduce en un astuto autómata de estados que devuelve inmediatamente su interno Task. A través de él, se puede esperar la finalización del método, cancelarlo y hacer todo lo que se puede hacer con un Task. Dentro del método, el autómata controla la ejecución. La esencia es que si no hay retraso, la ejecución es sincrónica, y si lo hay, el hilo se libera. Para una mejor comprensión de esto, es mejor observar este autómata. Se pueden crear cadenas a partir de estos async / await métodos.

Hagamos pruebas. Trabajo de 100 filósofos en una máquina con 4 núcleos lógicos, 8 segundos. La solución anterior con Monitor solo ejecutaba los primeros 4 hilos, y los demás ni siquiera se ejecutaban. Cada uno de estos 4 hilos estaba inactivo durante unos 2 ms. La solución con async / await ejecutaba todos los 100, mientras que cada uno esperaba un promedio de 6.8 segundos. Por supuesto, en sistemas reales, una inactividad de 6 segundos es inaceptable y es mejor no manejar tantas solicitudes así. La solución con Monitor resultó no ser escalable en absoluto.

Conclusión

Como se puede ver en estos pequeños ejemplos, .NET soporta muchas construcciones de sincronización. Sin embargo, no siempre es evidente cómo usarlas. Espero que este artículo haya sido útil. Por ahora terminamos aquí, pero aún queda mucho interesante por explorar, como las colecciones seguras para hilos, TPL Dataflow, programación reactiva, el modelo de transacciones de software, entre otros.

Fuentes

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster