Все мы слышали ни раз: JavaScript однопоточный язык программирования. Но, что это означает? Из вышесказанного следует, что интерпретатор языка идет по коду и выполняет команду за командой пока не достигнет конца. Но тогда это означало бы, что если требуется обратиться к серверу, то приходилось бы ждать ответа и только затем переходить к следующей операции, а пользователь ничего бы не смог сделать, пока идет обработка. К счастью, это не так, но как же происходят асинхронные действия и каким образом JavaScript может обрабатывать несколько операция одновременно?
JavaScript и правда не может обработать данные в несколько потоков, но есть некоторые вещи, которые позволяют нам вести работу асинхронно. Ведь JavaScript может выполняться в различных средах: браузер, NodeJS для сервера и тд. В данной статье мы будем рассматривать примеры только в рамках браузерного окружения и движка V8.
Итак, перед тем как рассматривать практические примеры, давайте познакомимся с некоторыми терминами, а именно: heap, call stack, WebApi, macro и microtask queue, eventloop.
Heap - это некий участок памяти, выделяемый, например, под хранение объектов.
Call stack - это то место, куда по ходу работы программы попадают вызванные функций (по принципу LIFO) после чего выполняются.
WebApi - это интерфейс, предоставляемый браузером. С помощью него мы можем взаимодействовать с DOM-деревом, устанавливать таймеры, отслеживать клики по элементам, отправлять запросы и тд
Macrotask queue - это очередь макротасок, сюда попадают некоторые функции (по принципу FIFO какие именно мы разберем далее), и если callstack пустой, то первая из очереди будет помещена в callstack и доступна к выполнению. Далее как только callstack очистится, будет вызвана следующая и тд
Microtask queue - это очеред микротасок, сюда также попадают функции, но в отличии от макро - микро таски будут выполняться до тех пор, пока они есть в очереди, и перед макротасками им отдается приоритет.
EventLoop - собственно сам механизм, который и определяет приоритеты, решает какую задачу куда направить и в какой момент времени.
Пример 1. Синхронный код
Итак рассмотрим первый пример, думаю всем заранее понятна очередь выполнения команд из следующего отрезка кода
function fn1() {
console.log('First msg');
}
function fn2() {
console.log('Second msg');
}
function fn3() {
console.log('Third msg');
}
function callAllMsgs() {
console.log('Start');
fn1();
fn2();
fn3();
console.log('End');
}
callAllMsgs();
Но как именно выполняются данные функции? В какой последовательности они помещаются в стек? Итак, схематично процесс выглядит следующим образом:
Изначально интерпретатор пробегает по коду, видит наши функции, объекты и пр. и сохраняет их в памяти (Heap). Далее как только доходит до вызова функции callAllMsgs(), помещает ее в стек, создается контекст выполнения и выводится сообщение ‘Start’. Далее в стек попадает функция fn1() для которой также создается контекст выполнения, исполняется код из функции fn1() после выполнения которого данная функция удаляется из стека и вызывается следующая функция fn2(). Как только все три внутреннии функции будут выполнены в стеке вновь останется только одна функция callAllMsgs(), выведется сообщение ‘End’ и данная функция тоже удалится.
Пример 2. Добавим асинхронную операцию (macrotask)
Изменим наш код и добавим таймер на вызов одной из функции. Как же теперь будет выглядеть процесс исполнения кода?
function fn1() {
console.log('First msg');
}
function fn2() {
console.log('Second msg');
}
function fn3() {
console.log('Third msg');
}
function callAllMsgs() {
fn1();
setTimeout(fn2, 1000);
fn3();
}
callAllMsgs();
Как и в прошлый раз, аналогично, сначала в стек попадет функция callAllMsgs(), далее fn1(), а что случиться затем, когда дело дойдет до вызова функции fn2() из setTimeout’a?
Тут в дело включается WebApi, который возьмет в работу таймаут, выдержит необходимый интервал и поместит данную функцию в очередь макрозадач. При этом eventloop не будет дожидаться этого таймаута, а продолжит выполнение кода, вызвав функцию fn3(), и только затем, как только стек будет пустой, выполнит функцию fn2()
Стоит заметить, что даже если бы таймаут был равен 0 секунд, то результат выводов не изменился, так как задача из очереди макротасок будет передана в стек только после того, как стек будет очищен и все синхронные операции будут выполнены. Этим же объясняется то, что количество миллисекунд, которые мы указываем в setTimeout не обязательно будет равно времени до вызова функции, так как если стек не будет освобожден, функция из очереди макротасок не будет помещена в стек.
Пример 3. Добавим асинхронную операцию (microtask)
Как правило когда говорят про очередь микрозадач (microtask), то речь идет о MutationObserver и Promise. В рамках данной статьи мы будет разбирать примеры, основанные на promise.
Рассмотрим такой пример:
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
const myPromise = new Promise((resolve, reject) => {
console.log(3);
resolve(4);
}).then((value) => console.log(value));
console.log(5);
Теперь помимо таймаута (макрозадача) есть еще и промис (микрозадача). Думаю нет сомнений, что сначала будет выполнен console.log(1), затем console.log(2) будет отправлен в очередь макрозадач посредством WebApi, но что произойдет далее? Код внутри конструктора промисов синхронный, так что сразу же будет выполнен console.log(3), а вот сам resolve и последующая обработка уже попадут в очередь микрозадач и буду ожидать когда стек полностью очистится. Далее будет выполнен console.log(5), и мы получаем ситуация, что у нас есть по одной задаче в очереди микро- и макрозадач.
Что же будет выполнено сначала? Нужно запомнить, что приоритет всегда будет отдан очереди микрозадач, при этом, если из очереди макрозадач задачи берутся по одной и дальше идет проверка пуст ли стек чтобы взять следующую, то очередь микрозадач будет выполняться до тех пор, пока не закончится. Следовательно сначала будет выведен console.log(4), а затем console.log(2).
А что же с рендером?
Мы не будем подробно останавливаться на этапах рендера страницы (requestAnimationFrame, Style Recalculate, Layout, Paint), в рамках данной статьи мы выделим все эти этапы в один, под названием Render. Так когда же у нас идет этап рендера и отрисовки страницы? На схеме ниже изображен порядок выполнения задач. Сначала выполняется весь синхронный run script код, затем выполняются задачи из очереди микрозадач, до тех пор, пока они все не будут выполнены. Затем берется 1 задача из очереди Render, затем одна задача из очереди макротасок и все повторяется заново: выполнение синхронного кода до тех пор, пока стек не очистится => все микротаски => 1 render-таска => 1 макротаска. Если на каком-то из этапов задач нет, то ивент луп просто переходит в следующему.