Мы всё-таки смогли дойти до третьей части и добрались до самого интересного — организации асинхронных вычислений.
В прошлых двух статьях мы посмотрели на абстракцию параллельно выполняющегося кода и кооперативного выполнения обработчиков задач.
Теперь посмотрим, как можно управлять потоком исполнения (control flow) в случае обработки асинхронных задач.
- Параллелизм (часть 1)
- Кооперативность (часть 2)
- Асинхронность (часть 3, текущая)
Асинхронность
Синхронные операции — операции, при которых мы получаем результат в результате блокирования потока выполнения. Для простых вычислительных операций (сложение/умножение чисел) — это единственный вариант их совершения, для операций ввода/вывода — один из, при этом мы говорим, к примеру, «попытайся прочитать из файла что-нибудь за 100мс», и если для чтения ничего нет — поток выполнения будет заблокирован на эти 100мс.
В некоторых случаях это допустимо (например, если мы делаем простое консольное приложение, либо какую-либо утилиту, цель которой — отработать и всё), но в некоторых — нет. К примеру, если мы так застрянем в потоке, в котором обрабатывается UI — наше приложение зависнет. За примерами далеко ходить не нужно — если javascript на сайте сделает while(true);
, то перестанут вызываться какие-либо другие обработчики событий страницы и её придётся закрыть. Те же дела, если начать что-нибудь вычислять под Android'ом в обработчиках 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 что угодно может являться файлом (или наоборот, файл может являться чем угодно))).
И мы можем вызвать некоторую синхронную функцию, передав ей множество дескрипторов, от которых мы ожидаем ввода, либо же хотим что-то записать, которая заблокирует поток до тех пор пока:
- Один или несколько переданных нами дескрипторов не станут готовы к совершению желаемой нами операции.
- Не истекло время ожидания (если оно было задано).
В результате выполнения этой процедуры мы получим множества готовых к чтению/записи файлов.
Callbacks
Самый простой способ получить результаты выполнения асинхронной операции — при её создании передать ссылки на функции, которые будут вызваны при каком-либо прогрессе выполнения/готовности результата.
Это довольно низкоуровневый подход, и часто неумение банально писать функции «в столбик» вместе со злоупотреблением анонимных функций приводит к «callback hell» (ситуация, когда мы имеем четыре-десять уровней вложенности функций, чтобы обработать последовательные операции):
// Вкладываем
function someAsync(a, callback) {
anotherAsync(a, function(b) {
asyncAgain(b, function(c) {
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(c) {
andAgain(b, c, handleAnd);
}
function handleAnd(d) {
lastAsync(d, callback);
}
}
Async Monad
Мы, программисты, любим абстрагировать и обобщать для сокрытия разных сложностей/рутины. Поэтому существует, в том числе, абстракция над асинхронными вычислениями.
Что такое «вычисление»? Это процесс преобразования 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(c) {
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. Тогда они ведут себя как те же корутины, но операции с ней становятся асинхронными, ибо между возвратом/принятием она ждёт результатов своих внутренних асинхронных операций.
Что ж, на этом моя серия статей заканчивается, надеюсь, они кому-нибудь были полезны и не запутали ещё больше.
Комментарии (13)
DmitryKoterov
06.02.2017 10:21+1Самое главное-то не написали.
try { let a = await something(); let b = await xxxxx(a); // или еще что угодно - хоть последовательно, хоть параллельно } catch (e) { console.log(e); throw e; // или еще что-нибудь }
Вот это можно запихнуть в async-функцию, и оно будет работать, как ожидается. И стектрейс будет внятным!
Async-await возвращают исключения в JS, с которыми у него было туго с момента самого появления. (А также возвращают исключения в асинхронное программирование в любом другом языке.)mayorovp
06.02.2017 10:25Внятные стектрейсы зависят только от используемой реализации обещаний (Promise). Ключевые слова async и await тут ни при чем. Нативные обещания в том же Chrome — да, дают внятные стектрейсы (в других браузерах не проверял). А вот всяческие полифилы могут и порушить все.
Rulexec
06.02.2017 11:07Написал «а внутрь неё подаётся получившееся R, либо возбуждается исключение E», но не уточнил, что исключение прямо внутри корутины возникает, ну и примера нет, спасибо.
avigmati
06.02.2017 18:00получается асинхронность = кооперативная многозадачность решенная через event loop?
Rulexec
06.02.2017 20:20Нет, асинхронность может существовать там, где нет никакого event loop'а. Ведь ничто не мешает передать в любой поток коллбэк, который будет вызван, как что-нибудь произошло.
Кооперативная многозадачность решённая через event loop просто порождает асинхронность, а не является ей. Асинхронность — просто абстрактное понятие, описывающее процессы, которые происходят не мгновенно.
vba
06.02.2017 19:33А это случайно не заблуждение отождествлять асинхронизм в глобальном его понимании с конкурентностью? На некоторых платформах(типа jvm) асинхорнизм построен на многопоточности ибо по другому там никак, но это всего лишь частный случай. Если брать ко-рутины то они там совсем по другому построены, и конкурентность там не причем.
mayorovp
07.02.2017 08:53А что мешает использовать подход с event loop на той же jvm? Почему многопоточность вдруг оказалась обязательной?
beduin01
Я никак не могу взять в толк. Ведь фоновая операция (к примеру обращение к БД) должна тоже в каком-то потоке обрабатываться? Где она происходит? В каком-то отдельном потоке? Но кто его создает? И каково количество этих потоков? Ведь у них же тоже должно быть переключение контекста, а значит те же сами блокировки.
При переключении задач процессору же нужно данные от предыдущей задачи сохранить. А перед этим понять в каких регистрах задача данные хранит и их где-то сохранить? Правильно? Где оно сохраняется и как это происходит? Ведь по факту это тоже самое переключение контекста только в миниатрюре.
Rulexec
Первое зависит от реализации. Платформа, предоставляющая event loop и асинхронное API действительно может создать сколько угодно других потоков и просто перекидывать данные оттуда в «наш главный». Но по факту новые потоки нужны только если что-то действительно вычисляем, опросить состояние IO (пример с БД) можно и в том же самом. А как там ОС это проворачивает — неизвестно, может у неё отдельный поток тоже для всего IO, я не знаю.
Второе: да, то же самое микропереключение. Только всё состояние живёт в замыканиях функций. Что, по сути, ничем не отличается от поточного контекста. Т.е. если бы мы это делали там, где нет замыканий, нам пришлось бы в функции, запрашивающие старта асинхронной операции, передавать не только коллбэк, но и какой-то объект контекста, который будет передан этому обработчику во время его вызова.
Rulexec
Тут важно ещё понимать, что «обращение к БД» в основном… не происходит, мы почти всё время просто ждём. Записать/прочитать данные в буффер и отправить его сетевой карте — это относительно быстрая операция, относительно времени, которое проходит между такими операциями. И если у нас времени ожидания достаточно, чтобы ещё повычислять чего-нибудь другого — нам не нужен ещё один поток.
mayorovp
Базовая "фоновая" IO-операция, такая как чтение или запись в сокет либо в файл, фонового потока не требует
*
. Она выполняется с использованием неблокироющего сокета или асинхронного API.Более сложные фоновые задачи зависят от того, кто и как их писал. Если это внешняя библиотека (например, тот же драйвер БД) — то вполне возможно, что она будет использовать блокирующие операции, а значит вызовы надо выносить в отдельный пул потоков.
Если же задача была написана с использованием асинхронных операций — то нет никаких причин существовать дополнительному фоновому потоку во время выполнения задачи.
*
говорят, в linux есть проблемы с файловым асинхронным вводом-выводом, из-за чего в некоторых конфигурациях серверу nginx приходится "выталкивать" чтение файлов в пул потоков. Но сетевой ввод-вывод, а именно он создает больше всего задержек, заставить работать полностью асинхронно не так и трудно.tumikosha
во всяких там FLex'ах обычно есть еще один поток, параллельный главному, долгие операции (БД, Http) туда перекидываются.
Разработчики Python просто клепают библы на C++ к.е. запускают поток параллельный главному и перекидывают таски туда.
Но, в итоге, все равно же получается очередь сообщений между потоками, этакая микро- RabbitMQ
Vadem
Может эта статья вам поможет: There Is No Thread.
Она про async/await из C#, но, думаю, принципиальной разницы тут нет.