Ситі філософи чи конкурентне програмування на .NET

Ситі філософи чи конкурентне програмування на .NET

Давайте подивимося як влаштовано конкурентне та паралельне програмування в .Net, на прикладі проблеми філософів, що обідають. План такий, від синхронізації потоків/процесів до моделі акторів (у наступних частинах). Стаття може бути корисною для першого знайомства або для того, щоб освіжити свої знання.

Навіщо взагалі це вміти? Транзистори досягають свого мінімального розміру, закон Мура впирається в обмеження швидкості світла і тому зростання спостерігається у кількості, транзисторів можна робити більше. При цьому кількість даних зростає, а користувачі очікують на негайну реакцію систем. У такій ситуації «звичайне» програмування, коли в нас один потік, що виконує, вже не ефективно. Потрібно якось вирішувати проблему одночасного чи конкурентного виконання. Причому проблема ця існує на різних рівнях: на рівні потоків, на рівні процесів, на рівні машин у мережі (розподілені системи). У .NET є якісні, перевірені часом технології для швидкого та ефективного вирішення таких завдань.

Завдання

Едсгер Дейкстра ставив цю проблему своїм учням ще в 1965. Усталене формулювання таке. Є деяка (зазвичай п'ять) кількість філософів і стільки ж виделок. Вони сидять за круглим столом, виделки між ними. Філософи можуть їсти зі своїх тарілок з нескінченною їжею, думати чи чекати. Щоб поїсти філософу, потрібно взяти дві виделки (останній ділить вилку з першим). Взяти та покласти вилку – дві окремі дії. Усі філософи безмовні. Завдання знайти такий алгоритм, щоб усі вони думали і були ситі через 54 роки.

Спочатку спробуємо вирішити це завдання через використання місця, що розділяється. Виделки лежать на загальному столі, і філософи просто їх беруть, коли вони є, і кладуть назад. Тут з'являються проблеми із синхронізацією, коли саме брати вилки? що робити, якщо вилки немає? та ін Але спочатку давайте запустимо філософів.

Для запуску потоків використовуємо пул потоків через Task.Run метод:

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

Пул потоків створений для оптимізації створення та видалення потоків. Цей пул має чергу з завданнями і CLR створює або видаляє потоки в залежності від кількості цих завдань. Один пул на всі AppDomain'и. Цей пул варто використати майже завжди, т.к. не потрібно морочитися зі створенням, видаленням потоків, їх чергами та ін. Можна і без пулу, але тоді доведеться безпосередньо використовувати ThreadЦе доцільно для випадків, коли потрібно змінити пріоритет потоку, коли у нас довга операція, для Foreground потоку та ін.

Іншими словами, System.Threading.Tasks.Task клас - це той же Thread, але з будь-якими зручностями: можливість запускати тяг після блоку інших тяг, повертати їх з функцій, зручно їх переривати і багато інших. ін. Вони потрібні для підтримки async/await конструкцій (Task-based Asynchronous Pattern, синтаксичний цукор для очікування IO операції). Про це ще поговоримо.

CancelationTokenSource тут потрібен, щоб потік міг сам завершиться сигналом викликаючого потоку.

Проблеми із синхронізацією

Блоковані філософи

Добре, ми вміємо створювати потоки, спробуємо пообідати:

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

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

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

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

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

Тут ми спочатку пробуємо взяти ліву, а потім праву вилки і якщо вийшло, то їмо і кладемо їх назад. Взяття однієї вилки атомарне, тобто. два потоки не можуть взяти одну одночасно (невірно: перший читає, що вилка вільна, другий теж, перший бере, другий бере). Для цього Interlocked.CompareExchange, який має бути реалізований за допомогою інструкції процесора (TSL, XCHG), яка блокує ділянку пам'яті для атомарного послідовного читання та запису. А SpinWait еквівалентно конструкції while(true) тільки з невеликою магією - потік займає процесор (Thread.SpinWait), але іноді передає управління іншому потоку (Thread.Yeild) або засинає (Thread.Sleep).

Але це рішення працює, т.к. потоки скоро (у мене протягом секунди) блокуються: всі філософи беруть свою ліву вилку, а правої немає. Масив forks тоді має значення: 1 2 3 4 5.

Ситі філософи чи конкурентне програмування на .NET

На малюнку блокування потоків (deadlock). Зеленим кольором – виконання, червоним – синхронізація, сірим – потік спить. Ромбиками вказано час запуску Task'ів.

Голод філософів

Хоча щоб мислити особливо багато їжі не потрібно, але голод будь-кого змусить кинути філософію. Спробуємо змоделювати ситуацію голодування потоків у нашому завданні. Голодування - це коли потік працює, але без істотної роботи, тобто це той же дідлок, тільки тепер потік не спить, а активно шукає поїсти, але їжі немає. Для того, щоб уникнути частого блокування кладемо вилку назад, якщо не змогли взяти інше.

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

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

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

У цьому коді важливо те, що два з чотирьох філософів забувають покласти свою ліву вилку. І виходить, що вони їдять більше їжі, а інші починають голодувати, хоча потоки мають однаковий пріоритет. Тут вони зовсім голодують, т.к. погані філософи кладуть свої виделки іноді назад. У мене виходить, що добрі їдять десь у 5 разів менше, ніж погані. Так невелика помилка коду призводить до того, що падає продуктивність. Тут ще варто зауважити, що можлива рідкісна ситуація, коли всі філософи беруть ліву вилку, правої немає, вони кладуть ліву, чекають, знову беруть ліву тощо. Ця ситуація теж голодування, більше схожа на взаємне блокування. Повторити її мені не вдалося. Нижче картинка для ситуації, коли два погані філософи забрали обидві виделки, а два хороших голодують.

Ситі філософи чи конкурентне програмування на .NET

Тут видно, що потоки прокидаються іноді і намагаються отримати ресурс. Два ядра з чотирьох нічого не роблять (зелений графік вгорі).

Смерть філософа

Ну і ще одна проблема, яка може перервати славний обід філософів — це якщо один із них раптово помре з вилками в руках (і його так і поховають). Тоді сусіди залишаться без обіду. Приклад коду для цього випадку ви можете вигадати і самі, наприклад викидається NullReferenceException після того, як філософ бере виделки. І, між іншим, виняток буде не обробленим і код його просто так не спіймає (для цього AppDomain.CurrentDomain.UnhandledException та ін.). Тому обробники помилок необхідні у самих потоках і з коректним завершенням.

Офіціант

Добре, як нам вирішити цю проблему із взаємними блокуваннями, голодуванням та смертями? Допускатимемо лише одного філософа до виделок, додамо взаємний виняток (mutual exclusion) потоків для цього місця. Як це зробити? Припустимо, що поруч із філософами стоїть офіціант, який дає дозвіл якомусь одному філософу взяти виделки. Як нам зробити цього офіціанта і як філософи проситимуть його, питання цікаві.

Найпростіший спосіб — це коли філософи просто постійно проситимуть офіціанта дати доступ до виделок. Тобто. тепер філософи не чекатимуть вилку поруч, а чекатимуть чи проситимуть офіціанта. Спочатку використовуємо для цього тільки User Space, в ньому ми не використовуємо переривання для виклику будь-яких процедур з ядра (про них нижче).

Рішення у просторі користувача

Тут робитимемо теж, що раніше робили з однією вилкою та двома філософами, крутитимемося в циклі і чекатимемо. Але тепер це будуть усі філософи і тільки одна вилка, тобто. можна сказати буде їсти лише той філософ, який узяв цю «золоту вилку» в офіціанта. Для цього використовуємо SpinLock.

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

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock це блокувальник, з, грубо кажучи, тим самим while(true) { if (!lock) break; }, але з ще більшою «магією», ніж у SpinWait (який там використовується). Тепер він вміє вважати тих, хто чекає, трохи присипляти їх і багато чого. ін Загалом, робить все можливе для оптимізації. Але треба пам'ятати, що це все той же активний цикл, який їсть ресурси процесора і тримає потік, який може призвести до голодування, якщо один з філософів ставати пріоритетнішим за інших, але не має золотої виделки (Priority Inversion problem). Тому використовуємо його тільки для дуже коротких змін у спільній пам'яті, без будь-яких сторонніх викликів, вкладених блокувань та ін. сюрпризів.

Ситі філософи чи конкурентне програмування на .NET

Малюнок для SpinLock. Потоки постійно «воюють» за золоту вилку. Трапляються провали - на малюнку виділена область. Ядра використовуються не повністю: лише близько 2/3 цими чотирма потоками.

Інше рішення тут було б використати тільки Interlocked.CompareExchange з тим же активним очікуванням, як показано в коді вище (у голодуючих філософах), але це, як уже було сказано, теоретично може призвести до блокування.

Про Interlocked варто сказати, що там не тільки CompareExchange, але й інші методи для атомарного читання та запису. А через повтор зміни у разі, якщо інший потік встигає внести свої зміни (читання 1, читання 2, запис 2, запис 1 погана), він може використовуватися для складних змін одного значення (Interlocked Anything паттерн).

Рішення у режимі ядра

Щоб уникнути втрати ресурсів у циклі, подивимося, як можна блокувати потік. Іншими словами, продовжуючи наш приклад, подивимося, як офіціант приспатиме філософа і розбудить його тільки тоді, коли треба. Спершу розглянемо, як це зробити через режим ядра операційної системи. Усі структури там часто виявляються повільнішими, ніж ті, що у просторі користувача. Повільніше в кілька разів, наприклад AutoResetEvent може бути в 53 рази повільніше SpinLock [Ріхтер]. Але з їхньою допомогою можна синхронізувати процеси по всій системі, керовані чи ні.

Основна конструкція тут це семафор, запропонований Дейкстрой понад півстоліття тому. Семафор це, спрощено кажучи, позитивне ціле число, кероване системою, і дві операції на ньому — збільшити та зменшити. Якщо зменшити не виходить, нуль, то потік, що викликає, блокується. Коли число збільшується якимось іншим активним потоком/процесом, тоді потоки пропускаються, а семафор знову зменшується число минулих. Можна уявити поїзди у вузькому місці із семафором. .NET пропонує кілька конструкцій з подібними функціями: AutoResetEvent, ManualResetEvent, Mutex і сам Semaphore. Ми будемо використовувати AutoResetEvent, це найпростіша з цих конструкцій: лише два значення 0 та 1 (false, true). Її метод WaitOne() блокує потік, що викликає, якщо значення було 0, а якщо 1, то знижує до 0 і пропускає його. А метод Set() підвищує до 1 і пропускає одного, хто чекає, який знову знижує до 0. Діє, як турнікет у метро.

Ускладнимо рішення і будемо використовувати блокування для кожного філософа, а не для всіх відразу. Тобто. тепер їсти можуть відразу кілька філософів, а не один. Але ми знову блокуємо доступ до столу, щоб коректно, уникаючи гонок (race conditions), взяти виделки.

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

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

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

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

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

Щоб зрозуміти, що тут відбувається, розглянемо випадок, коли філософові не вдалося взяти виделки, тоді його дії будуть такими. Він чекає на доступ до столу. Отримавши його, він намагається взяти виделки. Не вийшло. Він надає доступ до столу (взаємний виняток). І проходить свій «турнікет» (AutoResetEvent) (спочатку вони відкриті). Попадає знову у цикл, т.к. у нього немає виделок. Пробує взяти їх і зупиняється у свого турнікета. Якийсь більш щасливий сусід праворуч чи ліворуч, закінчивши їсти, розблокує нашого філософа, «відкриваючи його турнікет». Наш філософ проходить його (і він закривається за ним) вдруге. Пробує втретє взяти виделки. Вдало. І проходить свій турнікет, щоб пообідати.

Коли в такому коді будуть випадкові помилки (вони завжди є), наприклад, буде невірно вказаний сусід або створений один і той же об'єкт AutoResetEvent для всіх (Enumerable.Repeat), Тоді філософи чекатимуть вже розробників, т.к. пошук помилок у такому коді досить складне заняття. Ще одна проблема з цим рішенням у тому, що воно не гарантує, що якийсь філософ не почне голодувати.

Гібридні рішення

Ми розглянули два підходи до синхронізації, коли ми залишаємося в режимі користувача і крутимося в циклі і коли ми блокуємо потік через ядро. Перший метод добрий для коротких блокувань, другий для тривалих. Часто потрібно спочатку коротко очікувати на зміну змінної в циклі, а потім заблокувати потік, коли очікування довге. Цей підхід реалізовано у т.зв. гібридних конструкцій. Тут є ті ж конструкції, що були для режиму ядра, але тепер із циклом у режимі користувача: SemaphorSlim, ManualResetEventSlim та ін. Найпопулярніша конструкція тут це Monitor, т.к. у C# є відомий всім lock синтаксису. Monitor це той самий семафор з максимальним значенням 1 (мютекс), але з підтримкою очікування в циклі, рекурсії, Condition Variable патерну (про нього нижче) та ін. Подивимося на рішення з ним.

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

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

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

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

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

Тут ми знову блокуємо весь стіл для доступу до вилок, але тепер ми розблокуємо всі відразу потоки, а не сусідів, коли хтось закінчує їсти. Тобто. спочатку, хтось їсть і блокує сусідів, а коли цей хтось закінчує, але хоче відразу знову їсти, він іде у блокування і будить своїх сусідів, т.к. його час очікування менший.

Так ми уникаємо дідлок і голодування якогось філософа. Використовуємо цикл для короткого очікування та блокуємо потік для довгого. Розблокування відразу всіх працює повільніше, ніж якби було розблокування тільки сусіда, як у рішенні з AutoResetEvent, але різниця має бути великий, т.к. потоки повинні залишитися у режимі користувача спочатку.

У lock синтаксису є неприємні сюрпризи. Рекомендують використовувати Monitor безпосередньо [Ріхтер] [Ерік Ліпперт]. Один із них у тому, що lock завжди виходить із Monitorнавіть якщо був виняток, і тоді інший потік може змінити стан загальної пам'яті. У таких випадках частіше краще йти в дідлок або якось безпечно завершувати програму. Інший сюрприз у тому, що Monitor використовує блоки синхронізації (SyncBlock), які є у всіх об'єктах. Тому, якщо вибрано невідповідний об'єкт, можна легко отримати дідлок (наприклад, якщо зробити лок на інтернований рядок). Використовуємо завжди прихований об'єкт для цього.

Condition Variable патерн дозволяє коротше реалізувати очікування якоїсь складної умови. У .NET він неповний, мій погляд, т.к. за ідеєю там мають бути кілька черг на кількох змінних (як у Posix Threads), а не на одному лоці. Тоді можна було б зробити їх всім філософів. Але й у такому вигляді він дозволяє скоротити код.

Багато філософів чи async / await

Добре тепер ми вміємо ефективно блокувати потоки. Але, а якщо в нас стає багато філософів? 100? 10000? Наприклад, нам надійшло 100000 4 запитів на веб-сервер. Створювати кожному за запиту потік буде накладним, т.к. стільки потоків паралельно не виконуватимуться. Виконуватимуться лише стільки, скільки логічних ядер (у мене XNUMX). А решта просто відніматиме ресурси. Одне з рішень цієї проблеми async / await патерн. Ідея його в тому, що функція не тримає потік, якщо для її продовження потрібно щось зачекати. А коли вона це щось відбувається, вона відновлює своє виконання (але не обов'язково в тому ж потоці!). У нашому випадку, ми чекатимемо вилки.

SemaphoreSlim має для цього WaitAsync() метод. Ось реалізація з використанням цього патерну.

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

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

        Think(i);

        if (token.IsCancellationRequested) break;
    }
}

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

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

Метод з async / await транслюється в хитрий кінцевий автомат, який одразу повертає свій внутрішній Task. Через нього можна чекати завершення методу, скасовувати його та інше, що можна робити з Task. Усередині методу кінцевий автомат контролює виконання. Суть у тому, що якщо немає затримки, виконання синхронне, а якщо є, то потік звільняється. Для кращого розуміння цього краще переглянути цей кінцевий автомат. Можна створювати ланцюжки з цих async / await методів.

Потестуємо. Робота 100 філософів на машині із 4 логічними ядрами, 8 секунд. Попереднє рішення з Monitor виконувало лише 4 перші потоки, а решта взагалі не виконувалася. Кожен із цих 4 потоків простоював близько 2мс. А рішення з async/await виконувало всі 100, при цьому в середньому кожен чекав 6.8 секунд. Звичайно, в реальних системах простоювання по 6 секунд неприйнятне і краще не обробляти стільки запитів. Рішення ж з Monitor виявилося не масштабованим взагалі.

Висновок

Як видно із цих невеликих прикладів, .NET підтримує багато конструкцій синхронізації. Не завжди, втім, очевидно, як їх використати. Сподіваюся, ця стаття виявилася корисною. Поки що на цьому завершуємо, але залишилося ще багато цікавого, наприклад, потокобезпечні колекції, TPL Dataflow, Reactive програмування, Software Transaction модель та ін.

Джерела

Джерело: habr.com

Додати коментар або відгук