Как реализован цикл событий (event loop) в браузере?
На просторах интернета не удалось найти статей на эту тему. Очень подробно расписано, как устроен цикл событий в NodeJS, и почти ничего про браузер. Имеется в виду цикл событий, с помощью которого реализуется асинхронное программирование на JS. В случае, когда окружением, в котором выполняется JS-код, является Node, цикл событий запускается средствами встроенной библиотеки libUV, которая обращается к операционной системе, которая запускает цикл. Но как это происходит в браузере? Поделитесь, кто знает.)
Отслеживать
задан 3 сен 2018 в 12:17
69 1 1 серебряный знак 11 11 бронзовых знаков
чет вы плохо искали, куча статей ведь, из последнего habr.com/company/oleg-bunin/blog/417461, там и про браузер
3 сен 2018 в 12:19
Держи два видео Первое и Второе
3 сен 2018 в 16:05
А разве есть разница? Тот же libevent, не?
3 сен 2018 в 16:07
Да вот как ни странно, именно про это — не куча. Спасибо за ссылку, но там тоже тема не раскрыта. Да, нарисован event loop и упомянуто про таски и их приоритеты, но хотелось бы знать, как и когда запускается этот event loop, и что он делает, когда тасков нет. Одним словом, в подробностях.
3 сен 2018 в 17:23
@Eeki, event loop — это просто высокоуровневая концепция, движок v8 вообще не знает, что такое event loop и в его сурсах вы не встретите это слово нигде. есть 2 класса TaskQueue и TaskRunner , TQ — содержит в себе контейнер queue
Событийный цикл: микрозадачи и макрозадачи
Поток выполнения в браузере, равно как и в Node.js, основан на событийном цикле.
Понимание работы событийного цикла важно для оптимизаций, иногда для правильной архитектуры.
В этой главе мы сначала разберём теорию, а затем рассмотрим её практическое применение.
Событийный цикл
Идея событийного цикла очень проста. Есть бесконечный цикл, в котором движок JavaScript ожидает задачи, исполняет их и снова ожидает появления новых.
Общий алгоритм движка:
- Пока есть задачи:
- выполнить их, начиная с самой старой
- Бездействовать до появления новой задачи, а затем перейти к пункту 1
Это формализация того, что мы наблюдаем, просматривая веб-страницу. Движок JavaScript большую часть времени ничего не делает и работает, только если требуется исполнить скрипт/обработчик или обработать событие.
- Когда загружается внешний скрипт , то задача – это выполнение этого скрипта.
- Когда пользователь двигает мышь, задача – сгенерировать событие mousemove и выполнить его обработчики.
- Когда истечёт таймер, установленный с помощью setTimeout(func, . ) , задача – это выполнение функции func
- И так далее.
Задачи поступают на выполнение – движок выполняет их – затем ожидает новые задачи (во время ожидания практически не нагружая процессор компьютера)
Может так случиться, что задача поступает, когда движок занят чем-то другим, тогда она ставится в очередь.
Очередь, которую формируют такие задачи, называют «очередью макрозадач» (macrotask queue, термин v8).
Например, когда движок занят выполнением скрипта, пользователь может передвинуть мышь, тем самым вызвав появление события mousemove , или может истечь таймер, установленный setTimeout , и т.п. Эти задачи формируют очередь, как показано на иллюстрации выше.
Задачи из очереди исполняются по правилу «первым пришёл – первым ушёл». Когда браузер заканчивает выполнение скрипта, он обрабатывает событие mousemove , затем выполняет обработчик, заданный setTimeout , и так далее.
Пока что всё просто, не правда ли?
Отметим две детали:
- Рендеринг (отрисовка страницы) никогда не происходит во время выполнения задачи движком. Не имеет значения, сколь долго выполняется задача. Изменения в DOM отрисовываются только после того, как задача выполнена.
- Если задача выполняется очень долго, то браузер не может выполнять другие задачи, обрабатывать пользовательские события, поэтому спустя некоторое время браузер предлагает «убить» долго выполняющуюся задачу. Такое возможно, когда в скрипте много сложных вычислений или ошибка, ведущая к бесконечному циклу.
Это была теория. Теперь давайте взглянем, как можно применить эти знания.
Пример 1: разбиение «тяжёлой» задачи.
Допустим, у нас есть задача, требующая значительных ресурсов процессора.
Например, подсветка синтаксиса (используется для выделения цветом участков кода на этой странице) – довольно процессороёмкая задача. Для подсветки кода надо выполнить синтаксический анализ, создать много элементов для цветового выделения, добавить их в документ – для большого текста это требует значительных ресурсов.
Пока движок занят подсветкой синтаксиса, он не может делать ничего, связанного с DOM, не может обрабатывать пользовательские события и т.д. Возможно даже «подвисание» браузера, что совершенно неприемлемо.
Мы можем избежать этого, разбив задачу на части. Сделать подсветку для первых 100 строк, затем запланировать setTimeout (с нулевой задержкой) для разметки следующих 100 строк и т.д.
Чтобы продемонстрировать такой подход, давайте будем использовать для простоты функцию, которая считает от 1 до 1000000000 .
Если вы запустите код ниже, движок «зависнет» на некоторое время. Для серверного JS это будет явно заметно, а если вы будете выполнять этот код в браузере, то попробуйте понажимать другие кнопки на странице – вы заметите, что никакие другие события не обрабатываются до завершения функции счёта.
let i = 0; let start = Date.now(); function count() < // делаем тяжёлую работу for (let j = 0; j < 1e9; j++) < i++; >alert("Done in " + (Date.now() - start) + 'ms'); > count();
Браузер может даже показать сообщение «скрипт выполняется слишком долго».
Давайте разобьём задачу на части, воспользовавшись вложенным setTimeout :
let i = 0; let start = Date.now(); function count() < // делаем часть тяжёлой работы (*) do < i++; >while (i % 1e6 != 0); if (i == 1e9) < alert("Done in " + (Date.now() - start) + 'ms'); >else < setTimeout(count); // планируем новый вызов (**) >> count();
Теперь интерфейс браузера полностью работоспособен во время выполнения «счёта».
Один вызов count делает часть работы (*) , а затем, если необходимо, планирует свой очередной запуск (**) :
- Первое выполнение производит счёт: i=1…1000000.
- Второе выполнение производит счёт: i=1000001…2000000.
- …и так далее.
Теперь если новая сторонняя задача (например, событие onclick ) появляется, пока движок занят выполнением 1-й части, то она становится в очередь, и затем выполняется, когда 1-я часть завершена, перед следующей частью. Периодические возвраты в событийный цикл между запусками count дают движку достаточно «воздуха», чтобы сделать что-то ещё, отреагировать на действия пользователя.
Отметим, что оба варианта – с разбиением задачи с помощью setTimeout и без – сопоставимы по скорости выполнения. Нет большой разницы в общем времени счёта.
Чтобы сократить разницу ещё сильнее, давайте немного улучшим наш код.
Мы перенесём планирование очередного вызова в начало count() :
let i = 0; let start = Date.now(); function count() < // перенесём планирование очередного вызова в начало if (i < 1e9 - 1e6) < setTimeout(count); // запланировать новый вызов >do < i++; >while (i % 1e6 != 0); if (i == 1e9) < alert("Done in " + (Date.now() - start) + 'ms'); >> count();
Теперь, когда мы начинаем выполнять count() и видим, что потребуется выполнить count() ещё раз, мы планируем этот вызов немедленно, перед выполнением работы.
Если вы запустите этот код, то легко заметите, что он требует значительно меньше времени.
Всё просто: как вы помните, в браузере есть минимальная задержка в 4 миллисекунды при множестве вложенных вызовов setTimeout . Даже если мы указываем задержку 0 , на самом деле она будет равна 4 мс (или чуть больше). Поэтому чем раньше мы запланируем выполнение – тем быстрее выполнится код.
Итак, мы разбили ресурсоёмкую задачу на части – теперь она не блокирует пользовательский интерфейс, причём почти без потерь в общем времени выполнения.
Пример 2: индикация прогресса
Ещё одно преимущество разделения на части крупной задачи в браузерных скриптах – это возможность показывать индикатор выполнения.
Обычно браузер отрисовывает содержимое страницы после того, как заканчивается выполнение текущего кода. Не имеет значения, насколько долго выполняется задача. Изменения в DOM отображаются только после её завершения.
С одной стороны, это хорошо, потому что наша функция может создавать много элементов, добавлять их по одному в документ и изменять их стили – пользователь не увидит «промежуточного», незаконченного состояния. Это важно, верно?
В примере ниже изменения i не будут заметны, пока функция не завершится, поэтому мы увидим только последнее значение i :
> count();
…Но, возможно, мы хотим что-нибудь показать во время выполнения задачи, например, индикатор выполнения.
Если мы разобьём тяжёлую задачу на части, используя setTimeout , то изменения индикатора будут отрисованы в промежутках между частями.
Так будет красивее:
while (i % 1e3 != 0); if (i < 1e7) < setTimeout(count); >> count();
Теперь показывает растущее значение i – это своего рода индикатор выполнения.
Пример 3: делаем что-нибудь после события
В обработчике события мы можем решить отложить некоторые действия, пока событие не «всплывёт» и не будет обработано на всех уровнях. Мы можем добиться этого, обернув код в setTimeout с нулевой задержкой.
В главе Генерация пользовательских событий мы видели пример: наше событие menu-open генерируется через setTimeout , чтобы оно возникло после того, как полностью обработано событие «click».
menu.onclick = function() < // . // создадим наше собственное событие с данными пункта меню, по которому щёлкнули мышью let customEvent = new CustomEvent("menu-open", < bubbles: true >); // сгенерировать наше событие асинхронно setTimeout(() => menu.dispatchEvent(customEvent)); >;
Макрозадачи и Микрозадачи
Помимо макрозадач, описанных в этой части, существуют микрозадачи, упомянутые в главе Микрозадачи.
Микрозадачи приходят только из кода. Обычно они создаются промисами: выполнение обработчика .then/catch/finally становится микрозадачей. Микрозадачи также используются «под капотом» await , т.к. это форма обработки промиса.
Также есть специальная функция queueMicrotask(func) , которая помещает func в очередь микрозадач.
Сразу после каждой макрозадачи движок исполняет все задачи из очереди микрозадач перед тем, как выполнить следующую макрозадачу или отобразить изменения на странице, или сделать что-то ещё.
setTimeout(() => alert("timeout")); Promise.resolve() .then(() => alert("promise")); alert("code");
Какой здесь будет порядок?
- code появляется первым, т.к. это обычный синхронный вызов.
- promise появляется вторым, потому что .then проходит через очередь микрозадач и выполняется после текущего синхронного кода.
- timeout появляется последним, потому что это макрозадача.
Более подробное изображение событийного цикла выглядит так:
Все микрозадачи завершаются до обработки каких-либо событий или рендеринга, или перехода к другой макрозадаче.
Это важно, так как гарантирует, что общее окружение остаётся одним и тем же между микрозадачами – не изменены координаты мыши, не получены новые данные по сети и т.п.
Если мы хотим запустить функцию асинхронно (после текущего кода), но до отображения изменений и до новых событий, то можем запланировать это через queueMicrotask .
Вот пример с индикатором выполнения, похожий на предыдущий, но в этот раз использована функция queueMicrotask вместо setTimeout . Обратите внимание – отрисовка страницы происходит только в самом конце. Как и в случае обычного синхронного кода.
while (i % 1e3 != 0); if (i < 1e6) < queueMicrotask(count); >> count();
Итого
Более подробный алгоритм событийного цикла (хоть и упрощённый в сравнении со спецификацией):
- Выбрать и исполнить старейшую задачу из очереди макрозадач (например, «script»).
- Исполнить все микрозадачи:
- Пока очередь микрозадач не пуста: — Выбрать из очереди и исполнить старейшую микрозадачу
- Отрисовать изменения страницы, если они есть.
- Если очередь макрозадач пуста – подождать, пока появится макрозадача.
- Перейти к шагу 1.
Чтобы добавить в очередь новую макрозадачу:
- Используйте setTimeout(f) с нулевой задержкой.
Этот способ можно использовать для разбиения больших вычислительных задач на части, чтобы браузер мог реагировать на пользовательские события и показывать прогресс выполнения этих частей.
Также это используется в обработчиках событий для отложенного выполнения действия после того, как событие полностью обработано (всплытие завершено).
Для добавления в очередь новой микрозадачи:
- Используйте queueMicrotask(f) .
- Также обработчики промисов выполняются в рамках очереди микрозадач.
События пользовательского интерфейса и сетевые события в промежутках между микрозадачами не обрабатываются: микрозадачи исполняются непрерывно одна за другой.
Поэтому queueMicrotask можно использовать для асинхронного выполнения функции в том же состоянии окружения.
Web Workers
Для длительных тяжёлых вычислений, которые не должны блокировать событийный цикл, мы можем использовать Web Workers.
Это способ исполнить код в другом, параллельном потоке.
Web Workers могут обмениваться сообщениями с основным процессом, но они имеют свои переменные и свой событийный цикл.
Web Workers не имеют доступа к DOM, поэтому основное их применение – вычисления. Они позволяют задействовать несколько ядер процессора одновременно.
JavaScript Event Loop
Summary: in this tutorial, you’ll learn about the event loop in JavaScript and how JavaScript achieves the concurrency model based on the event loop.
JavaScript single-threaded model
JavaScript is a single-threaded programming language. This means that JavaScript can do only one thing at a single point in time.
The JavaScript engine executes a script from the top of the file and works its way down. It creates the execution contexts, and pushes, and pops functions onto and off the call stack in the execution phase.
If a function takes a long time to execute, you cannot interact with the web browser during the function’s execution because the page hangs.
A function that takes a long time to complete is called a blocking function. Technically, a blocking function blocks all the interactions on the webpage, such as mouse clicks.
An example of a blocking function is a function that calls an API from a remote server.
The following example uses a big loop to simulate a blocking function:
function task(message) < // emulate time consuming task let n = 10000000000; while (n > 0) < n--; >console.log(message); > console.log('Start script. '); task('Call an API'); console.log('Done!');Code language: JavaScript (javascript)
In this example, we have a big while loop inside the task() function that emulates a time-consuming task. The task() function is a blocking function.
The script hangs for a few seconds (depending on how fast the computer is) and issues the following output:
Start script. Download a file. Done!
To execute the script, the JavaScript engine places the first call console.log() on top of the call stack and executes it. Then, it places the task() function on top of the call stack and executes the function.
However, it’ll take a while to complete the task() function. Therefore, you’ll see the message ‘Download a file.’ a little time later. After the task() function completes, the JavaScript engine pops it off the call stack.
Finally, the JavaScript engine places the last call to the console.log(‘Done!’) function and executes it, which will be very fast.
Callbacks to the rescue
To prevent a blocking function from blocking other activities, you typically put it in a callback function for execution later. For example:
console.log('Start script. '); setTimeout(() => < task('Download a file.'); >, 1000); console.log('Done!');Code language: JavaScript (javascript)
In this example, you’ll see the message ‘Start script. ‘ and ‘Done!’ immediately. And after that, you’ll see the message ‘Download a file’ .
Here’s the output:
Start script. Done! Download a file.
As mentioned earlier, the JavaScript engine can do only one thing at a time. However, it’s more precise to say that the JavaScript runtime can do one thing at a time.
The web browser also has other components, not just the JavaScript engine.
When you call the setTimeout() function, make a fetch request, or click a button, the web browser can do these activities concurrently and asynchronously.
The setTimeout() , fetch requests and DOM events are parts of the Web APIs of the web browser.
In our example, when calling the setTimeout() function, the JavaScript engine places it on the call stack, and the Web API creates a timer that expires in 1 second.

Then JavaScript engine places the task() function into a queue called a callback queue or a task queue:

The event loop is a constantly running process that monitors both the callback queue and the call stack.
If the call stack is not empty, the event loop waits until it is empty and places the next function from the callback queue to the call stack. If the callback queue is empty, nothing will happen:

See another example:
console.log('Hi!'); setTimeout(() => < console.log('Execute immediately.'); >, 0); console.log('Bye!'); Code language: JavaScript (javascript)
In this example, the timeout is 0 seconds, so the message ‘Execute immediately.’ should appear before the message ‘Bye!’ . However, it doesn’t work like that.
The JavaScript engine places the following function call on the callback queue and executes it when the call stack is empty. In other words, the JavaScript engine executes it after the console.log(‘Bye!’) .
console.log('Execute immediately.');Code language: JavaScript (javascript)
Here’s the output:
Hi! Bye! Execute immediately.
The following picture illustrates JavaScript runtime, Web API, Call stack, and Event loop:

In this tutorial, you have learned about the JavaScript event loop, a constantly running process that coordinates the tasks between the call stack and callback queue to achieve concurrency.
Understanding event loop in JavaScript
Learn the basic concepts about the JavaScript Event Loop.
We often hear that JavaScript is a single-threaded programming language, which means it executes all of the instructions line by line in a synchronous manner. Thus since everything works on the main thread, there seems to be no possibility of executing parallel processes in JavaScript.
But now let’s assume that we have a function which is taking some time to execute, for example in the below function which is having a loop over 10k times, the console.log() would be executed once the loop is over and in the meantime, our UI interaction with the browser would be interrupted.
function someTimeTakingFunc() for(var i = 0; i 10000; i++) > console.log("Loop has been executed") >
The above scenario seems to be impractical, but there might be cases where any function can take an indeterminate amount of time and our main thread would be blocked, for example calling an API for fetching data from the server-side. And this is the practical use case we are dealing with in development every day. So how JavaScript handles this and where does the event loop come into the picture? We will surely get to know this, but before moving further, let’s understand the basic memory architecture in JavaScript.
Memory Organization of JavaScript:
The Javascript Engine consists of two main components:
- Memory Heap — this is where the memory allocation happens, all of our object variables are assigned here in a random manner.
- Call Stack — this is where your function calls are stored.
Understanding the Call Stack
Call stack is a LIFO (last in first out) data structure. All of the function calls are pushed into this call stack and are said to be a frame. In short function, a stack is nothing but the simple stack data structure which keeps track of the function currently being executed.
Let’s understand the above concept with the example of the following code snippet:
function square(b) return b * b ; > function cube(x) return x * square(x) > console.log(cube(8));
The above snippet is an example of defining a cube function, which is calling a square function, to find the cube of a number passed in the argument. But how does this work in call stack? Let’s understand step by step:
- When we call the console.log() line, this code/function is pushed into the call stack.
- Now the above console.log() function is called the cube function, hence this function is pushed into the stack.
- After this the cube function is calling the square function, and now this would be pushed into the stack.
- Now once the square function is executed, and the result is returned, then the square function is popped out of the stack.
- Since at this step, we have got the square function result, so cube function would be executed and popped out of the call stack.
- At last, the console.log() would be executed and this function would be popped out of the stack and the stack would now be empty.

Now since we have understood that call stacks are possible to execute the function in a step by step manner, there seems to be no possibility of keeping something into parallel. But there is something called «WEB APIs» in the browser environment, which is some additional capabilities provided by browsers in addition to JavaScript Engine.
Javascript Web APIs
These are the additional functionality, that helps us perform some additional tasks which cannot be run using the main thread. However, since our JavaScript runtime is single-threaded, it can export some tasks to the WEB APIs which helps us to respond to multiple threads. Example of some web APIs are:
- DOM
- Ajax (Network requests)
- setTimeout()
For instance, setTimeout() is called, the browser delegates the task to a different thread to calculate the time interval specified in the argument of the setTimeout() method, and once done this tread would then call the desired function in callback stack.
Since JavaScript is single-threaded, the browser has the capability of delegating the task in multiple threads. But how does the event loop help in these executions? But now we are good to go ahead with Event Loop.
Event Loop && Event Queue
Since we know that Web APIs delegate some of the tasks to different threads, on completion of these tasks, how the main or desired functions are sent to the call stack.
Event Queue is a special queue, which keeps track of all the functions queues, which are needed to be pushed into the call stack. The event queue is responsible for sending new functions to the track for processing. The queue data structure is required to maintain the correct sequence in which all operations should be sent for execution.
Let’s take an example, in the following example, we are using a setTimeout function, which will log the «executed» string after 2000 milliseconds.
setTimeout(function() console.log("Executed";) >, 2000);
Now when a setTimeout operation is processed in the call stack. On its execution, it calls a web API which fires a timer for 2000 milliseconds. After 2000 milliseconds has been elapsed, the web API, place the callback function of setTimeout in the event queue.
Here need to mention that, just placing our function does not necessarily imply that the function will get executed. This function has to be pushed into the call stack for execution and here the event loop comes into the picture. The event loop waits for the function stack to be empty, once the call stack is empty this will push the first function from the event queue to the call stack, and in this way, the desired function will be called.

Thus event loop works in a cyclic manner, where it continually checks whether or not the call stack is empty. If it is empty, new functions are added from the event queue. If it is not, then the current function call is processed.

Written by Aman Agrawal
Aman is a software developer at LoginRadius and keen to learn and work upon new technologies.
