Разбираемся с асинхронностью в JavaScript [Перевод статьи Sukhjinder Arora]

https://blog.bitsrc.io/understanding-asynchronous-javascript-the-event-loop-74cd408419ff
  • Перевод
Привет, Хабр! Представляю вашему вниманию перевод статьи «Understanding Asynchronous JavaScript» автора Sukhjinder Arora.



От автора перевода: Надеюсь перевод данной статьи поможет вам ознакомиться с чем-то новым и полезным. Если статья вам помогла, то не поленитесь и поблагодарите автора оригинала. Я не претендую на звание профессионального переводчика, я только начинаю переводить статьи и буду рад любым содержательным фидбекам.

JavaScript — это однопоточный язык программирования, в котором может быть выполнено только что-то одно за раз. То есть, в одном потоке движок JavaScript может обработать только 1 оператор за раз.

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

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

Здесь то и вступает в игру асинхронность JavaScript. Используя асинхронность JavaScript(функции обратного вызова(callback’и), “промисы” и async/await) вы можете выполнять долгие сетевые запросы без блокирования основного потока.

Несмотря на то, что не обязательно изучать все эти концепции, чтобы быть хорошим JavaScript-разработчиком, полезно их знать.

Итак, без лишних слов, давайте начинать.

Как работает синхронный JavaScript?


Прежде чем мы углубимся в работу асинхронного JavaScript, давайте для начала разберемся как выполняется синхронный код внутри движка JavaScript. Например:

const second = () => {
  console.log('Hello there!');
}
const first = () => {
  console.log('Hi there!');
  second();
  console.log('The End');
}
first();

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

Контекст выполнения


Контекст выполнения — это абстрактное понятие окружения, в котором код оценивается и выполняется. Всякий раз, когда какой-либо код выполняется в JavaScript он запускается в контексте выполнения.

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

Стек вызовов


Под стеком вызовов подразумевается стек со структурой LIFO(Last in, First Out/Последний вошел, первый вышел), который используется для хранения всех контекстов выполнения, созданных на протяжении исполнения кода.

В JavaScript имеется только один стек вызовов, так как это однопоточный язык программирования. Структура LIFO означает, что элементы могут добавляться и удаляться только с вершины стека.

Давайте теперь вернемся к фрагменту кода выше и попробуем понять, как движок JavaScript его выполняет.

const second = () => {
  console.log('Hello there!');
}
const first = () => {
  console.log('Hi there!');
  second();
  console.log('The End');
}
first();



И так, что же здесь произошло?


Когда код начал выполняться, был создан глобальный контекст выполнения(представленный как main()) и добавлен на вершину стека вызовов. Когда встречается вызов функции first(), он так же добавляется на вершину стека.

Далее, на вершину стека вызовов помещается console.log('Hi there!'), после выполнения он удаляется из стека. После этого мы вызываем функцию second(), поэтому она помещается на вершину стека.

console.log('Hello there!') добавлен на вершину стека и удаляется из него по завершению выполнения. Функция second() завершена, она также удаляется из стека.

console.log('The End') добавлен на вершину стека и удален по завершению. После этого функция first() завершается и также удаляется из стека.

Выполнение программы заканчивается, поэтому глобальный контекст вызова(main()) удаляется из стека.

Как работает асинхронный JavaScript?


Теперь, когда мы имеем общее представление о стеке вызовов и о том, как работает синхронный JavaScript, давайте вернемся к асинхронному JavaScript.

Что такое блокирование?


Давайте предположим, что мы выполняем обработку изображения или сетевой запрос синхронно. Например:

const processImage = (image) => {
  /**
  * Выполняем обработку изображения
  **/
  console.log('Image processed');
}
const networkRequest = (url) => {
  /**
  * Обращаемся к некоторому сетевому ресурсу
  **/
  return someData;
}
const greeting = () => {
  console.log('Hello World');
}
processImage(logo.jpg);
networkRequest('www.somerandomurl.com');
greeting();

Обработка изображения и сетевой запрос требует времени. Когда функция processImage() вызвана её выполнение потребует некоторого времени, в зависимости от размера изображения.

Когда функция processImage() выполнена она удаляется из стека. После нее вызывается и добавляется в стек функция networkRequest(). Это снова займет некоторое время прежде чем завершить выполнение.

В конце концов, когда функция networkRequest() выполнена, вызывается функция greeting(), поскольку она содержит только метод console.log, а этот метод, как правило, выполняется быстро, функция greeting() выполнится и завершится мгновенно.

Как вы видите, нам нужно ждать пока функция(такие как processImage() или networkRequest()) завершится. Это означает, что такие функции блокируют стек вызовов или основной поток. По итогу мы не можем выполнить другие операции, пока код выше не будет выполнен.

Так какое же решение?


Самое простое решение — это асинхронные функции обратного вызова. Мы используем их, чтобы сделать наш код неблокируемым. Например:

const networkRequest = () => {
  setTimeout(() => {
    console.log('Async Code');
  }, 2000);
};
console.log('Hello World');
networkRequest();

Здесь я использовал метод setTimeout для того чтобы имитировать сетевой запрос. Пожалуйста, помните, что setTimeout не является частью движка JavaScript, это часть так называемого web API(в браузере) и C/C++ APIs (в node.js).

Для того чтобы понять, как этот код выполняется, мы должны разобраться с ещё несколькими понятиями, такими как цикл обработки событий и очередь обратных вызовов(также известная как очередь задач или очередь сообщений).



Цикл обработки событий, web API и очередь сообщений/очередь задач не являются частью движка JavaScript, это часть браузерной среды выполнения JavaScript или среды выполнения JavaScript в Nodejs(в случае Nodejs). В Nodejs, web APIs заменяется на C/C++ APIs.

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

const networkRequest = () => {
  setTimeout(() => {
    console.log('Async Code');
  }, 2000);
};
console.log('Hello World');
networkRequest();
console.log('The End');



Когда код приведенный выше загружается в браузер console.log('Hello World') добавляется в стек и удаляется из него по завершению выполнения. Далее встречается вызов функции networkRequest(), он добавляется на вершину стека.

Следующая вызывается функция setTimeout() и помещается на вершину стека. Функция setTimeout() имеет 2 аргумента: 1) функция обратного вызова и 2) время в миллисекундах.

setTimeout() запускает таймер на 2 секунды в окружении web API. На этом этапе, setTimeout() завершается и удаляется из стека. После этого, в стек добавляется console.log('The End'), выполняется и удаляется из него по завершению.

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

Цикл обработки событий


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

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

После console.log('Async Code') добавляется на вершину стека, выполняется и удаляется из него. На этом моменте обратный вызов выполнен и удален из стека, а программа полностью завершена.

События DOM


Очередь сообщений также содержит обратные вызовы от событий DOM, такие как клики и “клавиатурные” события. Например:

document.querySelector('.btn').addEventListener('click',(event) => {
  console.log('Button Clicked');
});

В случае с событиями DOM, обработчик событий находится в окружении web API, ожидая определенного события(в данном случае клик), и когда это событие происходит функция обратного вызова помещается в очередь сообщений, ожидая своего выполнения.

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

ES6 Очередь микротасков


Прим. автора перевода: В статье автор использовал message/task queue и job/micro-taks queue, но если перевести task queue и job queue, то по идее это получается одно и то же. Я поговорил с автором перевода и решил просто опустить понятие job queue. Если у вас есть какие-то свои мысли на этот счет, то жду вас в комментариях

Ссылка на перевод статьи по промисам от этого же автора


ES6 представил понятие очередь микротасков, которые используются “промисами” в JavaScript. Разница между очередью сообщений и очередью микротасков состоит в том, что очередь микротасков имеет более высокий приоритет по сравнению с очередью сообщений, это означает, что “промисы” внутри очереди микротасков будут выполняться раньше, чем обратные вызовы в очереди сообщений.

Например:

console.log('Script start');
setTimeout(() => {
  console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
    resolve('Promise resolved');
  }).then(res => console.log(res))
    .catch(err => console.log(err));
console.log('Script End');

Вывод:

Script start
Script End
Promise resolved
setTimeout

Как вы можете видеть, “промис” выполнился раньше setTimeout, все это из-за того, что ответ “промиса” хранится внутри очереди микростасков, которая имеет более высокий приоритет, нежели очередь сообщений.

Давайте разберем следующий пример, на этот раз 2 “промиса” и 2 setTimeout:

console.log('Script start');
setTimeout(() => {
  console.log('setTimeout 1');
}, 0);
setTimeout(() => {
  console.log('setTimeout 2');
}, 0);
new Promise((resolve, reject) => {
    resolve('Promise 1 resolved');
  }).then(res => console.log(res))
    .catch(err => console.log(err));
new Promise((resolve, reject) => {
    resolve('Promise 2 resolved');
  }).then(res => console.log(res))
    .catch(err => console.log(err));
console.log('Script End');

Вывод:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
setTimeout 1
setTimeout 2

И снова оба наших “промиса” выполнились раньше обратных вызовов внутри setTimeout, так как цикл обработки событий считает задачи из очереди микротасков важнее задач из очереди сообщений/очереди задач.

Если во время выполнения задач из очереди микротасков появляется ещё один “промис”, то он будет добавлен в конец этой очереди и выполнен раньше обратных вызовов из очереди сообщений, и не важно сколько времени они ожидают своего выполнения.

Например:

console.log('Script start');
setTimeout(() => {
  console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
    resolve('Promise 1 resolved');
  }).then(res => console.log(res));
new Promise((resolve, reject) => {
  resolve('Promise 2 resolved');
  }).then(res => {
       console.log(res);
       return new Promise((resolve, reject) => {
         resolve('Promise 3 resolved');
       })
     }).then(res => console.log(res));
console.log('Script End');

Вывод:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
Promise 3 resolved
setTimeout

Таким образом, все задачи из очереди микротасков будут выполнены раньше задач из очереди сообщений. То есть цикл обработки событий сначала очистит очередь микротасков, а только после этого начнет выполнение обратных вызовов из очереди сообщений.

Заключение


Итак, мы изучили, как работает асинхронный JavaScript и понятия: стека вызовов, цикла обработки событий, очереди сообщений/очереди задач и очереди микротасков, которые составляют среду выполнения JavaScript
Поделиться публикацией

Комментарии 11

    –1
    Что именно означает однопоточный, если я верно помню чтобы запустить второй поток в других яп и работать с ним все равно нужно применять костыли в виде блокировок.
    В js тоже можно запустить другие потоки прямо из контекста этого, значит что не такой уж он однопоточный выходит, и по сути разница только в том насколько сложно работать с другими потоками придется программисту.
      0
      Нет, не обязательно блокировки использовать. Можно работать с разными наборами данных. Есть атомарные операции.
        0
        То есть бывает такое что переменная непосредственно доступна в разных потоках? это вроде как на уровне операционной системы нереально и придумывают, что то типа редиса тока внутри программы.
          0
          Что такое переменная? Это указатель на память, будь то стек или куча. Вся память процесса доступна каждому потоку, ОС создаёт виртуальную память в рамках процесса. Очень опасно пользоваться стековыми переменными (типы по значению) в других потоках, но и такое бывает.
        +2
        В JavaScript нет потоков (если не рассматривать последний Node.js). Весь код, хоть он и асинхронный, обрабатывается в одном потоке (см. Event loop). То есть в один момент времени выполняется только одна инструкция, хотя может казаться что они выполняются параллельно. В других ЯП, в которых реализована нормальная многопоточность, в один момент времени могут выполняться сразу несколько инструкций (в разных потоках). И как раз для организации доступа к разделяемой памяти между потоками и применяются блокировки.

        Например, если нужно сделать некоторые математические расчеты в несколько потоков, то на JavaScript они будут выполняться, в действительности, последовательно, а на Java, например, будут выполняться параллельно (если правильно реализовать).
          +3
          Стоит упомянуть, что всё-таки есть способ запустить код в настоящем отдельном потоке в браузере — Web Workers. Да, с ограничениями и сложностями с обменом данными, но тем не менее часть тяжелых вычислений можно туда перенести, разгрузив код отрисовки интерфейса, уменьшив лаги.
            0

            Это верно. Но для тех, кто работал с обычними небезопасными потоками яваскриптовые воркеры выглядят точно так же, как запуск дочернего процесса и далее беседа с ним через сокет / пайп.


            Взять и пошерить память нельзя до сих пор даже в wasm, хотя казалось бы, почему нет. Видимо, спека даёт какие-то атомарные гарантии, как ява :(

        0
        Опечатки:
        • И так, → Итак,
        • Когда встречается вызов функции first() он так же… → Когда встречается вызов функции first(), он также...
        • понятие окружения в котором код→ понятие окружения, в котором код
        • Таким образом все задачи из очереди → Таким образом, все задачи из очереди
        • И так, мы изучил как работает асинхронный JavaScript и другие понятия, такие как стек вызовов, цикл обработки событий, очередь сообщений/очередь задач и очередь микротасков, которые вместе представляют собой среду выполнения JavaScript. → Итак, мы изучили, как работает асинхронный JavaScript и понятия: стека вызовов, цикла обработки событий, очереди сообщений/очереди задач и очереди микротасков, которые составляют среду выполнения JavaScript.
        • очередь микростасков → очередь микротасков
        • Как вы можете видеть “промис” выполнился раньше → Как вы можете видеть, “промис” выполнился раньше
        • считает задачи из очереди микротасков важнее, чем задачи из очереди сообщений/очереди задач → считает задачи из очереди микротасков важнее задач из очереди сообщений/очереди задач ИЛИ считает задачи из очереди микротасков более важными, чем задачи из очереди сообщений/очереди задач
          0
          Огромное вам спасибо!
          0
          Спасибо за перевод понятной и ёмкой статьи!
            0
            Рад, что статья помогает людям :)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое