Континуації в Java

The distinguishing characteristic of industrial-strength software is that it is intensely difficult… the complexity of such systems exceeds the human intellectual capacity… we may master this complexity, but we can never make it go away.

Grady Booch
Давайте повернемося на кілька десятиліть назад і подивимося на те, як виглядали типові програми тих років. Тоді домінував Імперативний підхід. Нагадаю, що назву він отримав завдяки тотальному програми контролю над процесом обчислень: у програмі чітко вказується, що і коли повинно бути виконано. Немов набір наказів Імператора. Більшість операційних систем пропонували для написання виконуваних програм саме цей підхід. Він широко використовується і донині, наприклад при написанні різного роду утиліт. Більше того, з такого підходу починається вивчення програмування в школі. У чому ж причина його популярності? Справа в тому, що Імперативний спосіб дуже простий і зрозумілий людині. Освоїти його не складно.

Погляньмо на приклад. Я вибрав Pascal для додання кодом архаїчності. Програма виводить запрошення ввести значення змінної «x», зчитує введене значення з консолі, потім те ж для змінної y, врешті виводить суму «x» та «y». Всі дії ініціює програма — і ввід і вивід. У суворої послідовності.

var
x, y: integer;
begin
write('x = ');
readln(x);
write('y = ');
readln(y);
writeln('x + y = ', x + y);
end.

Тепер трохи перепишемо код і введемо ряд абстракцій (так, термін «абстракція» не є власністю ООП), для того щоб підкреслити основні дії програми.

var
x, y: integer;
begin
x := receiveArg;
y := receiveArg;
sendResult('x + y = ', x + y);
end.

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

Еволюція операційних систем призвела до появи графічних оболонок, і імперативний стиль перестав бути домінуючим. ОС з графічною оболонкою пропонують зовсім інший підхід до структури програм, т. зв. event-driven підхід. Суть підходу у тому, що програма більшу частину часу простоює, нічого не робить і реагує лише на «подразники» з боку операційної системи. Справа в тому, що графічний інтерфейс дає користувачеві одночасний доступ до всіх елементів керування вікна і ми не можемо опитувати їх послідовно, як це відбувається в імперативних програмах. Навпаки, програма повинна оперативно реагувати на будь-які дії користувача з будь-якою частиною вікна, якщо це передбачено логікою чи це очікується користувачем. Підхід event-driven — це не вибір розробників прикладних програм, це вибір розробників ОС, оскільки така модель дозволяє більш ефективно використовувати ресурси машини. Крім того, ОС бере на себе обслуговування графічної оболонки і в цьому сенсі є «товстим» посередником між клієнтом і прикладними програмами. Насправді технічно прикладні програми залишаються імперативними, оскільки вони мають імперативний «ядерце», т. зв. message loop або event loop. Але в більшості випадків це ядерце є типовим і приховано в надрах використовуються програмістами графічних бібліотек.

Є event-driven підхід еволюційним розвитком розробки? Скоріше, це необхідність. Просто так виявилося простіше і економічніше. У цього підходу є відомі недоліки. Насамперед, він менш природним, ніж імперативний підхід, і призводить до додаткових накладних витрат, але про це трохи пізніше. Заговорив я про це підході ось чому: справа в тому, що даний підхід поширився далеко за межі прикладного ПО. Саме так влаштований зовнішній інтерфейс більшості серверів. Грубо кажучи, типовий сервер декларує список команд, які він може виконати. Як і прикладна графічна програма, сервер простоює до тих пір, поки ззовні не прийде команда (event), яку він може обробити. Чому ж event-drive підхід перекочував в серверну архітектуру? Адже тут немає обмежень зі сторони графічної оболонки ОС. Думаю, що причин кілька: це в першу чергу особливості використовуваних мережевих протоколів (як правило, з'єднання ініціюється клієнтом), і все та ж потреба в економії ресурсів машини, споживання яких легко регулювати event-driven підході.

Response onRequest(Request request) {
switch (request.type) {
case "sum":
int x = request.get("x");
int y = request.get("y");
return new Response(x + y);
...

І ось тут я б хотів звернути увагу на одну з суттєвих недоліків event-driven підходу — це великі накладні витрати і відсутність у коді явних абстракцій, що відображають логіку поведінки сервера. Насамперед я маю на увазі взаємозв'язок між різними командами, які декларує сервер: не всі з них є незалежними, деякі повинні виконуватися в певній послідовності. Але оскільки при використанні event-driven не завжди вдається відобразити взаємозв'язок між різними операціями, з'являються накладні витрати у вигляді відновлення контексту для виконання кожної операції, і у вигляді додаткових перевірок, які потрібні, щоб переконатися, що ця операція може бути виконана. Іншими словами, у випадку, якщо протокол, реалізований сервером, складний, то економія ресурсів від використання event-driven підходу стає не такою очевидною. Але не дивлячись на це, є й інші вагомі причини його використання: мовні засоби, стандарти і використовувані бібліотеки не залишають розробнику вибору. Однак далі я хочу поговорити про важливі зміни, які відбулися в останні роки, і про те, що даний підхід непогано вписався в нову реальність.

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

for (;;) {

Request request = receive();
switch (request.type) {

case "sum": 
int x = receiveInt();
int y = receiveInt();
send(new Response(x + y));
break;

...

Згадайте код на Pascal, в який я додав операції receiveArg і sendResult. Погодьтеся, що він дуже нагадує те, що ми бачимо у даному прикладі: ми послідовно запитуємо аргументи і відправляємо результат. Різниця лише в тому, що тут роль консолі грає мережне з'єднання з клієнтом. Імперативний стиль дозволяє позбутися від накладних витрат при обробці пов'язаних операцій. Однак без використання спеціальних механізмів, про які мова піде пізніше, він більш агресивно експлуатує ресурси машини, і непридатний для реалізації серверів, які обслуговують більше кількість з'єднань. Судіть самі: якщо в event-driven підході потік виділяється на окрему операцію, то тут потік виділяється як мінімум на сесію, час життя якої істотно більше. Спойлер: агресивне використання ресурсів в Імперативному підході — усувний недолік.

Тепер поглянемо на «типову» реалізацію сервера — код нижче не пов'язаний з яким-небудь фреймворком і відображає лише схему обробки запитів. За основу я взяв процедуру реєстрації нового користувача з підтвердженням через SMS-код. Нехай ця процедура складається з двох пов'язаних операцій: register і confirm.

Response onReceived(Request request) {
switch (request.type) {

case "register":
User user = registerUser(request);
user.confCode = generateConfirmationCode();
sendSms("Confirmation code " + user.confCode);
return Response.ok;

case "confirm":
String code = request.get("code");
User user = lookupUser(request);
if (user == null || !Objects.equals(code, user.confCode)) {
return Response.fail;
}
return user.confirm() ? Response.ok : Response.fail;
...


Пробіжимося по коду. Операція register. Тут ми створюємо нового клієнта, використовуючи дані з реквеста. Але давайте подумаємо, що включає в себе процес створення клієнта: це, потенційно, серія звернень до однієї чи кількох зовнішніх систем (генерація коду, відправка SMS), операції з диском. Наприклад, операція sendSms, поверне нам управління лише після того, як SMS повідомлення з кодом буде успішно надіслано. Звернення до зовнішніх систем (час доставки запиту, час обробки, час передачі результату назад) і операції роботи з диском займають час, і будуть призводити до простоїв поточного потоку. Зверніть увагу: ми прив'язуємо згенерований код до клієнта (поле confCode). Справа в тому, що після обробки даного реквеста ми покинемо оброблювач, і всі локальні змінні будуть скинуті. Нам же необхідно зберегти код для подальшого порівняння, коли надійде запит confirm. При його обробці ми першим ділом відновлюємо контекст виконання. Це ті самі накладні витрати, про які я говорив.

Синхронна обробка запитів, як в нашому прикладі, пов'язана з великою кількістю блокувальних операцій, що призводять до простою цінного системного ресурсу. І все б нічого, але до обслуговуючих систем пред'являються великі вимоги щодо пропускної здатності і часу відгуку. Простої системних ресурсів стає недозволеною розкішшю. Давайте поглянемо на діаграму.

image

Тут я зобразив блокуючий виклик. Заштрихований ділянка це простий вхідного потоку. Істотний він? Згадайте розміри і кількість таймаутів, використовуваних у вашій системі. Вони красномовно говорять вам про величину можливого простою. Мова йде не про мілісекундах, а про десятки секунд, а іноді і про хвилинах. При навантаженні 1000 TpS, простий в 1 секунду — це 1000 операцій, на обробку яких був виділений додатковий ресурс.

Які ж рішення пропонує нам індустрія для збільшення пропускної здатності і зменшення часу відгуку? Розробники заліза, наприклад, пропонують багатоядерність. Так, це розширює можливості окремо взятої машини. Event-driven підхід, завдяки масштабованості, легко утилізує новий ресурс. Але синхронна реалізація обробників запитів робить використання потоків малоефективним. І ось тут на допомогу нам приходять асинхронні виклики.

image

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

void onReceived(Request request) {

switch (request.type) {

case "register":
registerUser(request, user -> {
generateConfirmationCode(code -> {
user.confCode = code;
sendSms("Confirmation code " + code, () -> {
reply(Response.ok);
});
});
});
break;
...

Тут я навів лише одну операцію register. Але цього вже достатньо, щоб побачити основний недолік асинхронного стилю: найгірша читаність коду, збільшення його розмірів. Появи «драбинки» callback-ів, замість серії синхронних викликів. Даний приклад виглядає пристойно лише завдяки лямбдам. Без них сприймати асинхронний код було б куди складніше. Іншими словами, мова Java недостатньо адаптований до нових вимог. В ньому немає необхідних інструментів, що роблять роботу з асинхронним кодом більш комфортним.

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

Так, такий спосіб є.

Континуації
Континуації — ще один механізм управління ходом виконання програми (додаток до циклів, умовним ветвлениям, викликів методів тощо), що дозволяє припиняти виконання методу в певній точці на невизначений строк з вивільненням поточного потоку.
Ось основні артефакти даного інструменту.

  • Suspendable method — метод, виконання якого може бути призупинено на невизначений термін, а потім відновлено
  • Coroutine/Fiber зберігають стек при призупинення виконання. Стек може бути переданий по мережі на іншу машину для того, щоб відновити виконання припиненого методу там
  • CoIterator дозволяє створювати різновид ітераторів, званих генераторами (реалізований в бібліотеці Мана)


Це далеко не повний список. Такі артефакти, як Channel, Reactive dataflow Actor, також дуже цікаві, проте це теми для окремих статей. Тут я розглядати їх не буду.

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

  • Jau VM — надбудова над JVM, «експериментальний» проект (2005 рік)
  • JavaFlow — спроба створення капітальної бібліотеки, не підтримується з 2008 року
  • jyield — невеликий «експериментальний» проект (лютий 2010 року)
  • coroutines — ще один «експериментальний» проект (жовтень 2010 року)
  • jcont — відносно свіжа спроба (2013 рік)
  • Continuations library Матіса Мана — найбільш простий і вдалий, на мій погляд, солюшн для Java


Концепція, реалізована Маном, проста і легка в освоєнні. На жаль, на даний момент не підтримується. Крім того, буквально нещодавно недоступна стала і оригінальна стаття, з описом бібліотеки.

Але не все так погано. Панове з Parallel Universe, взявши за основу бібліотеку Мана, переробили її, зробивши свою вже більш важку версію — Quasar.

Quasar успадкував у бібліотеки Мана основні ідеї, які розвинув їх, і додав до неї деяку інфраструктуру. Крім того, на даний момент це єдине таке рішення, яке працює з Java 8.

Що нам дає цей інструмент? Перш за все, ми отримуємо можливість писати асинхронний код, не втрачаючи при цьому наочності синхронного. Більш того, тепер ми можемо писати серверний код в імперативному стилі.

for (;;) {

Request request = receive();
switch (request.type) {

case "register":
User user = registerUser(request);
int confCode = generateConfirmationCode();
sendSms("Confirmation code " + confCode);
reply(Response.confirm);
String code = receiveConfirmationCode();
if (Objects.equals(code, confCode) && user.confirm()) {
reply(Response.ok);
} else {
reply(Response.fail);
}
break;

...

Це приклад тієї ж реєстрації користувача. Зверніть увагу на те, що від парного реквеста register/confirm залишився тільки один: register. confirm зник, оскільки тут він нам більше не потрібен. У даній реалізації мінімум накладних витрат: весь контекст операції зберігається в локальних змінних, нам не треба запам'ятовувати згенерований код, лукапить заново користувача. Після його реєстрації, генерації коду і відправки СМС, ми просто чекаємо отримання цього коду клієнта і нічого більше. Не новий реквест з купою зайвих атрибутів, а всього лише один код!

Як же це працює? Пропоную почати з бібліотеки Мана. Бібліотека містить всього кілька класів, основним з яких є Coroutine.

Coroutine co = new Coroutine(new CoroutineProto() {
@Override
public void coExecute() throws SuspendExecution {
...
Coroutine.yield(); // suspend execution
...
}
});
...
co.run(); // run execution
...
co.run(); // resume execution

Coroutine — це, по суті, оболонка для Runnable. Точніше, не для стандартного Runnndable, а для спеціальної версії даного інтерфейсу — CoroutineProto. Завдання корутины — зберігати стан стека в моменти призупинення виконання вкладеної завдання. Самі по собі корутины нічого не роблять: вкладений коли ініціюється методом run, який починає або відновлює, після зупинки, виконання коду в методі coExecute. Управління з методу run повертається після того, як метод coExecute закінчить свою роботу, або припинить її, викликавши статичний метод Coroutine.yield. Про те, в якому стані перебуває метод coExecute можна дізнатися через виклик Couroutine.getState. Три методу — run, yield і getState, по суті, описують весь значущий інтерфейс клас Coroutine. Все дуже просто. Зверніть увагу на виключення SuspendExecution. Насамперед, це маркер, який вказує на те, що метод може припинятися. Особливістю бібліотеки Мана є те, що дане виняток реально пробрасывается в момент припинення (єдиний «порожній» — без стека — примірник). Даний эксепшн не можна «душити». Це одне з незручностей бібліотеки.

Одне з застосувань корутин Ман побачив у створенні особливого різновиду ітераторів: генератори. По всій видимості, Мана (як і його попередників) пригнічував той факт, що підтримка генераторів є в багатьох мовах, у т. ч. в C# (yield return, yield break). В свою бібліотеку він включив спеціальний клас CoIterator, який реалізує інтерфейс Iterator. Для створення генератора необхідно пронаследовать CoIterator і реалізувати абстракный припиняв метод run. У конструкторі CoIterator створюються корутина, якій «згодовується» абстрактний метод run.

class TestIterator extends CoIterator<String> {
@Override
public void run() throws SuspendExecution {
produce("A");
produce("B");
for(int i = 0; i < 4; i++) {
produce("С" + i);
}
produce("D");
produce("Е");
}
}

Після того, як ідея, закладена Маном в його бібліотеку, стає зрозуміла, освоєння Quasar не становить праці. У Quasar використана трохи інша термінологія. Приміром, Fiber використовується в Quasar в ролі корутин, є, по суті, полегшеною версією потоку (термін, ймовірно, запозичений з Win API, де файберы присутні досить давно). Використовувати його так само просто, як і корутины.

Fiber fiber = new Fiber (new SuspendableRunnable() {
public void run() 
throws SuspendExecution, InterruptedException {
...
Fiber.park(); // suspend execution
...
}
}).start(); // start execution
...
fiber.unpark(); // resume execution

Тут ми бачимо вже знайомий нам SuspendExecution. Однак у Quasar він чесно виконує роль маркера, і не обов'язковий. Замість нього можна використовувати анотацію @Suspendable.

class C implements I {
@Suspendable
public int f() {
try {
return g() * 2;
} catch(SuspendExecution s) {
assert false;
}
}
}

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

В бібліотеці Quasar є все необхідне, для «перетворення» асинхронних інтерфейсів в псевдосинхронные, забезпечуючи клієнтський код наочністю синхронного та ефективністю асинхронного. Крім того, примірники Fiber є сериализуемыми, тобто їх можна частково виконувати на різних машинах: почати на одній ноде, призупинити, передати по мережі на іншу ноду, і там відновити виконання.

Щоб зрозуміти міць файберов, давайте уявимо собі наступну ситуацію. Припустимо, у нас є класичний сервер з синхронної обробкою реквестов. Нехай, обробляючи реквесты користувачів, наш сервер час від часу звертається до зовнішніх ресурсів. До БД, наприклад. Припустимо, для роботи сервера ми виділили 1000 потоків. І ось, в якийсь момент, наш зовнішній ресурс почав «подтупливать». У цьому випадку обробники нових реквестов при зверненні до цього ресурсу почнуть підвішувати, блокуючи свої потоки. При високому навантаженні на сервер пул потоків буде швидко витрачений, і почнуться реджекты. Якщо пул потоків загальний, то реджектиться будуть навіть ті реквесты, які з зовнішнім ресурсом ні як не пов'язані. При цьому сервер може зовсім нічого не робити. Вузьким місцем у всій нашій системі виявився зовнішній ресурс, який не впорався з навантаженням і вийшов з ладу.

Чому ж нам допоможуть файберы? Файбер перетворює наш синхронний обробник у асинхронний. Тепер, при зверненні до зовнішнього ресурсу, ми можемо спокійно повернути потік в пул, і запитати у машини лише небагато пам'яті для збереження поточного стека виконання. Коли від зовнішнього ресурсу надійде відповідь, ми візьмемо в пулі вільний потік, відновимо стек і продовжимо обробку реквеста. Краса!

Але тут треба зробити застереження: це все спрацює, тільки якщо інтерфейс до зовнішнього ресурсу буде асинхронним. На жаль, дуже багато бібліотек надають лише синхронний інтерфейс. Типовий приклад JDBC. Але треба зазначити, що Java рухається в бік асинхронності. Старі інтерфейси переписуються (NIO, AIO, CompletableFuture, Servlet 3.0), нові часто є асинхронними спочатку (Netty, ZooKeeper).

Звичайно, дуже хотілося б бачити зрушення в цьому напрямку з боку Oracle. Робота ведеться, але дуже повільно, і в найближчій версії Java штатної підтримки континуаций очікувати не варто. Будемо сподіватися, що бібліотека Quasar не залишиться єдиною в своєму роді, і ми побачимо ще багато цікавих рішень, які роблять написання асинхронного коду простим і зручним.
Джерело: Хабрахабр

0 коментарів

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