Что означает асинхронный в контексте JavaScript? Чтобы понять этот термин, давайте взглянем на следующий код:

fs.readFile('poem.txt', (err, data) => { 
  if (err) throw err; 
  console.log(data); 
});

Вызывается единственная функция fs.readFile, которая принимает на вход два аргумента: строку с именем файла и другую функцию, часто называемую callback.

Асинхронность означает, что программа продолжит работу сразу после вызова fs.readFile, не дожидаясь завершения или даже запуска обратного вызова. Функция, переданная в качестве аргумента (обратный вызов), будет запущена (обратно вызвана) в какой-то момент в будущем. Кроме того, обратный вызов получает закрытие, т. е. переменные вне функции будут доступны во время ее выполнения — возможность быстро идентифицировать эти переменные кажется естественной при написании JavaScript, но это не так очевидно, если учесть, как это реализовано внутри. (подробнее о закрытии можно прочитать здесь, здесь, здесь и здесь). За кулисами fs.readFile вызывает потенциально блокирующий системный вызов.

Это возможно, потому что в JavaScript есть модель параллелизма, известная как «цикл событий». Давайте рассмотрим некоторые основные понятия. Фрейм — это имя функции, ее аргументы и локальные переменные. Стек — это реестр, состоящий из фреймов вызванных и еще не завершенных функций. Куча — это неструктурированная область памяти, в которой размещаются объекты. Очередь сообщений — это список сообщений, которые необходимо обработать.

Google V8 или Microsoft’s Chakra — примеры сред выполнения JavaScript. Среда выполнения JavaScript содержит стек, кучу и очередь сообщений. Каждое сообщение в очереди соответствует функции. Сообщение извлекается из очереди и обрабатывается, что означает, что его функция вызывается, когда в стеке достаточно места (начальный кадр стека). Обработка сообщения завершается, когда стек снова становится пустым. Каждое сообщение обрабатывается полностью до обработки любого другого сообщения. Сообщения добавляются в очередь каждый раз, когда происходит событие, и к этому событию подключен прослушиватель событий. Если прослушиватель отсутствует, сообщение не добавляется в очередь и событие теряется. Вызов setTimeout добавит сообщение в очередь по истечении заданного времени. Если в очереди нет других сообщений, сообщение обрабатывается сразу; однако, если есть сообщения, сообщение setTimeout должно будет ждать обработки других сообщений. По этой причине второй аргумент указывает минимальное время, а не гарантированное время.

Вот как работает цикл обработки событий, написанный в виде псевдокода.

while (queue.waitForMessage()) { 
  queue.processNextMessage(); 
}

queue.waitForMessage синхронно ожидает поступления сообщения, если в данный момент его нет. Когда есть сообщение, оно обрабатывается; и вся операция повторяется.

Почему это сделано именно так? JavaScript начинался как своего рода «быстрый и грязный» язык сценариев для веб-браузеров в Netscape. Создать полностью отдельную, хорошо продуманную модель параллелизма было невозможно. JavaScript стал однопоточным с обратными вызовами, которые выполняются в том же цикле обработки событий, что и сам графический интерфейс браузера, т. е. они используют одну и ту же очередь сообщений. Node.js естественным образом унаследовал этот механизм параллелизма.

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

В Node.js, в то время как пользовательское приложение является однопоточным, ввод-вывод (сеть, файловая система) является асинхронным (т. е. он никогда не блокируется) и может планироваться и выполняться в пуле потоков. Я говорю может здесь, потому что это деталь реализации среды выполнения Node.js, которая зависит от операционной системы — она отличается для Windows, Unix или *BSD, например. в Linux сетевой ввод-вывод использует epoll, в то время как файловый ввод-вывод использует пул потоков. Node.js использует библиотеку libuv для абстрагирования этих асинхронных операций с помощью средств операционной системы (которые различаются в каждой ОС).

Веб-воркер или кросс-происхождение iframe имеет собственный стек, кучу и очередь сообщений. Отдельные среды выполнения могут обмениваться данными, только отправляя сообщения с использованием метода postMessage: он добавляет сообщение в другую среду выполнения, если в этой среде выполнения настроены прослушиватели для соответствующих событий. Следует отметить, что веб-воркеры — это спецификация браузера; однако существуют некоторые реализации, связанные с Node.js.

Первоначально опубликовано на zaiste.net.