Event Loop и асинхронный код: оптимизация JavaScript-приложений
Event loop и оптимизация приложений при помощи асинхронного кода
ГлавнаяБлогРазработчикамEvent loop и оптимизация приложений при помощи асинхронного кода
Разработчикам25 октября 2022

Event loop и оптимизация приложений при помощи асинхронного кода

Фотография автора
Александр Клыков фронтенд-разработчик

Что делать, чтобы при добавлении большой функции приложение оставалось таким же отзывчивым или как не сломать сайт бесконечным циклом — в этом материале разберем способы оптимизации ресурсоемких задач, лучше поймем внутренние процессы интерпретаторов JavaScript и научимся осознанно использовать их. Как полагается, начнем с терминов и определений, далее рассмотрим примеры.  

Call stack и event loop в деталях

Call Stack или стек вызовов — это механизм, который позволяет интерпретаторам JavaScript выполнять задачи в определнной последовательности. Когда в коде вызывается функция, интерпретатор кладет ее в стек. Любая вложенная функция также кладется «наверх» стека. Как только вложенность заканчивается, функции выполняются по принципу LIFO (последним пришел - первым ушел), то есть последняя добавленная в стек функция будет выполнена первой. Если, конечно, на момент добавления в стеке ничего больше не выполняется. 

Tasks или задачи — это фрагменты кода, порядок выполнения которых определяется очередью. Примерами задач могут служить коллбеки event listener или загрузка внешнего скрипта. Все задачи, как было указано раньше, добавляется в очередь задач, откуда они передаются в стек вызовов, в порядке FIFO (первым пришел - первым ушел), то есть, передача задач в call stack, а следовательно, их выполнение происходят в порядке добавления.  

Microtasks или микрозадачи — это асинхронные фрагменты кода, например, .then-метод у промисов. Их порядок выполнения также определяется очередью, но в этом случае, очередью микрозадач.

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

Изображение статьи


Описанное поведение взаимодействия очередей и стека вызовов циклично. На самом деле, оно буквально происходит в цикле. В цикле событий.

Event Loop или цикл событий — это последний вводный термин, который нам потребуется. Это цикл, который контролирует порядок передачи в стек вызовов задач, микрозадач и задач рендера. 

Изображение статьи

Цикл работает по следующей схеме:

  1. Сначала цикл передает в стек одну задачу, задача выполняется и покидает стек;
  2. После того как стек станет пустым, цикл передает в него поочередно все микрозадачи. Они выполняются и стек пустеет;
  3. После опустошения стека, цикл передает в него поочередно все задачи рендера.

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

«Забивание» событийного цикла

Но что произойдет, если у нас в коде будет очень ресурсоемкая функция, на выполнение которой потребуется пара секунд или больше? Или если потребуется бесконечный цикл?

Изображение статьи

В примере выше в консоль будет бесконечно выводиться have a nice day! пока браузер не выдаст ошибку и не предложит прервать процесс. С точки зрения работы цикла событий выглядеть это будет так: из очереди задач принимается цикл, он передается в стек вызовов и… никогда не завершается. Следовательно, стек никогда не пустеет и передать туда микрозадачи нельзя. Ну, а если нельзя передать микрозадачи, то выполнить ререндер невозможно. Как и какую-либо новую задачу. Другими словами, весь функционал страницы, в которой присутствует бесконечный цикл, ограничивается запуском этого цикла.

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

Изображение статьи

Разбивай и властвуй

Можно не забивать цикл, если разбить задачу или микрозадачу на множество мелких шагов. 

Самый очевидный путь для реализации разбитой на шаги задачи — поставить setTimeout внутрь рекурсивной функции. В примере ниже происходит следующая последовательность действий:

  1. loop передается из очереди задач через цикл событий в стек вызовов; 
  2. В стек из loop также попадает setTimeout, который отправляет коллбек внутри себя в Web API, а оттуда коллбек попадает в очередь задач (так как мы delay не указали, то в нашем случае, коллбек окажется в очереди через 4 миллисекунды). 
  3. К этому времени наш первый loop уже покинул стек, так как свою задачу, поставить setTimeout, он уже выполнил. А это значит, что стек освободился; 
  4.  В стек можно передать микрозадачи и рендер;
  5.  После их выполнения цикл событий отправит в стек коллбек из setTimeout, который вызовет loop опять.  

Таким образом, мы не забиваем цикл событий, позволяя бесконечной рекурсивной функции работать не ломая функционал сайта.
Изображение статьи Очевидно, что у этого подхода есть недостаток. Наш стек все еще не в идеальном состоянии. Так как коллбек вызовет loop, loop попадет наверх стека, что увеличивает время выполнения задачи. Сначала стек должен выполнить loop, потом закончить выполнение коллбека. 

Promise и setTimeout для оптимизации крупной задачи

Для решения этой трудности можно использовать альтернативный подход разбиения задачи на шаги — setTimeout внутри await new Promise.

Изображение статьи

Итак, что же происходит в нашем примере? 

  1. Цикл, который исполняет асинхронную функцию, отправляется в очередь задач, откуда попадает в стек;
  2. В цикле создается новый промис, resolve которого (а это функция), передается в setTimeout, как коллбек; 
  3. setTimeout отправляет resolve через Web API в очередь задач; 
  4. Так как промис не может завершиться до выполнения resolve, await конструкция создает микрозадачу. Она ожидает выполнение resolve, который должен сначала попасть в стек из очереди задач. Эта микрозадача содержит в себе следующую итерацию цикла, так как по сути, await это синтаксический сахар, который оборачивает идущий после себя код в .then; 
  5. Изначальная задача выполнилась, стек опустел. Теперь в него могут отправиться микрозадачи и рендерные задачи; 
  6. После ренедера, в стек попадает коллбек из предыдущей итерации цикла. Resolve выполняется, коллбек покидает стек;
  7. В стек попадают микрозадачи, в том числе и новая итерация цикла. Она выполняется до await; 
  8. setTimeout снова отправляет resolve в очередь задач, а следующая итерация цикла отправляется в очередь микрозадач;
  9. Микрозадача покидает стек, после рендера стек снова будет доступен для любой задачи. 


Каждая итерация цикла стала своей собственной задачей или микрозадачей, что означает, что «забивание» цикла событий невозможно и в стеке наши микрозадачи не будут иметь большую вложенность. Что, в свою очередь, значит, это более оптимизированный подход с меньшим количеством возможных точек поломки. 

Конструкцию вида setTimeout((resolve)) можно беспрепятственно поместить внутрь then-метода объекта. Это сделает объект Thenable, чего достаточно для работы с оператором await. await передаст методу resolve-функцию и будет ожидать ее выполнения. 

Изображение статьи

Чтобы ESLint не выдавал ошибку, следует реализовать данный функционал через класс. 

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

Изображение статьи


Разбиение задач на шаги, используя принцип работы событийного цикла JavaScript — мощный прием оптимизации кода, особенно важный, когда приложение изобилует массивными функциями и долгими циклами.

3.4к
629

Другие статьи

Ко всем статьям
Фоновое изображение: четверть круга закрыват часть круга

Интересные статьи и кейсы
от Work Solutions

Нажимая кнопку «Подписаться», я даю согласие на обработку персональных данных

Спасибо за подписку!

Фоновое изображение: верхний полукруг