Асинхронные программы чертовски неудобно писать. Настолько неудобно, что даже в node.js, заявленном как «у нас все правильное-асинхронное», понадобавляли таки синхронных аналогов асинхронных функций. Что уж говорить про питоновский синтаксис, не дающий объявить лямбду со сколь-либо сложным кодом внутри…
Забавно, что красивое решение проблемы не требует ничего экстраординарного, но почему-то до сих пор не реализовано.
Допустим, у нас есть такой синхронный код:
Асинхронный аналог будет выглядеть куда страшнее:
Чем длиннее цепочка вызовов, тем страшнее код.
Может, вам недостаточно страшно? Тогда попробуйте написать аналог следующего кода, заменив все вызовы на асинхронные:
И дело не в том, что потребуется много буков. Основная засада — синхронный код, приведенный выше, замечательно читается. А вот в том асинхронном безобразии, которое у вас получится, даже вы сами через пару часов так просто не разберетесь.
К слову сказать, пример не голословный. Отдаленное подобие пришлось как-то реализовывать на питоне, причем именно в асинхронном виде.
Возможно, вы видели в node.js такую концепцию, как Promise. Так вот, ее больше нет. Самые обычные колбэки оказались куда человечнее. Поэтому про Promise я рассказывать не буду.
А расскажу я про библиотеку Do. Эта библиотека базируется на концепции continuables. Вот пример, демонстрирующий разницу подходов:
Continuable — это функция, которая возвращает другую функцию, которая принимает в качестве параметров callback и errback и совершает асинхронный вызов.
В некоторых случаях такой подход позволяет заметно упростить код — взгляните на «continuables-style short». Здесь в качестве колбэка мы используем непосредственно doSomething, так как сигнатура функции нам подходит, а в качестве errback используем некий «стандартный» errorHandler, определенный где-то еще.
Do умеет многое. Параллельные вызовы, асинхронный map, некоторые другие интересности. Подробнее об этом можно почитать в статье "Комбо-библиотека Do". Там же можно прочесть о том, как конвертировать функции, заточенные под callback-style (стандартный для node.js) в continuables-style.
Однако, вернемся к примерам, с которых я начал. Чем может Do помочь в нашем случае? Собственно, вот чем:
Это continuables-style аналог самого первого примера. Ну что ж, может чуточку лучше по сравнению с callback-style, а может и нет. По крайней мере рост отступов с ростом длины цепочки остановлен, а обработчик ошибок сконцентрировался в одной точке. Но код выглядит страшно, особенно в сравнении с исходной синхронной версией в четыре строки. Более сложный пример — тот что с циклами — Do вообще не по зубам, снова придется городить страшенный огород.
Кирка и лом не помогли, хочется чего-то возвышенного. Хочется, чтобы асинхронный вызов был не сложнее синхронного. А в идеале — почти от него не отличался. И это возможно.
Лучше всего инфраструктура решения описана у Ивана Сагалаева в статье "ADISP". ADISP — это написанная им питоновская библиотека, которая и приносит счастье.
Нечто похожее можно собрать и на JS, примером служит Er.js, но туда понапихали многовато магии для первого знакомства, поэтому рекомендую именно статью Сагалаева.
Подход, примененный в ADISP, позволяет писать код в следующем стиле:
Да, это тот самый страшный пример с циклами. Все вызовы асинхронные. Обрамляющая функция func приведена только для того, чтобы показать, что ее придется задекорировать. process — декоратор, аналогичный описанному у Сагалаева. getChunk, needsPreprocessing, preprocess, obtainFallback, processResult — асинхронные функции, задекорированные декоратором async в терминологии ADISP.
Подход работает везде, где есть yield в питоновском стиле. То есть, превосходный асинхронный node.js в пролете, поскольку V8 еще не поддерживает yield.
Нужно ли что-то еще, когда используя трюк с yield мы можем добиться столь достойных результатов? Считаю, что да, поскольку:
— Использование ключевого слова yield в контексте асинхронных вызовов выглядит странно. Все-таки это слово предназначено для несколько иных вещей.
— Необходимость декорирования обрамляющей функции — неудобство и лишний повод для ошибки
— Код того же ADISP хоть и не сложен, но чтобы понять, как эта штука работает, надо изрядно поломать мозг. Мне как-то пришлось использовать ADISP в чуточку модифицированном виде. Я нарвался на странное поведение и долго и мучительно вникал, в чем же дело. Косяк оказался совсем в другом месте, но шансы свихнуться при отладке были более чем реальными.
Пример с yield наглядно показывает, что в среде исполнения есть все для реализации асинхронных вызовов удобным, красивым образом. Настолько все, что даже не трогая внутренние механизмы можно добиться требуемого. Правомерный вопрос — почему бы не предоставить встроенное, нативное решение, реализация которого не должна получиться слишком сложной?
По сути, среда исполнения должна запомнить контекст в месте асинхронного вызова и прекратить исполнение, а в момент вызова колбэка просто восстановить все в нужном виде и, если нужно, бросить исключение. И она умеет это делать, по крайней мере потенциально. Вопрос в том, как подсказать ей, когда нам нужен этот трюк.
Как правило, асинхронные функции реализованы либо в ядре языка (например, setTimeout), либо в библиотечных функциях (например, функции модуля fs в node.js). Таким образом, проблема красивых асинхронных вызовов в первую очередь имеет отношение именно к библиотечным функциям.
Это означает замечательную вещь — красивые асинхронные вызовы могут быть введены простым добавлением специального соглашения для асинхронных библиотечных функций. Не нужно менять язык, не нужно придумывать новое ключевое слово и ломать голову над обратной совместимостью. Просто дайте автору библиотеки способ указать, что асинхронной библиотечной функции нужен способ вернуть к жизни контекст, из которого ее вызвали, а текущее исполнение нужно прекратить. Такие функции можно будет смело использовать, например, так:
Здесь nbSleep — неблокирующий вызов sleep, который фактически прервет исполнение в точке вызова и когда-нибудь начнет его снова из этой же точки, используя сохраненный контекст в качестве колбэка.
Пусть нам даже придется иметь пары функций — одну обычную, с колбэком (все-таки в некоторых случаях вариант с колбэком предпочтительнее), а вторую неблокирующую. Это не страшно, при желании можно сделать обертку:
По крайней мере, синхронные аналоги, которыми можно наделать бед, можно будет выкинуть на фиг, заменив их использование на использование неблокирующих аналогов.
Если нам все-таки хочется добавить красивых асинхронных вызовов на уровне языка, то, видимо, не обойтись без нового ключевого слова. Понятно, что это уже скорее фантазии: изменение языка — слишком уж вольная вольность, в отличие от изменения среды исполнения. Тем не менее, давайте одним глазком глянем, что могло бы получиться:
— Длинная форма: callback — это название переменной, в которую будет заскладирован контекст возврата для передачи в асинхонную функцию
— Короткая форма: контекст возврата будет добавлен последним аргументом
— Третий вариант — «вывернутый наизнанку»: возвращаемое значение будет передано в лямбду (регистрируем созданную асинхронную «задачу» в неком «менеджере» — может мы захотим ее отменить?), а в точку вызова вернемся обычным для async способом
Зачем может быть нужна поддержка на уровне языка? Думаю, только если нам нужно сделать что-нибудь этакое с нашим хитрым колбэком. Например, отдать его в несколько функций (ой, до чего порочная будет практика). В большинстве случаев должно хватать поддержки на уровне библиотечных функций. И уж точно вызовы неблокирующих библиотечных функций будут смотреться лучше, чем засилье ключевого слова async.
Идея с «вывернутым наизнанку» вызовом, кстати, применима и для неблокирующих библиотечных функций, достаточно передавать в них функцию для вызова непосредственно перед завершением текущего исполнения.
Я надеюсь, что когда-нибудь неблокирующие библиотечные функции будут добавлены в V8 и node.js, сделав их еще асинхроннее и прекраснее. Я надеюсь, что их также добавят и в Python. Я надеюсь, что на этом не остановятся и во всех новых и потенциально любимых языках и средах вместо синхронных функций будут функции неблокирующие — везде, где это имеет смысл.
Забавно, что красивое решение проблемы не требует ничего экстраординарного, но почему-то до сих пор не реализовано.
Суть проблемы
Допустим, у нас есть такой синхронный код:
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.