Pull to refresh

Асинхронность: почему это никак не сделают правильно?

Reading time7 min
Views6.8K
Асинхронные программы чертовски неудобно писать. Настолько неудобно, что даже в node.js, заявленном как «у нас все правильное-асинхронное», понадобавляли таки синхронных аналогов асинхронных функций. Что уж говорить про питоновский синтаксис, не дающий объявить лямбду со сколь-либо сложным кодом внутри…

Забавно, что красивое решение проблемы не требует ничего экстраординарного, но почему-то до сих пор не реализовано.

Суть проблемы


Допустим, у нас есть такой синхронный код:
var f = open(args);
checkConditions(f);
var result = readAll(f);
checkResult(result);

Асинхронный аналог будет выглядеть куда страшнее:
asyncOpen(args, function(error, f){
  if(error)
    throw error;
  checkConditions(f);
  asyncReadAll(f, function(error, result){
    if(error)
      throw error;
    checkResult(result);
  });
});

Чем длиннее цепочка вызовов, тем страшнее код.

Может, вам недостаточно страшно? Тогда попробуйте написать аналог следующего кода, заменив все вызовы на асинхронные:
while(true)
{
  var result = getChunk(args1);
  while(needsPreprocessing(result))
  {
    result = preprocess(result);
    if(!result)
      result = obtainFallback(args2);
  }
  processResult(result);
}

И дело не в том, что потребуется много буков. Основная засада — синхронный код, приведенный выше, замечательно читается. А вот в том асинхронном безобразии, которое у вас получится, даже вы сами через пару часов так просто не разберетесь.

К слову сказать, пример не голословный. Отдаленное подобие пришлось как-то реализовывать на питоне, причем именно в асинхронном виде.

Решения с помощью кирки и лома


Возможно, вы видели в node.js такую концепцию, как Promise. Так вот, ее больше нет. Самые обычные колбэки оказались куда человечнее. Поэтому про Promise я рассказывать не буду.

А расскажу я про библиотеку Do. Эта библиотека базируется на концепции continuables. Вот пример, демонстрирующий разницу подходов:
// callback-style
asyncFunc(args, function(error, result){
  if(error)
    throw error;
  doSomething(result);
});

// continuables-style
var continuable = continuableFunc(args);
continuable(function(result){ // callback
  doSomething(result);
}, function(error){ // errback
  throw error;
});

// continuables-style short
continuableFunc(args)(doSomething, errorHandler);

Continuable — это функция, которая возвращает другую функцию, которая принимает в качестве параметров callback и errback и совершает асинхронный вызов.

В некоторых случаях такой подход позволяет заметно упростить код — взгляните на «continuables-style short». Здесь в качестве колбэка мы используем непосредственно doSomething, так как сигнатура функции нам подходит, а в качестве errback используем некий «стандартный» errorHandler, определенный где-то еще.

Do умеет многое. Параллельные вызовы, асинхронный map, некоторые другие интересности. Подробнее об этом можно почитать в статье "Комбо-библиотека Do". Там же можно прочесть о том, как конвертировать функции, заточенные под callback-style (стандартный для node.js) в continuables-style.

Однако, вернемся к примерам, с которых я начал. Чем может Do помочь в нашем случае? Собственно, вот чем:
Do.chain(
  continuableOpen(args),
  function(f){
    checkConditions(f);
    return continuableReadAll(f);
  }
)(function(result){
  checkResult(result);
}, errorHandler);

Это continuables-style аналог самого первого примера. Ну что ж, может чуточку лучше по сравнению с callback-style, а может и нет. По крайней мере рост отступов с ростом длины цепочки остановлен, а обработчик ошибок сконцентрировался в одной точке. Но код выглядит страшно, особенно в сравнении с исходной синхронной версией в четыре строки. Более сложный пример — тот что с циклами — Do вообще не по зубам, снова придется городить страшенный огород.

yield спешит на помощь


Кирка и лом не помогли, хочется чего-то возвышенного. Хочется, чтобы асинхронный вызов был не сложнее синхронного. А в идеале — почти от него не отличался. И это возможно.

Лучше всего инфраструктура решения описана у Ивана Сагалаева в статье "ADISP". ADISP — это написанная им питоновская библиотека, которая и приносит счастье.

Нечто похожее можно собрать и на JS, примером служит Er.js, но туда понапихали многовато магии для первого знакомства, поэтому рекомендую именно статью Сагалаева.

Подход, примененный в ADISP, позволяет писать код в следующем стиле:
var func = process(function(){
  while(true)
  {
    var result = yield getChunk(args1);
    while(yield needsPreprocessing(result))
    {
      result = yield preprocess(result);
      if(!result)
        result = yield obtainFallback(args2);
    }
    yield processResult(result);
  }
});

Да, это тот самый страшный пример с циклами. Все вызовы асинхронные. Обрамляющая функция func приведена только для того, чтобы показать, что ее придется задекорировать. process — декоратор, аналогичный описанному у Сагалаева. getChunk, needsPreprocessing, preprocess, obtainFallback, processResult — асинхронные функции, задекорированные декоратором async в терминологии ADISP.

Подход работает везде, где есть yield в питоновском стиле. То есть, превосходный асинхронный node.js в пролете, поскольку V8 еще не поддерживает yield.

Нативное решение


Нужно ли что-то еще, когда используя трюк с yield мы можем добиться столь достойных результатов? Считаю, что да, поскольку:

— Использование ключевого слова yield в контексте асинхронных вызовов выглядит странно. Все-таки это слово предназначено для несколько иных вещей.
— Необходимость декорирования обрамляющей функции — неудобство и лишний повод для ошибки
— Код того же ADISP хоть и не сложен, но чтобы понять, как эта штука работает, надо изрядно поломать мозг. Мне как-то пришлось использовать ADISP в чуточку модифицированном виде. Я нарвался на странное поведение и долго и мучительно вникал, в чем же дело. Косяк оказался совсем в другом месте, но шансы свихнуться при отладке были более чем реальными.

Пример с yield наглядно показывает, что в среде исполнения есть все для реализации асинхронных вызовов удобным, красивым образом. Настолько все, что даже не трогая внутренние механизмы можно добиться требуемого. Правомерный вопрос — почему бы не предоставить встроенное, нативное решение, реализация которого не должна получиться слишком сложной?

По сути, среда исполнения должна запомнить контекст в месте асинхронного вызова и прекратить исполнение, а в момент вызова колбэка просто восстановить все в нужном виде и, если нужно, бросить исключение. И она умеет это делать, по крайней мере потенциально. Вопрос в том, как подсказать ей, когда нам нужен этот трюк.

Неблокирующие библиотечные функции

Как правило, асинхронные функции реализованы либо в ядре языка (например, setTimeout), либо в библиотечных функциях (например, функции модуля fs в node.js). Таким образом, проблема красивых асинхронных вызовов в первую очередь имеет отношение именно к библиотечным функциям.

Это означает замечательную вещь — красивые асинхронные вызовы могут быть введены простым добавлением специального соглашения для асинхронных библиотечных функций. Не нужно менять язык, не нужно придумывать новое ключевое слово и ломать голову над обратной совместимостью. Просто дайте автору библиотеки способ указать, что асинхронной библиотечной функции нужен способ вернуть к жизни контекст, из которого ее вызвали, а текущее исполнение нужно прекратить. Такие функции можно будет смело использовать, например, так:
while(true)
{
  doSomePeriodicTask();
  nbSleep(1000);
}

Здесь nbSleep — неблокирующий вызов sleep, который фактически прервет исполнение в точке вызова и когда-нибудь начнет его снова из этой же точки, используя сохраненный контекст в качестве колбэка.

Пусть нам даже придется иметь пары функций — одну обычную, с колбэком (все-таки в некоторых случаях вариант с колбэком предпочтительнее), а вторую неблокирующую. Это не страшно, при желании можно сделать обертку:
var asyncUnlink = fs.unlink;
fs.unlink = function(fName, callback){
  if(callback)
    return asyncUnlink(fName, callback);
  return nbUnlink(fName);
};

По крайней мере, синхронные аналоги, которыми можно наделать бед, можно будет выкинуть на фиг, заменив их использование на использование неблокирующих аналогов.

Ключевое слово async?

Если нам все-таки хочется добавить красивых асинхронных вызовов на уровне языка, то, видимо, не обойтись без нового ключевого слова. Понятно, что это уже скорее фантазии: изменение языка — слишком уж вольная вольность, в отличие от изменения среды исполнения. Тем не менее, давайте одним глазком глянем, что могло бы получиться:
var result1 = async(callback, myAsyncFunc(args, callback)); // long form
var result2 = async myAsyncFunc(args); // short form
var result3 = async(cb, createTask(args, cb), function(task){TaskManager.register(task);});

— Длинная форма: callback — это название переменной, в которую будет заскладирован контекст возврата для передачи в асинхонную функцию
— Короткая форма: контекст возврата будет добавлен последним аргументом
— Третий вариант — «вывернутый наизнанку»: возвращаемое значение будет передано в лямбду (регистрируем созданную асинхронную «задачу» в неком «менеджере» — может мы захотим ее отменить?), а в точку вызова вернемся обычным для async способом

Зачем может быть нужна поддержка на уровне языка? Думаю, только если нам нужно сделать что-нибудь этакое с нашим хитрым колбэком. Например, отдать его в несколько функций (ой, до чего порочная будет практика). В большинстве случаев должно хватать поддержки на уровне библиотечных функций. И уж точно вызовы неблокирующих библиотечных функций будут смотреться лучше, чем засилье ключевого слова async.

Идея с «вывернутым наизнанку» вызовом, кстати, применима и для неблокирующих библиотечных функций, достаточно передавать в них функцию для вызова непосредственно перед завершением текущего исполнения.

Напоследок


Я надеюсь, что когда-нибудь неблокирующие библиотечные функции будут добавлены в V8 и node.js, сделав их еще асинхроннее и прекраснее. Я надеюсь, что их также добавят и в Python. Я надеюсь, что на этом не остановятся и во всех новых и потенциально любимых языках и средах вместо синхронных функций будут функции неблокирующие — везде, где это имеет смысл.

* Все исходники в этой статье подсвечены с помощью Source Code Highlighter.
Tags:
Hubs:
Total votes 86: ↑81 and ↓5+76
Comments78

Articles