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

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

Veamos cómo funciona la programación concurrente y paralela en .Net, usando como ejemplo el Problema del comedor de los filósofos. El plan es este, desde la sincronización de hilos/procesos, hasta el modelo de actor (en las siguientes partes). El artículo puede ser útil para el primer conocido o para refrescar sus conocimientos.

¿Por qué hacerlo en absoluto? Los transistores alcanzan su tamaño mínimo, la ley de Moore se basa en la limitación de la velocidad de la luz y por lo tanto se observa un aumento en el número, se pueden fabricar más transistores. Al mismo tiempo, la cantidad de datos crece y los usuarios esperan una respuesta inmediata de los sistemas. En tal situación, la programación "normal", cuando tenemos un hilo ejecutándose, ya no es efectiva. Debe resolver de alguna manera el problema de la ejecución simultánea o concurrente. Además, este problema existe a diferentes niveles: a nivel de hilos, a nivel de procesos, a nivel de máquinas en la red (sistemas distribuidos). .NET tiene tecnologías de alta calidad y probadas para resolver estos problemas de manera rápida y eficiente.

Tarea

Edsger Dijkstra planteó este problema a sus alumnos ya en 1965. La formulación establecida es la siguiente. Hay un cierto número (generalmente cinco) de filósofos y el mismo número de tenedores. Se sientan en una mesa redonda, tenedores entre ellos. Los filósofos pueden comer de sus platos de comida interminable, pensar o esperar. Para comer un filósofo, debes tomar dos tenedores (el último comparte el tenedor con el primero). Levantar y dejar un tenedor son dos acciones separadas. Todos los filósofos guardan silencio. La tarea es encontrar un algoritmo tal que todos ellos piensen y estén llenos incluso después de 54 años.

Primero, intentemos resolver este problema mediante el uso de un espacio compartido. Los tenedores están sobre la mesa común y los filósofos simplemente los toman cuando están y los devuelven. Aquí hay problemas con la sincronización, ¿cuándo exactamente tomar surebets? ¿y si no hay tenedor? etc. Pero primero, comencemos con los filósofos.

Para iniciar subprocesos, usamos un grupo de subprocesos a través 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);
}

El grupo de subprocesos está diseñado para optimizar la creación y eliminación de subprocesos. Este grupo tiene una cola con tareas y CLR crea o elimina subprocesos según la cantidad de estas tareas. Un grupo para todos los AppDomains. Esta piscina debe usarse casi siempre, porque. no es necesario preocuparse por crear, eliminar hilos, sus colas, etc. Es posible sin un grupo, pero luego debe usarlo directamente Thread, esto es útil para casos en los que se necesita cambiar la prioridad de un hilo, cuando tenemos una operación larga, para un hilo en primer plano, etc.

Другими словами, System.Threading.Tasks.Task la clase es la misma Thread, pero con todo tipo de comodidades: la capacidad de ejecutar una tarea después de un bloque de otras tareas, devolverlas desde funciones, interrumpirlas convenientemente y más. etc. Son necesarios para admitir construcciones asíncronas/en espera (patrón asincrónico basado en tareas, azúcar sintáctico para esperar operaciones de E/S). Hablaremos de esto más tarde.

CancelationTokenSource aquí es necesario para que el subproceso pueda terminarse a la señal del subproceso que llama.

Problemas de sincronización

Filósofos bloqueados

Bien, sabemos cómo crear hilos, intentemos 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 tratamos de tomar el tenedor izquierdo, y luego el tenedor derecho, y si funciona, comemos y los volvemos a colocar. Tomar un tenedor es atómico, es decir dos hilos no pueden tomar uno al mismo tiempo (incorrecto: el primero dice que la bifurcación está libre, el segundo también, el primero toma, el segundo toma). Para esto Interlocked.CompareExchange, que debe implementarse con una instrucción de procesador (TSL, XCHG), que bloquea una parte de la memoria para la lectura y escritura secuencial atómica. Y SpinWait es equivalente a la construcción while(true) solo con un poco de "magia": el hilo toma el procesador (Thread.SpinWait), pero a veces transfiere el control a otro subproceso (Thread.Yeild) o se duerme (Thread.Sleep).

Pero esta solución no funciona, porque los flujos pronto (para mí en un segundo) se bloquean: todos los filósofos toman su bifurcación izquierda, pero no la derecha. La matriz de horquillas tiene los valores: 1 2 3 4 5.

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

En la figura, bloqueo de subprocesos (punto muerto). Verde: ejecución, rojo: sincronización, gris: el subproceso está inactivo. Los rombos indican la hora de inicio de las Tareas.

El hambre de los filósofos

Aunque no es necesario pensar especialmente en mucha comida, pero el hambre hace que cualquiera renuncie a la filosofía. Intentemos simular la situación de inanición de subprocesos en nuestro problema. El hambre es cuando un subproceso se está ejecutando, pero sin un trabajo significativo, en otras palabras, este es el mismo punto muerto, solo que ahora el subproceso no está durmiendo, sino que está buscando activamente algo para comer, pero no hay comida. Para evitar bloqueos frecuentes, volveremos a colocar la horquilla si no podemos tomar otra.

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

Lo importante de este código es que dos de cada cuatro filósofos se olvidan de dejar el tenedor izquierdo. Y resulta que comen más comida, mientras que otros empiezan a pasar hambre, aunque los hilos tienen la misma prioridad. Aquí no están completamente muertos de hambre, porque. los malos filósofos vuelven a poner sus tenedores a veces. Resulta que las buenas personas comen unas 5 veces menos que las malas. Entonces, un pequeño error en el código conduce a una caída en el rendimiento. También vale la pena señalar aquí que es posible una situación rara cuando todos los filósofos toman la bifurcación de la izquierda, no hay una derecha, ponen la izquierda, esperan, toman la izquierda nuevamente, etc. Esta situación es también un hambre, más como un punto muerto. No logré repetirlo. A continuación se muestra una imagen de una situación en la que dos malos filósofos han tomado ambos tenedores y dos buenos se mueren de hambre.

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

Aquí puede ver que los subprocesos se despiertan a veces e intentan obtener el recurso. Dos de los cuatro núcleos no hacen nada (gráfico verde arriba).

Muerte de un filósofo

Bueno, otro problema que puede interrumpir una cena gloriosa de filósofos es si uno de ellos muere repentinamente con tenedores en las manos (y así lo enterrarán). Entonces los vecinos se quedarán sin almorzar. Puede crear un código de ejemplo para este caso usted mismo, por ejemplo, se descarta NullReferenceException después de que el filósofo toma los tenedores. Y, por cierto, la excepción no se manejará y el código de llamada no la detectará (por este AppDomain.CurrentDomain.UnhandledException y etc.). Por lo tanto, se necesitan controladores de errores en los propios subprocesos y con una terminación elegante.

Camarero

Bien, ¿cómo resolvemos este problema de punto muerto, inanición y muerte? Permitiremos que solo un filósofo alcance las bifurcaciones, agregaremos una exclusión mutua de hilos para este lugar. ¿Cómo hacerlo? Supongamos que un camarero está de pie junto a los filósofos, quien da permiso a cualquier filósofo para tomar los tenedores. Cómo hacemos este camarero y cómo le preguntarán los filósofos, las preguntas son interesantes.

La forma más sencilla es cuando los filósofos simplemente pedirán constantemente al camarero acceso a los tenedores. Aquellos. ahora los filósofos no esperarán un tenedor cerca, sino que esperarán o preguntarán al camarero. Al principio, usamos solo el espacio de usuario para esto, en él no usamos interrupciones para llamar a ningún procedimiento del kernel (sobre ellos a continuación).

Soluciones en espacio de usuario

Aquí haremos lo mismo que solíamos hacer con un tenedor y dos filósofos, daremos vueltas en un ciclo y esperaremos. Pero ahora serán todos los filósofos y, por así decirlo, solo un tenedor, es decir. se puede decir que solo comerá el filósofo que tomó este "tenedor de oro" del camarero. Para esto usamos 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 esto es un bloqueador, con, en términos generales, el mismo while(true) { if (!lock) break; }, pero con aún más "magia" que en SpinWait (que se usa allí). Ahora sabe cómo contar a los que esperan, ponerlos a dormir un poco y más. etc En general, hace todo lo posible para optimizar. Pero debemos recordar que este sigue siendo el mismo ciclo activo que consume los recursos del procesador y mantiene el flujo, lo que puede conducir a la inanición si uno de los filósofos se vuelve más prioritario que otros, pero no tiene un tenedor dorado (Problema de inversión de prioridad) . Por lo tanto, lo usamos solo para cambios muy breves en la memoria compartida, sin llamadas de terceros, bloqueos anidados y otras sorpresas.

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

Dibujo para SpinLock. Los arroyos están constantemente "luchando" por el tenedor dorado. Hay fallas: en la figura, el área seleccionada. Los núcleos no se utilizan por completo: solo alrededor de 2/3 por estos cuatro hilos.

Otra solución aquí sería usar solo Interlocked.CompareExchange con la misma espera activa que se muestra en el código anterior (en los filósofos hambrientos), pero esto, como ya se dijo, teóricamente podría conducir al bloqueo.

Про Interlocked Cabe señalar que no sólo hay CompareExchange, pero también otros métodos para lectura y escritura atómicas. Y a través de la repetición de cambios, en caso de que otro subproceso tenga tiempo de realizar sus cambios (leer 1, leer 2, escribir 2, escribir 1 es incorrecto), se puede usar para cambios complejos en un solo valor (patrón Interlocked Anything).

Soluciones de modo kernel

Para evitar desperdiciar recursos en un bucle, veamos cómo podemos bloquear un hilo. En otras palabras, siguiendo con nuestro ejemplo, veamos cómo el camarero pone a dormir al filósofo y lo despierta sólo cuando es necesario. Primero, veamos cómo hacer esto a través del modo kernel del sistema operativo. Todas las estructuras allí suelen ser más lentas que las del espacio de usuario. Varias veces más lento, por ejemplo AutoResetEvent tal vez 53 veces más lento SpinLock [Richter]. Pero con su ayuda, puede sincronizar procesos en todo el sistema, administrados o no.

La construcción básica aquí es el semáforo propuesto por Dijkstra hace más de medio siglo. Un semáforo es, en pocas palabras, un número entero positivo gestionado por el sistema, y ​​dos operaciones sobre él, incremento y decremento. Si no disminuye, cero, entonces el subproceso de llamada está bloqueado. Cuando el número se incrementa por algún otro subproceso/proceso activo, los subprocesos se omiten y el semáforo vuelve a disminuir según el número pasado. Uno puede imaginar trenes en un cuello de botella con un semáforo. .NET ofrece varias construcciones con una funcionalidad similar: AutoResetEvent, ManualResetEvent, Mutex y yo Semaphore. Usaremos AutoResetEvent, esta es la más simple de estas construcciones: solo dos valores 0 y 1 (falso, verdadero). su método WaitOne() bloquea el subproceso de llamada si el valor era 0, y si 1, lo baja a 0 y lo omite. Un método Set() sube a 1 y deja pasar a un camarero, que vuelve a bajar a 0. Actúa como un torniquete del metro.

Compliquemos la solución y usemos el candado para cada filósofo, y no para todos a la vez. Aquellos. ahora bien, puede haber varios filósofos a la vez, y no uno solo. Pero volvemos a bloquear el acceso a la mesa para realizar correctamente, evitando carreras (condiciones de carrera), realizar 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();
}

Para comprender lo que está sucediendo aquí, considere el caso en que el filósofo no pudo tomar los tenedores, entonces sus acciones serán las siguientes. Él está esperando el acceso a la mesa. Habiéndolo recibido, intenta tomar los tenedores. No funciono. Da acceso a la tabla (exclusión mutua). Y pasa su "torniquete" (AutoResetEvent) (inicialmente están abiertos). Entra en el ciclo de nuevo, porque no tiene tenedores. Intenta tomarlos y se detiene en su "torniquete". Algún vecino más afortunado a la derecha oa la izquierda, habiendo terminado de comer, abre 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. Buena suerte. Y pasa su torniquete para cenar.

Cuando hay errores aleatorios en dicho código (siempre existen), por ejemplo, se especifica incorrectamente un vecino o se crea el mismo objeto AutoResetEvent para todos (Enumerable.Repeat), entonces los filósofos estarán esperando a los desarrolladores, porque Encontrar errores en dicho código es una tarea bastante difícil. Otro problema con esta solución es que no garantiza que algún filósofo no pase hambre.

Soluciones Híbridas

Hemos visto dos enfoques de tiempo, cuando permanecemos en modo de usuario y bucle, y cuando bloqueamos el hilo a través del núcleo. El primer método es bueno para cerraduras cortas, el segundo para largas. A menudo es necesario esperar brevemente a que una variable cambie en un ciclo y luego bloquear el hilo cuando la espera es larga. Este enfoque se implementa en el llamado. estructuras híbridas. Aquí están las mismas construcciones que para el modo kernel, pero ahora con un ciclo de modo de usuario: SemaphorSlim, ManualResetEventSlim etc. El diseño más popular aquí es Monitor, porque en C# hay un conocido lock sintaxis. Monitor este es el mismo semáforo con un valor máximo de 1 (mutex), pero con soporte para esperar en un bucle, recursividad, el patrón de variable de condición (más sobre eso a continuación), etc. Veamos una 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í estamos nuevamente bloqueando toda la mesa para acceder a los tenedores, pero ahora estamos desbloqueando todos los hilos a la vez, y no a los vecinos cuando alguien termina de comer. Aquellos. primero, alguien come y bloquea a los vecinos, y cuando este alguien termina, pero quiere volver a comer enseguida, entra en bloqueo y despierta a sus vecinos, porque. su tiempo de espera es menor.

Así evitamos los estancamientos y el hambre de algún filósofo. Usamos un bucle para una espera corta y bloqueamos el hilo para una larga. Desbloquear a todos a la vez es más lento que si solo se desbloqueara el vecino, como en la solución con AutoResetEvent, pero la diferencia no debe ser grande, porque los subprocesos deben permanecer en modo de usuario primero.

У lock la sintaxis tiene sorpresas desagradables. Recomendar a utilizar Monitor directamente [Richter] [Eric Lippert]. uno de ellos es que lock siempre fuera de Monitor, incluso si hubiera una excepción, en cuyo caso otro subproceso podría cambiar el estado de la memoria compartida. En tales casos, a menudo es mejor ir a punto muerto o de alguna manera terminar el programa de manera segura. Otra sorpresa es que Monitor utiliza bloques de sincronización (SyncBlock), que están presentes en todos los objetos. Por lo tanto, si se selecciona un objeto inapropiado, puede obtener fácilmente un interbloqueo (por ejemplo, si bloquea una cadena interna). Usamos el objeto siempre oculto para esto.

El patrón Variable de condición le permite implementar de manera más concisa la expectativa de alguna condición compleja. En .NET está incompleto, en mi opinión, porque en teoría, debería haber varias colas en varias variables (como en Posix Threads), y no en un lok. Entonces se podrían hacer para todos los filósofos. Pero incluso de esta forma, te permite reducir el código.

muchos filósofos o async / await

Bien, ahora podemos bloquear hilos de manera efectiva. Pero, ¿y si tenemos muchos filósofos? 100? 10000? Por ejemplo, recibimos 100000 solicitudes al servidor web. Será una sobrecarga crear un hilo para cada solicitud, porque tantos hilos no se ejecutarán en paralelo. Solo ejecutará tantos núcleos lógicos como haya (tengo 4). Y todos los demás simplemente quitarán recursos. Una solución a este problema es el patrón async/await. Su idea es que la función no retenga el hilo si necesita esperar a que algo continúe. Y cuando hace algo, reanuda su ejecución (¡pero no necesariamente en el mismo hilo!). En nuestro caso, esperaremos a la bifurcación.

SemaphoreSlim tiene para esto WaitAsync() método. Aquí hay una implementación usando 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();
}

Método con async / await se traduce en una máquina de estado complicado que devuelve inmediatamente su interno Task. A través de él, puede esperar la finalización del método, cancelarlo y todo lo demás que puede hacer con Task. Dentro del método, la máquina de estado controla la ejecución. La conclusión es que si no hay retraso, entonces la ejecución es síncrona, y si lo hay, entonces se libera el hilo. Para una mejor comprensión de esto, es mejor observar esta máquina de estados. Puedes crear cadenas a partir de estos async / await métodos.

Probemos. Trabajo de 100 filósofos en una máquina con 4 núcleos lógicos, 8 segundos. La solución anterior con Monitor solo ejecutó los primeros 4 subprocesos y el resto no se ejecutó en absoluto. Cada uno de estos 4 subprocesos estuvo inactivo durante aproximadamente 2 ms. Y la solución async/await ejecutó las 100, con una espera promedio de 6.8 segundos cada una. Por supuesto, en los sistemas reales, la inactividad durante 6 segundos es inaceptable y es mejor no procesar tantas solicitudes como esta. La solución con Monitor resultó no ser escalable en absoluto.

Conclusión

Como puede ver en estos pequeños ejemplos, .NET admite muchas construcciones de sincronización. Sin embargo, no siempre es obvio cómo usarlos. Espero que este artículo haya sido útil. Por ahora, este es el final, pero aún quedan muchas cosas interesantes, por ejemplo, colecciones seguras para subprocesos, flujo de datos TPL, programación reactiva, modelo de transacción de software, etc.

fuentes

Fuente: habr.com

Añadir un comentario