Event Loop в деталях¶
В данной статье поговорим о том, почему Event Loop вообще был создан, как с ним работать и почему про него спрашивают на собесах.
JS был спроектирован как однопоточный язык программирования. Это значит, что он может выполнять только одну операцию одновременно. Тем не менее у JavaScript есть такой механизм как Event Loop, который как раз и позволяет выполнять "асинхронные" операции. Почему "асинхронные" в кавычках? Да просто потому что JavaScript тоже выполняет их синхронно, асинхронности в самом JavaScript как таковой нет. Вперед под кат, будем разбираться.
Синхронный код¶
С синхронным кодом все более или менее ясно: интерпретатор проходится по каждой инструкции выполняет ее и все работает.
1 2 3 4 5 6 7 8 |
|
stateDiagram-v2
VarDecl: Объявление переменной - acc
VarOutput: Вывод переменной - acc
VarInc: Инкрементация переменной - acc
VarOutput2: Вывод переменной - acc
VarDecl2: Объявление переменной - anotherAcc
VarOutput3: Вывод переменной - acc, anotherAcc
[*] --> VarDecl
VarDecl --> VarOutput
VarOutput --> VarInc
VarInc --> VarOutput2
VarOutput2 --> VarDecl2
VarDecl2 --> VarOutput3
VarOutput3 --> [*]
Асинхронный код¶
С асинхронным кодом все немножко сложнее. Рассмотрим следующий пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Как мы можем увидеть console.log
, который был в setTimeout
почему-то выполнился позже, но почему так случилось?
Тут в силу и вступает Event Loop. Так как setTimeout
это асинхронная операция (таймер высчитывается на стороне браузера, а не в JS).
Проходимся по коду как интерпретатор¶
Давайте рассмотрим все более подробно. Мы объявляем все наши переменные и проделываем с ними какие-то операции. Все синхронные операции будут выполняться как только интерпретатор дойдет до них:
stateDiagram-v2
Program: Скрипт
Program
state Timer {
VarOutput4: Вывод переменной
VarOutput4
}
state EventLoop {
[*]
}
state Program {
VarDecl: Объявление переменной
VarOutput: Вывод переменной
VarInc: Инкрементация переменной
VarOutput2: Вывод переменной
VarDecl2: Объявление переменной
VarOutput3: Вывод переменной
Timer: Таймер
[*] --> VarDecl
VarDecl --> VarOutput
VarOutput --> VarInc
VarInc --> VarOutput2
VarOutput2 --> Timer
Timer --> VarDecl2
VarDecl2 --> VarOutput3
VarOutput3 --> [*]
--
EventLoop: Event Loop
}
Другое дело обстоит с таймером, время которое ожидает таймаут будет считаться на стороне браузера, поэтому операция как бы "пропадет" из очереди. Таймер попал в Event Loop, где будет ждать покуда браузер не пришлет сигнал, о том что время для таймаута вышло и коллбэк внутри таймера можно выполнять.
stateDiagram-v2
Program: Скрипт
Program
state Timer {
VarOutput4: Вывод переменной
VarOutput4
}
state EventLoop {
Timer: Таймер
Timer
}
state Program {
VarDecl: Объявление переменной
VarOutput: Вывод переменной
VarInc: Инкрементация переменной
VarOutput2: Вывод переменной
VarDecl2: Объявление переменной, anotherAcc
VarOutput3: Вывод переменной
[*] --> VarDecl
VarDecl --> VarOutput
VarOutput --> VarInc
VarInc --> VarOutput2
VarOutput2 --> VarDecl2
VarDecl2 --> VarOutput3
VarOutput3 --> [*]
--
EventLoop: Event Loop
}
Теперь самое неочевидное: даже если таймаут выполнился, а функция в которой мы выполняем все синхронные операции еще не выполнилась, Event Loop будет держать все что в нем содержится, покуда у нас не очистится Call Stack. Только после того как все синхронные операции в функции выполнились Event Loop отдаст нам наш таймер, который мы сможем выполнить:
stateDiagram-v2
Program: Скрипт
Program
state EventLoop {
[*] --> VarOutput4
}
state Program {
VarDecl: Объявление переменной
VarOutput: Вывод переменной
VarInc: Инкрементация переменной
VarOutput2: Вывод переменной
VarDecl2: Объявление переменной
VarOutput3: Вывод переменной, anotherAcc
VarOutput4: Вывод переменной (из таймера)
[*] --> VarDecl
VarDecl --> VarOutput
VarOutput --> VarInc
VarInc --> VarOutput2
VarOutput2 --> VarDecl2
VarDecl2 --> VarOutput3
VarOutput3 --> VarOutput4
VarOutput4 --> [*]
--
EventLoop: Event Loop
}
Лезем в дебри¶
Как уже было сказано выше интерпретатор в JavaScript выполняет одну операцию за раз, все что является асинхронным он отправляет в Event Loop. Однако, вы наверное могли слышать о таких вещах как "макротаски" и "микротаски".
Дело в том, что Event Loop - единственный механизм в JavaScript, который позволяет реализовать асинхронность (хотя по сути все операции выполняются синхронно, просто очень быстро, об этом далее). Event Loop является стеком, где хранятся все задачи, которые не вошли в синхронный поток выполнения. После завершения синхронного потока - задачи начинают выполняться из Event Loop'а. Однако у Event Loop'а тоже есть свои правила. Он делит все задачи на подтипы:
- Микрозадачи
- Макрозадачи
- Задачи отрисовки
Макрозадачами являются все асинхронные операции, такие как XmlHTTPRequest
, setTimeout
и так далее.
В микрозадачи попадают в основном только две категории: then
у промисов, а также Intersection Observer.
В задачи отрисовки попадают задачи связанные с отрисовкой и обновлением контента страницы.
Если бы мы делали свой Event Loop, то он бы выглядел следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
|
Самое интересное в данном коде находится внизу, внутри цикла while
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Мы можем наглядно рассмотреть как Event Loop делает решения о том какую задачу брать и выполнять первой.
- Сначала Event Loop проверяет выполнились ли все синхронные задачи
- Потом выполняются все задачи из микротасков
- После выполнения всех микротасков - очередь очищается
- Затем мы берем одну макрозадачу из списка и выполняем ее
- После выполнения мы смотрим нужно ли нам сделать перерисовку страницы
- Если перерисовать страницу нужно - делаем это
- Все снова начинается с первого пункта
Эксперименты ✨¶
Весь этот флоу достаточно легко проверить. Давайте возьмем несколько микро и макро операций и попробуем запустить их:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Внимание
На всякий случай напомню, что коллбэк внутри конструктора промиса является синхронным.
Как мы можем увидеть тут все выводится согласно коду, который мы рассматривали выше.
- Сначала выполнились все синхронные операции:
Step 1
,Step 3
; - Потом выполнились все микротаски:
Step 4
; - Затем выполнились все макротаски:
Step 2
,Step 5
;
Следует заметить что задачи из каждой очереди реализованы по принципу FIFO (First In, First Out) (Первый вошел, первый вышел), именно поэтому в списке макрозадач вывелось Step 2
, а затем Step 5
.
stateDiagram-v2
Program: Скрипт
SyncTasks: Синхронные задачи
MicroTasks: Микро-задачи
MacroTasks: Макро-задачи
Program
state Program {
direction LR
SyncTasks --> MicroTasks
MicroTasks --> MacroTasks
}
state SyncTasks {
Step1: Step 1 - In global scope
Step3: Step 3 - In Promise constructor
Step1 --> Step3
}
state MicroTasks {
Step4: Step 4 - In then
}
state MacroTasks {
Step2: Step 2 - In setTimeout
Step5: Step 5 - In another setTimeout
Step2 --> Step5
}
Два then¶
Теперь давайте добавим еще один then
к нашему промису и посмотрим что случится:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Как мы можем увидеть, особо ничего не поменялось. Просто добавилась еще одна микрозадача, в целом список выводимых строк никак не поменялся, просто после Step 4
добавился Step 5
.
stateDiagram-v2
Program: Скрипт
SyncTasks: Синхронные задачи
MicroTasks: Микро-задачи
MacroTasks: Макро-задачи
Program
state Program {
direction LR
SyncTasks --> MicroTasks
MicroTasks --> MacroTasks
}
state SyncTasks {
Step1: Step 1 - In global scope
Step3: Step 3 - In Promise constructor
Step1 --> Step3
}
state MicroTasks {
Step4: Step 4 - In then
Step5: Step 5 - In another then
Step4 --> Step5
}
state MacroTasks {
Step2: Step 2 - In setTimeout
Step6: Step 6 - In another setTimeout
Step2 --> Step6
}
Два Promise
с двумя then
¶
Теперь давайте попробуем провернуть то же, но уже с двумя промисами:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
Тут уже что-то по интереснее, давайте разбираться. Все синхронные задачи выполнились первыми. Затем подтянулись микротаски, но не в обычном порядке: вместо того чтобы выполнить console.log
в последовательности Step 4
, Step 5
, Step 8
, Step 9
, он выполнился в последовательности Step 4
, Step 8
, Step 5
, Step 9
. Сейчас разберемся почему так случилось.
stateDiagram-v2
Program: Скрипт
Wrong: ❌
Right: ✅
Step4: Step 4 - In then
Step5: Step 5 - In another then
Step8: Step 8 - In then
Step9: Step 9 - In another then
Step44: Step 4 - In then
Step55: Step 5 - In another then
Step88: Step 8 - In then
Step99: Step 9 - In another then
Program
state Program {
Wrong
--
Right
}
state Wrong {
Step4 --> Step5
Step5 --> Step8
Step8 --> Step9
}
state Right {
Step44 --> Step88
Step88 --> Step55
Step55 --> Step99
}
Дело в том, что интерпретатор JavaScript выполняет код шаг за шагом. Он увидел что у нас есть then
и поместил их в очередь микрозадач, когда пришла микрозадачи отработали - интерпретатор увидел еще один then
и опять поместил его в очередь микрозадач. Таким образом все then
, которые были после первых then
выполнялись в ряд после того как первичные then
отработали.
Микрозадача, внутри которой другая микрозадача¶
Давайте создадим промис, внутри которого будет объявляться макрозадача. Сделаем это опять же с помощью промисов:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
Тут все достаточно тривиально. Во время выполнения одной из микрозадач была найдена макрозадача. Эта макрозадача была помещена в конец очереди макрозадач. Поэтому Step 4
вывелся в конце (потому что данная микрозадача была добавлена позже всех).
Макрозадача, внутри которой микрозадача¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
Давайте вспомним что макрозадачи выполняются по одной. Перед тем как выполнять каждую следующую задачу Event Loop проверяет нет ли у нас активных задач из Call Stack, а также нет ли микрозадач.
Как только новая задача находится и Call Stack пустой - она сразу же выполняется. Вот почему Step 3
выполнился сразу же после Step 2
.