Итак, мы знаем, что Javascript по своей природе является однопоточным. Но если это так, то как он поддерживает асинхронное выполнение? Давайте углубимся в это!
Путаница
Таким образом, Javascript является однопоточным, что интуитивно означает, что он имеет только основной поток. Таким образом, все операции выполняются последовательно.
Но мы часто видим, как крупные предприятия используют Javascript для запуска своего бэкэнда на NodeJS или других движках, которым требуется, чтобы их сервисы были асинхронными по своей природе.
Если сам язык однопоточный, как он может поддерживать асинхронность?
Tl/DR;
Не язык обрабатывает асинхронную функциональность, а среда, в которой он работает, делает это за нас. Язык просто предоставляет синтаксис.
Ключевые правила
Итак, прежде чем мы углубимся в эту тему, давайте установим несколько истин.
- Javascript является однопоточным
- Javascript предоставляет синтаксис для выполнения асинхронных функций, но не обрабатывает само выполнение. (Вроде как предоставить интерфейс, но не реализацию)
Давайте прыгать прямо в!
Для людей, которые работают с Javascript, такие ключевые слова, как async/await, promise, callback, знакомы. Поэтому, прежде чем мы расшифруем, что происходит под капотом, мы можем, по крайней мере, установить, что JS действительно предоставляет синтаксис для написания кода асинхронным способом. В этом посте мы не будем обсуждать, что на самом деле делают эти ключевые слова. Но мы можем с уверенностью сказать, что эти ключевые слова обеспечивают «некоторую» асинхронную функциональность.
Давайте сначала посмотрим, как среда выполнения Javascript сначала выполняет синхронный код.
В JS есть такая штука, как стек вызовов. Когда JS читает код сверху вниз, он продолжает добавлять все функции, которые должен выполнить, в стек вызовов.
После этого он берет функции из стека одну за другой и выполняет их. Поскольку он использует стековую структуру данных (LIFO), сохраняется порядок вызываемых функций.


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

Теперь может показаться, что эта штука Event Loop — это излишество. Я имею в виду, что из того, что я видел, все, что он делает, это просто получает первую функцию в стеке вызовов и говорит движку выполнить ее. Ничего сверхъестественного в этом нет! Это может сделать любой! Так какой смысл иметь что-то вроде цикла событий и делать вещи такими сложными!?
Теперь все усложняется для Event Loop. Представляем очередь событий!
Раньше у нашего друга Event Loop было только одно место для поиска — стек вызовов. Он просто выбирает самую верхнюю функцию, которую видит, и передает ее движку. Но теперь появляется новый актер, называемый очередью событий! Очередь событий похожа на обычную очередь со свойствами FIFO.
Допустим, наш код теперь выглядит примерно так:
async function a() {
setTimeout(() => console.log("I'm a"), 3000);
}
function b() {
console.log("I'm b")
}
function main() {
a();
b();
}
Результат этого будет выглядеть примерно так, как показано ниже
I'm b I'm a // this is printed after 3s
Это отлично. Этого мы и ожидали. Но нас интересует, как это выглядит в стеке вызовов и цикле событий. Итак, давайте посмотрим на это.

Глядя на код, к a() прикреплен обратный вызов — setTimeout . Это похоже на синтаксис, предоставляемый JS, но на самом деле это WebAPI, что означает, что он является частью браузера или вашей среды NodeJS! JS сам по себе не будет знать, как его выполнить!
Таким образом, при выполнении a() наш браузер видит, что это асинхронно, и немедленно начинает обработку, но зарегистрированный обратный вызов помещается в сообщение о событии.

Теперь все становится сложнее для цикла событий. Раньше он просто считывал функции из вашего кода и помещал их в очередь, как только выполнялась самая верхняя функция в стеке вызовов. Но теперь он также должен смотреть на очередь событий! Он устанавливает приоритет как:
- Сначала проверьте, есть ли какая-либо функция в стеке вызовов, если есть, отправьте ее в JS-движок.
- Если стек вызовов пуст (или содержит только основную функцию), проверьте очередь событий.

Как видно из приведенной выше диаграммы, поток 1 все еще обрабатывается, но это не блокирует вашу функцию от обработки другой функции в потоке 2. Мы достигли асинхронности!

В этом прелесть JS. Он определил синтаксис для асинхронной обработки, но сам не выполняет его. Он делегирует это. Таким образом, движок (ваш браузер, NodeJS) несет ответственность за его выполнение.
Теперь эти механизмы могут использовать какой-либо другой язык для обработки и, следовательно, могут свободно использовать несколько ядер или потоков. Сам язык JS отвечает ТОЛЬКО за поддержание цикла событий, стека вызовов и очереди событий в потоке.
Вся тяжелая работа по обработке делегирована движку, в то время как основной поток отвечает только за наблюдение за тем, какую функцию и когда выполнять! Такой дизайн делает JS чрезвычайно легким, но достаточно мощным, чтобы его можно было использовать где угодно!
Но ждать! Что, если моя синхронная функция зависит от результата асинхронной функции?
Да, это почти всегда так. Скажем, ваш код выглядит следующим образом:
async function a() {
return new Promise(resolve => {
setTimeout(() => resolve(42), 3000);
})
}
function b(number) {
console.log("I got - ", number);
}
function c() {
console.log("I'm C!");
}
async function main() {
a().then(res => b(res));
c();
}
main();
Это тоже происходит так же, как описано выше! Единственная разница в том, что теперь у нас есть 2 обратных вызова! Первый — () => resolve(42), второй — .then(res => b(res). Поскольку они должны выполняться по порядку, именно поэтому у нас есть очередь в качестве структуры данных в очереди событий! Таким образом, наша очередь событий в этом случае будет выглядеть следующим образом:

Из-за такого порядка мы видим результат следующим образом:
I'm C! I got - 42
Это пример того, как это делает Javascript. Использование функций обратного вызова. Другие реализации архитектуры Event Loop имеют собственную такую структуру.
Если взять, к примеру, Kotlin, у них есть то, что называется ключевым словом suspend. Функции приостановки — это в основном асинхронные функции в kotlin. Поэтому, когда вы компилируете его, компилятор создает древовидную структуру всех вложенных suspend функций. Таким образом, вместо того, чтобы поддерживать указатель, такой как обещание в Javascript, наличие древовидной структуры делает его еще проще, поскольку вам просто нужно пройти вверх по дереву!
Заключение
Итак, теперь мы увидели, что JS, несмотря на то, что он однопоточный, обеспечивает достаточную расширяемость для асинхронного программирования. И тот факт, что он абстрагируется от того, как обрабатывать асинхронный аспект вашего кода для выбранного вами движка, делает его еще более мощным!
Этот тип дизайна, который имеет стек вызовов, очередь событий и цикл событий, называется архитектурой цикла событий. Это довольно распространено и используется во фреймворке, не говоря уже о языке. Ktor, веб-фреймворк, написанный на Kotlin, использует эту архитектуру, хотя сам язык поддерживает асинхронность через сопрограммы!
Зачем им это делать? Ака. Каковы преимущества использования архитектуры цикла обработки событий помимо очевидной возможности обработки асинхронных функций?
- Эффективное управление потоками. Поскольку в основном есть только один поток, который упорядочивает асинхронные задачи, нет проблем с конкуренцией потоков или чем-то еще, поскольку часть обработки была абстрагирована.
- Масштабируемость. У любого разработчика, когда он/она слышит слово «очередь», на ум приходит одно очевидное преимущество — это преимущество масштабируемости, которое оно дает!
- Кроссплатформенная совместимость. Поскольку часть обработки была абстрагирована, такая структура позволяет очень легко интегрироваться с любым движком!
- Предсказуемая производительность. Поскольку существует один поток, управляющий асинхронной частью кода, шансов на гонку условий или проблем, связанных с параллелизмом, которые входят в состав пакета с многопоточной средой, практически нет.
Но главное мое преимущество в том, что это так легко понять! Многопоточность, хотя звучит круто, чрезвычайно сложно правильно реализовать и еще труднее понять! Следовательно, мы видим, что многие фреймворки отходят от него и сосредотачиваются на многоядерном подходе. И, как мы видели в архитектуре цикла обработки событий, ее довольно легко реализовать, поскольку она абстрагируется от части обработки!
Но опять же это мое мнение. Дайте мне знать ваши! Надеюсь, вам понравился этот очень упрощенный обзор того, как однопоточный Javascript делает асинхронные вещи и как работает архитектура цикла событий. Конечно, в этом есть довольно много нюансов. Но пока, для понимания, этого должно хватить!