Конкурентність: Кооперативность

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

Кооперативность
На відміну від витісняючої багатозадачності, яка перериває виконання вашого коду в будь-який час, в будь-якому місці, де побажає, кооперативна є «ручним варіантом», коли ваш код знає про те, що виконується не один, є інші очікують процеси, і він сам вирішує, коли йому передати управління іншим.
При кооперативної багатозадачності важливо не робити тривалих операцій, а якщо і робити — то періодично передавати управління.
Ідеальним варіантом буде, якщо ваша «кооперативна частина» не буде працювати з блокуючим I/O і потужними обчисленнями, а буде використовувати неблокирующее асинхронне API, а ці времязатратние речі будуть винесені «зовні», де будуть виконуватися паралельно «псевдопараллельности».
Корутины
Я говорив, що операційна система schedule'іт потоки, виконуючи їх код певними порціями часу. Але давайте подумаємо, як це в принципі можливо реалізувати. Варіанти виходить два:
  1. Процесор підтримує можливість обірвати виконання інструкцій через якийсь час і виконати якийсь інший заздалегідь заданий код (переривання по таймеру, або, якщо можливо, за кількістю виконаних інструкцій).
  2. Ми городим компілятор машинного коду в машинний код, який буде сам рахувати кількість виконаних інструкцій яким-небудь чином і перерве виконання, коли лічильник досягне якогось межі.
Другий варіант до оверхеду на перемикання контексту (зберегти значення всіх регістрів куди-небудь) додає оверхед на цю модифікацію коду (хоча її можна зробити і AOT), плюс на підрахунок інструкцій у процесі їх виконання (все стане повільніше не більше ніж у два рази, а в більшості випадків — значно менше).
І от коли ми з якихось причин не хочемо (чи не можемо) використовувати переривання процесора по таймеру, а другий варіант-це взагалі корито якийсь — в справу вступає кооперативна багатозадачність. Ми можемо писати функції в такому стилі, що самі говоримо, коли можна перервати її виконання і повыполнять які-небудь інші завдання. Якось так:
void some_name() {
doSomeWork();

yield();

while (true) {
doAnotherWork();

yield();
}

doLastWork();
}

Де при кожному виклику
yield()
система збереже весь контекст функції (значення змінних, що місце, де був викликаний
yield()
) і продовжить виконувати іншу функцію такого ж типу, відновивши її контекст і відновивши виконання з того місця, де вона минулий раз закінчила.
У такого підходу є і плюси і мінуси. З плюсів:
  • Якщо у нас тільки один фізичний потік (або якщо наша група завдань виконується тільки в одному) на деяку частину загальної пам'яті не потрібні блокувань, т. до. ми самі вирішуємо, коли будуть виконуватися інші завдання, і можемо виконувати дії без побоювання, що хтось інший побачить чи втрутиться в них на півдорозі, а там, де блокування будуть потрібні — вони реалізуються просто boolean'ом.
Мінуси:
  • Кванти часу будуть сильно нерівномірними (що не так важливо, головне, щоб вони були досить малі, щоб не були помітні затримки).
  • Якась функція може все-таки створити відчутну затримку, реалізувавшись некоректно. І, що набагато гірше — якщо вона зовсім не поверне керування.
За швидкодією складно говорити. З одного боку, воно може бути швидше, якщо буде змінювати контексти не так часто, як планувальник, може бути повільніше, якщо буде перемикати контексти занадто часто, а з іншого боку — занадто великі затримки між поверненнями управління інших завдань можуть вплинути на UI або I/O, що стане помітно і тоді користувач навряд чи скаже, що воно стало працювати швидше.
Але повернемося до наших корутинам. Корутины (coroutines, співпрограми) мають не одну точку входу і одну виходу (як звичайні функції — підпрограми), а одну стартову, опціонально одну фінальну та довільну кількість пар вихід-вхід.
Для початку розглянемо випадок з нескінченною кількістю виходів (генератор нескінченного списку):
function* serial() {
let i = 0;
while (true) {
yield i++;
}
}

Це Javascript, при виклику функції serial повернеться об'єкт, у якого є метод
next()
, який при послідовних виклики буде повертати нам об'єкти виду
{value: Any, done: Boolean}
, де done false поки виконання генератора не уткнется в кінець блоку функції, а у value — значення, які ми посилаємо yield'ом.
… але крім повернення значення yield може так само і прийняти нові дані всередину. Наприклад, зробимо якийсь такий суматор:
function* sum() {
let total = 0;

while (true) {
let n = yield total;
total += n;
}
}

let s = sum();
s.next(); // 0
s.next(3); // 3
s.next(5); // 8
s.next(7); // 15
s.next(0); // 15

Перший виклик
next()
отримує значення, яке передав перший yield, а потім ми можемо передати в
next()
значення, яке хочемо щоб yield повернув.
Думаю, ви зрозуміли, як це працює. Але якщо поки що не розумієте, як це можна використовувати — почекайте наступній статті, де я розповім про промисах і async/await'е.
Актори
Модель акторів — потужна і досить проста модель паралельних обчислень, що дозволяє досягти одночасно і ефективності і зручності невеликою ціною (про неї далі). Є лише дві сутності: актор (у якого є адреса стан) і повідомлення (довільні дані). При отриманні повідомлення актор може:
  • Діяти в залежності від свого стану
  • Створити нових акторів, він буде знати їх адреса, може поставити їх початкове стан
  • Відправити повідомлення за відомим адресами (в повідомленнях можна відправляти адреса, включаючи свій)
  • Змінити стан
Що добре в актора? Якщо правильно розподіляти ресурси по акторам, то можна повністю позбутися від будь-яких блокувань (хоча, якщо подумати, блокування стають очікуваннями результату, але під час цього очікування ви змушені обробляти інші повідомлення, а не просто чекати).
Крім того, ваш код з великою ймовірністю стане організований куди краще, логічно розділений, вам доведеться добре проробляти API акторів. І актор куди простіше переиспользуется, ніж просто клас, т. к. єдиний спосіб взаємодіяти з ним — це надсилати йому повідомлення і приймати повідомлення від нього на переданих йому адреси, у нього немає жорстких залежностей і неявних зв'язків, а будь-його «зовнішній виклик» легко перехоплюється і кастомізіруєтся.
Ціна цього — черга повідомлень і оверхед на роботу з нею. Кожен актор буде мати чергу надходять йому повідомлень, в якій будуть накопичуватися приходять повідомлення. Якщо він не буде встигати обробляти їх — вона буде рости. У навантажених системах вам доведеться якось вирішувати цю проблему, вигадуючи способи для паралельної обробки, щоб у вас були групи акторів, які роблять якусь одну задачу. Але в цьому випадку черзі дають вам і плюс, т. к. стає дуже легко моніторити місця, де у вас не вистачає продуктивності. Замість однієї метрики «я чекав результату 50мс» у вас для кожного компонента системи з'являється метрика «може обробляти N запитів в хвилину».
Актори можуть бути реалізовані безліччю різних способів: можна створювати для кожного свій потік (але тоді не зможемо створити дійсно багато примірників), а можна створити кілька потоків, які будуть працювати дійсно паралельно і крутити всередині них обробники повідомлень — від цього нічого не зміниться (якщо тільки які-небудь з них не роблять дуже довгих операцій, що буде блокувати виконання інших), а акторів можна буде створити значно більше. Якщо повідомлення сериализуемы, то немає проблем розподілити актори з різних машин, що непогано підвищує можливості до масштабування.
Не буду наводити прикладів, якщо ви зацікавилися, раджу почитати Learn You Some Erlang Great for Good!. Erlang — ЯП, цілком побудований на концепції акторів, а система supervisor'а ів дозволяє робити додатки дійсно відмовостійкими. Не кажучи вже про OTP, задають правильний тон і робить завдання написати погану систему досить складною.


У третій частині перейдемо до найцікавішого — способи організації асинхронних обчислень, коли ми робимо запит на якусь дію, а результат цього запиту отримаємо тільки в невизначеному майбутньому. Без всяких макаронних изделений, callback hell'ів і невизначених станів.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.