Конкурентність: Асинхронність

Ми все-таки змогли дійти до третьої частини і дісталися до самого цікавого — організації асинхронних обчислень.
В минулих двох статтях ми подивилися на абстракцію паралельно виконується коду і кооперативного виконання обробників завдань.
Тепер подивимося, як можна управляти потоком виконання (control flow) у разі обробки асинхронних завдань.

Асинхронність
Синхронні операції — операції, при яких ми отримуємо результат у результаті блокування потоку виконання. Для простих обчислювальних операцій (додавання/множення чисел) — це єдиний варіант їх вчинення, для операцій вводу/виводу — один з, при цьому ми говоримо, наприклад, «спробуй прочитати з файлу що-небудь за 100мс», і якщо для читання нічого немає — потік виконання буде заблокований на ці 100мс.
У деяких випадках це можливо (наприклад, якщо ми робимо просту консольний додаток, або будь-яку утиліту, мета якої — відпрацювати і все), але в деяких — ні. Наприклад, якщо ми застрягнемо в потоці, в якому обробляється UI — наше додаток зависне. За прикладами далеко ходити не потрібно — якщо javascript на сайті зробить
while(true);
, то перестануть викликати які-небудь інші обробники подій сторінки і її доведеться закрити. Ті ж справи, якщо почати що-небудь обчислювати під На ом в обробниках UI-подій (код яких викликається в UI-потоці), це призведе до появи вікна «додаток не відповідає, закрити?» (подібні вікна викликаються watchdog-таймер, який скидається, коли виконання повертається назад до системи UI).
Асинхронні операції — операції, при яких ми просимо здійснити деяку операцію і можемо якимось чином відслідковувати процес/результат її виконання. Коли вона буде виконана — невідомо, але ми можемо продовжити займатися іншими справами.
Event loop
Event loop — це нескінченний цикл, який бере події з черги і як їх обробляє. А в деяких проміжках — дивиться, чи не сталося якихось IO-подій, або не просрочились якісь таймери — тоді додає в чергу подія про це, щоб потім обробити.
Повернемося до прикладу з браузером. Вся сторінка працює в одному event loop'е, завантажений сторінкою javascript додається у чергу, щоб здійснитися. Якщо на сторінці відбуваються які-небудь UI-події (клік по кнопці, переміщення миші, інше) — код їх обробників додається у чергу. Обробники виконуються послідовно, немає ніякої паралельності, поки працює якийсь код — всі інші чекають. Якщо який-небудь код викличе якусь спеціальну функцію, начебто
setTimeout(function() { alert(42) }, 5000)
— то це створить десь поза циклу таймер, по закінченню якого у чергу буде доданий код функції
alert(42)
.
Фішка: якщо хтось у черзі перед виконанням обробника буде щось довго вираховувати, то обробник таймера, очевидно, виконується пізніше, ніж через п'ять секунд.
Друга фішка: навіть якщо ми попросимо, наприклад, 1 мілісекунду очікування, може пройти куди більше, оскільки реалізація event loop'а може подивитися: «ага, черга порожня, найближчий таймер через 1мс, будемо чекати IO-подій 1мс», а коли ми викличемо select, реалізація операційної системи може подивитися: «ага, подій начебто немає, на твій час мені все одно, я роблю context switch, поки є можливість», а там всі інші потоки заиспользовали все доступне їм час і ми пролетіли.
select
Асинхронні IO-події на низькому рівні реалізовані за допомогою варіацій select'а. У нас є деякі файлові дескриптори (які можуть бути або файлами, або мережевими сокетами, або чимось ще (по суті, в Linux що завгодно може бути файлом (або навпаки, файл може бути чим завгодно))).
І ми можемо викликати деяку синхронну функцію, передавши їй безліч дескрипторів, від яких ми очікуємо введення, або ж хочемо щось записати, яка заблокує потік до тих пір, поки:
  1. Один або кілька переданих нами дескрипторів не стануть готові до здійснення бажаної нами операції.
  2. Не минув час очікування (якщо воно було задано).
У результаті виконання цієї процедури ми отримаємо безлічі готових до читання/запису файлів.
Callbacks
найпростіший спосіб отримати результати виконання асинхронної операції — при її створенні передати посилання на функції, які будуть викликані при якому-небудь прогрес виконання/готовності результату.
Це досить низькорівневий підхід, і часто невміння банально писати функції «в стовпчик» разом зі зловживанням анонімних функцій призводить до «callback hell» (ситуація, коли ми маємо чотири-десять рівнів вкладеності функцій, щоб обробити послідовні операції):
// Вкладаємо
function someAsync(a, callback) {
anotherAsync(a, function(b) {
asyncAgain(b, function© {
andAgain(b, c, function(d) {
lastAsync(d, callback);
});
});
});
}

// Лінійно
function someAsync2(a, callback) {
var b;

anotherAsync(a, handleAnother);

function handleAnother(_b) {
b = _b;
asyncAgain(b, handleAgain);
}

function handleAgain© {
andAgain(b, c, handleAnd);
}

function handleAnd(d) {
lastAsync(d, callback);
}
}

Async Монада
Ми, програмісти, любимо абстрагувати і узагальнювати для приховування різних складнощів/рутини. Тому існує, в тому числі, абстракція над асинхронними обчисленнями.
Що таке «обчислення»? Це процес перетворення A в B. Будемо записувати синхронні обчислення як A → B.
Що таке «асинхронне значення»? Це обіцянка надати нам у майбутньому деяке значення T (яке може бути успішним результатом, або помилкою). Будемо позначати це як Async[T].
Тоді «асинхронна операція» буде виглядати як
A → Async[T]
, де A — якісь аргументи, необхідні для старту операції (наприклад, це може бути URL, до якого ми хочемо зробити GET-запит).
Як працювати з Async[T]? Нехай у нього буде метод run, який прийме коллбек, який буде викликаний тоді, коли дані стануть доступні:
Async[T].run : (T → ()) → ()
(приймає функцію, приймаючу T, нічого не повертає).
Добре, а тепер додамо найголовніше — можливість продовжити асинхронну операцію. Якщо у нас є Async[A], то, очевидно, коли A стане доступно, ми можемо створити Async[B] і чекати вже його результату. Функція для такого продовження буде виглядати так:
Async[A].then : (A → Async[B]) → Async[B]

тобто якщо ми можемо створити Async[B] з якогось A, а так само маємо Async[A], який коли-небудь надасть нам A, немає ніяких проблем надати Async[B] відразу, бо B ми зможемо все-таки отримати через якийсь час і в результаті все зійдеться.
Реалізація цього добра
function Async(starter) {
this.run = function(callback) {
starter(callback);
};
var runParent = this.run;

this.then = function(f) {
return new Async(function(callback) {
runParent(function(x) {
f(x).run(callback);
});
});
};
}

І тоді той наш синтетичний приклад вище стає:
function someAsync(a) {
return anotherAsync(a).then(function(b) {
return asyncAgain(b).then(function© {
return andAgain(b, c);
}).then(function(d) {
return lastAsync(d);
});
});
}

Але далі цікавіше. Явно розмежуємо тип асинхронного значення на помилку/результат. Тепер у нас завжди Async[E + R] (плюс це тип-сума, одне з двох). І тоді ми можемо, наприклад, ввести метод
Async[E + R].success : (R → Async[E + N]) → Async[E + N]
. Зверніть увагу, що E залишилося недоторканим.
Ми можемо реалізувати цей метод тільки так, щоб він виконував передану йому функцію тільки в разі отримання успішного результату (тобто отриманню R, а не E) і запускав наступну асинхронну операцію, інакше — результат асинхронної операції продовжує залишатися «помилковим».
this.success = function(f) {
return new Async(function(callback) {
runParent(function(x) {
if (x.isError()) callback(x);
else f(x).run(callback);
});
});
};

Тепер якщо ми будемо chain'ить асинхронні операції за допомогою методу success, ми будемо обробляти тільки успішну гілку розвитку подій, а будь-яка помилка проскочить всі наступні обробники і потрапить відразу в коллбек, переданий у run.
Ми тільки що абстрагировали потік виконання і ввели в нашу абстракцію виключення. Якщо погратися ще трохи, можна буде придумати метод failure, який може перетворити помилку в іншу помилку, або ж повернути успішний результат.
Промисы (promises, обіцянки)
Є стандарт, що описує інтерфейс Thenable. Він працює практично ідентичне тому, що було описано вище, але в Promises/A+ немає поняття старту асинхронної операції. Якщо ми маємо на руках Thenable, то вже десь щось відбувається, і все що ми можемо — підписатися на результат виконання. І там один метод then, що приймає два опціональних функції для обробки успішної/провальною гілки, а не різні методи.
Тут вже на смак і колір, в обох підходів є і плюси і мінуси.
async/await — промисы + корутины
Щоб використовувати промисы, нам потрібно використовувати лямбда-функції в неймовірних кількостях. Що може бути досить візуально шумно і незручно. Немає можливості зробити це якось краще?
Є.
У нас є корутины, у яких може бути безліч точок входу. І це те, що нам потрібно. Нехай у нас буде корутина, яка видає назовні Async[E + R], а всередину неї подається вийшло R, або порушується виняток E. І тоді починається дзен:
function someAsync*(a) {
var b = yield anotherAsync(a),
c = yield asyncAgain(b),
d = yield andAgain(b, c);

yield lastAsync(d);
}

Потім нам потрібен «executor» такого добра, який буде приймати цю корутину, діставати з неї виходи, якщо вони є Async'ами — виконувати їх, якщо іншими корутинами — рекурсивно execute'ить їх, вважаючи результатом останній yield.
А async/await — це коли ми yield перейменовуємо в await, а перед декларацією функції пишемо async. Ну і іноді (у разі Python, наприклад), можна побачити асинхронні генератори, в яких є одночасно і yield та await. Тоді вони ведуть себе як ті ж корутины, але операції з нею стають асинхронними, бо між поверненням/прийняттям вона чекає результатів своїх внутрішніх асинхронних операцій.


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

0 коментарів

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