Что такое цикл обработки событий и стек вызовов в JavaScript?

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

Ответ кроется в его архитектуре времени выполнения, которая включает в себя стек вызовов, веб-API, очереди задач (включая очередь микрозадач) и цикл обработки событий.

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

Стек вызовов: координатор выполнения JavaScript

Однопоточность JavaScript означает, что он может выполнять только одну задачу за раз. Но как он отслеживает, что выполняется, что будет дальше и где возобновить работу после прерывания? Вот тут-то и вступает в игру стек вызовов.

Что такое стек вызовов?

Стек вызовов - это структура данных, которая записывает контекст выполнения вашей программы. Думайте об этом как о списке дел для движка JavaScript.

Вот как это работает:

  • Функции Last-In-First-Out (LIFO) добавляются в верхнюю часть стека и удаляются оттуда после завершения
  • Контекст выполнения - Каждый вызов функции создает новый контекст (например, переменные, аргументы), который помещается в стек

Давайте посмотрим, как работает стек вызовов, проанализировав приведенный ниже простой скрипт и проследив за его выполнением:

function logThree() {
  console.log(‘Three’);
}

Function logThreeAndFour() {
  logThree(); // step 3
  console.log(‘Four’); // step 4
}

console.log(‘One’); // step 1
console.log(‘Two’); // step 2
logThreeAndFour(); // step 3-4
>

В этом пошаговом руководстве:

  • Стек - Это текущие функции, ожидающие выполнения, с последней добавленной функцией вверху.
  • Действие - Описывает, что на самом деле делает движок JavaScript на каждом шаге, включая выполнение функций и удаление их из стека

Вот как стек вызовов обрабатывает приведенный выше сценарий:

Шаг 1: файл console.log("Один") помещается в стек:

  • Стек - [main(), console.log('Один')]
  • Действие - Выполняется - ”Один", выскакивает из стека

Шаг 2: выводится файл console.log("Два").:

  • Стек - [main(), console.log(”Два")]
  • Действие - Выполняется "Два", выскакивает

Шаг 3: Вызывается функция logThreeAndFour():

  • Стек - [main(), logThreeAndFour()]
  • Действие - внутри функции logThreeAndFour() вызывается функция logThree() Stack - [main(), logThreeAndFour(), logThree()] Действие - logThree() вызывает console.log('Три') Стек - [main(), logThreeAndFour(), logThree(), console.log('Три')] Действие - Выполняется 'Three', выводится из стека [main(), logThreeAndFour(), logThree()] - Выводится ’logThree()

Шаг 4: выводится файл console.log("Четыре").

  • Стек - [main(), logThreeAndFour(), console.log(”Четыре")]
  • Действие - Выполняет "Четыре", выскакивает
  • Стек -[main(), logThreeAndFour()] - открывается logThreeAndFour()

Наконец, стек пуст, и программа завершает работу:

call stack’s LIFO structure ensures nested functions resolve before outer ones

Однопоточная дилемма

Поскольку JavaScript имеет только один стек вызовов, блокирующие операции (например, циклы с высокой нагрузкой на процессор) блокируют работу всего приложения:

function longRunningTask() {
  // Simulate a 3-second delay
  const start = Date.now();
  while (Date.now() - start < 3000) {} // Blocks the stack
  console.log('Task done!');
}

longRunningTask(); // Freezes the UI for 3 seconds
console.log('This waits...'); // Executes after the loop

Именно из-за этого ограничения JavaScript полагается на асинхронные операции (например, setTimeout, fetch), обрабатываемые API-интерфейсами браузера вне стека вызовов.

Веб-API и очередь задач: расширение возможностей JavaScript

В то время как стек вызовов управляет синхронным выполнением, истинная мощь JavaScript заключается в его способности обрабатывать асинхронные операции, не блокируя основной поток. Это стало возможным благодаря веб-интерфейсам API и очереди задач, которые работают в тандеме с циклом обработки событий для разгрузки и планирования неблокирующих задач.

Роль веб-API

Веб-API - это интерфейсы, предоставляемые браузером, которые обрабатывают задачи за пределами основной среды выполнения JavaScript. Они включают:

  • Таймеры - setTimeout, setInterval
  • Сетевые запросы - выборка, XML-HttpRequest
  • Манипуляции с DOM - добавляйте EventListener, нажимайте, прокручивайте
  • API-интерфейсы устройств - Геолокация, камера, Уведомления

Эти API-интерфейсы позволяют JavaScript делегировать трудоемкие операции многопоточной среде браузера, освобождая стек вызовов для обработки других задач.

Как веб-API и очередь задач работают вместе

Давайте разберем пример setTimeout:

console.log('Start');

setTimeout(() => {  
  console.log('Timeout callback');  
}, 1000);  

console.log('End');  

Вот последовательность выполнения приведенного выше фрагмента:

  1. Стек вызовов: console.log('Start') выполняется и завершает работу, функция setTimeout() регистрирует обратный вызов с помощью API таймера браузера и завершает работу, функция console.log('End') выполняется и завершает работу
  2. Фоновый режим работы браузера: API-интерфейс таймера рассчитан на 1000 мс
  3. Очередь задач: По истечении таймера в очередь задач добавляется функция обратного вызова () => { console.log(...) }
  4. Цикл обработки событий: Как только стек вызовов пуст, цикл обработки событий перемещает обратный вызов из очереди задач в консоль стека .Выполняется log("Обратный вызов по таймауту"): Начать Конец Обратный вызов по тайм-ауту

Обратите внимание, что задержки по таймеру являются минимальными гарантиями, то есть обратный вызов setTimeout(обратный вызов, 1000) может выполняться через 1000 мс, но никогда раньше. Если стек вызовов занят (например, из-за длительного цикла), обратный вызов ожидает в очереди задач.

Давайте рассмотрим другой пример использования API геолокации:

console.log('Requesting location...');

navigator.geolocation.getCurrentPosition(  
  (position) => { console.log(position); }, // Success callback  
  (error) => { console.error(error); }     // Error callback  
);

console.log('Waiting for user permission...');

В приведенном выше фрагменте getCurrentPosition регистрирует обратные вызовы с помощью API геолокации браузера. Затем браузер обрабатывает запросы на получение разрешений и выборку данных GPS. Как только пользователь отвечает, соответствующий обратный вызов добавляется в очередь задач. Это позволяет циклу обработки событий передавать его в стек вызовов во время простоя:

Requesting location...  
Waiting for user permission...  
{ coords: ... } // After user grants permission  

Без веб-API и очереди задач стек вызовов зависал бы во время сетевых запросов, таймеров или взаимодействий с пользователем.

Очередь микрозадач и цикл обработки событий: определение приоритетов обещаний

В то время как очередь задач обрабатывает API на основе обратного вызова, такие как [setTimeout], современные асинхронные функции JavaScript (promises, async/await) основаны на очереди микрозадач. Понимание того, как цикл обработки событий расставляет приоритеты в этой очереди, является ключом к пониманию порядка выполнения JavaScript.

Что такое очередь микрозадач?

Очередь микрозадач - это выделенная очередь для:

  • Обещанные реакции - обработчики .then(), .catch(), .finally()
  • queueMicrotask() - Явно добавляет функцию в очередь микрозадач
  • асинхронный/ожидающий Вызов функции после ожидания ставится в очередь как микрозадача
  • Обратные вызовы MutationObserver - Используются для отслеживания изменений DOM

В отличие от очереди задач, очередь микрозадач имеет более высокий приоритет. Цикл обработки событий обрабатывает все микрозадачи перед переходом к задачам.

Цикл обработки событий следует строгой последовательности рабочего процесса. Он выполняет все задачи в стеке вызовов, полностью освобождает очередь микрозадач, обновляет пользовательский интерфейс (если таковые имеются), а затем обрабатывает одну задачу из очереди задач, прежде чем повторить весь процесс снова, непрерывно. Это гарантирует, что код, основанный на обещаниях, будет запущен как можно скорее, даже если задачи запланированы ранее.

Давайте рассмотрим пример микрозадач в сравнении с очередями задач:

console.log('Start');

// Task (setTimeout)
setTimeout(() => console.log('Timeout'), 0);

// Microtask (Promise)
Promise.resolve().then(() => console.log('Promise'));

console.log('End');

Это приводит к следующему результату:

Start  
End  
Promise  
Timeout  

Event loop prioritizes the microtask queue entirely before touching the task queue

Выполнение разбивки выполняется в следующей последовательности:

  1. console.log("Пуск") выполняет
  2. setTimeout планирует свой обратный вызов в очереди задач
  3. Promise.resolve().then() планирует обратный вызов в очереди микрозадач
  4. console.log('Конец') выполняет
  5. Цикл обработки событий: Стек вызовов пуст - обработка микрозадач Журналы обещаний Очередь микрозадач пуста - обработка следующей задачи Журналы тайм-аутов

Распространенное предостережение, связанное с микрозадачами

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

function recursiveMicrotask() {
  Promise.resolve().then(() => {
    console.log('Microtask!');
    recursiveMicrotask(); // Infinite loop
  });
}

recursiveMicrotask();

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

асинхронность/ожидание и микрозадачи

синтаксис async/await - это синтаксический сахар для promises. Код после await преобразуется в микрозадачу:

async function fetchData() {
  console.log('Fetching...');
  const response = await fetch('/data'); // Pauses here
  console.log('Data received'); // Queued as microtask
}

fetchData();
console.log('Script continues');

Результат выглядит следующим образом:

Fetching...  
Script continues  
Data received 

Веб-работники: Разгрузка от тяжелых задач

Однопоточная модель JavaScript обеспечивает простоту, но с трудом справляется с задачами, требующими больших затрат ресурсов процессора, такими как обработка изображений и вычисления сложных или больших наборов данных. Эти задачи могут привести к зависанию пользовательского интерфейса, что приведет к ухудшению взаимодействия с пользователем. Веб-работники решают эту проблему, выполняя скрипты в отдельных фоновых потоках, освобождая основной поток для обработки DOM и взаимодействия с пользователем.

Как работают веб-работники

Рабочие процессы выполняются в изолированной среде с собственным пространством памяти. Они не могут получить доступ к объектам DOM или window, что обеспечивает потокобезопасность. Связь между основным потоком и рабочими процессами осуществляется посредством передачи сообщений, при которой данные копируются (посредством структурированного клонирования) или передаются (с использованием переносимых объектов), чтобы избежать конфликтов в общей памяти.

В приведенном ниже блоке кода показан пример делегирования сложной работы по обработке изображения, выполнение которой в этом сценарии, как предполагается, требует много вычислительного времени. метод worker.postMessage отправляет сообщение работнику. Затем он использует worker.onmessage и worker.onerror для обработки результатов и ошибок фоновой работы.

Вот основная тема:

// Create a worker and send data
const worker = new Worker('worker.js');
worker.postMessage({ task: 'processImage', imageData: rawPixels }); 

// Listen for results or errors
worker.onmessage = (event) => {
  displayProcessedImage(event.data); // Handle result
};

worker.onerror = (error) => {
  console.error('Worker error:', error); // Handle failures
};

В приведенном ниже фрагменте кода мы используем метод onmessage для получения уведомления о начале обработки изображения. Переданный rawPixel может быть доступен в объекте event через поле данных, как показано ниже.

И теперь мы видим его от worker.js точка зрения:

// Receive and process data
self.onmessage = (event) => {
  const processedData = heavyComputation(event.data.imageData); 
  self.postMessage(processedData); // Return result
};

Рабочие объекты работают в отдельной глобальной области, отсюда и использование self. Используйте переносимые объекты (например, ArrayBuffer) для больших объемов данных, чтобы избежать дорогостоящего копирования, а создание слишком большого количества рабочих объектов может привести к увеличению объема памяти; повторно используйте их для повторяющихся задач.

Вывод

Асинхронное преимущество JavaScript заключается в его элегантной организации стека вызовов, веб-API и цикла обработки событий - системы, которая обеспечивает неблокирующее выполнение, несмотря на ее однопоточный характер. Используя очередь задач для операций на основе обратного вызова и устанавливая приоритет очереди микрозадач для promises, JavaScript обеспечивает эффективную обработку асинхронных рабочих процессов.

Освоив эти концепции, вы сможете писать код, который будет не просто функциональным, но и предсказуемым и производительным, независимо от того, обрабатываете ли вы взаимодействие с пользователем, извлекаете данные или оптимизируете рендеринг.

Экспериментируйте с DevTools, применяйте асинхронные шаблоны и позвольте циклу обработки событий JavaScript работать на вас, а не против вас.