Сытыя філосафы або канкурэнтнае праграмаванне на .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). А ўсе астатнія будуць проста адбіраць рэсурсы. Адно з рашэнняў гэтай праблемы 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 секунд. Вядома, у рэальных сістэмах прастойванне па XNUMX секунд непрымальна і лепш не апрацоўваць столькі запытаў так. Рашэнне ж з Monitor аказалася не маштабуецца наогул.

Заключэнне

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

крыніцы

Крыніца: habr.com

Дадаць каментар