Как работает асинхронность в Node.js?

Ответ

Node.js использует однопоточный цикл событий (event loop) на базе libuv для неблокирующей обработки операций ввода-вывода (I/O). Когда встречается асинхронная операция (например, запрос к БД или чтение файла), она передается в системный пул потоков libuv, а основной поток Node.js продолжает выполнять синхронный код. По завершении асинхронной операции её колбэк помещается в соответствующую очередь (например, очередь микрозадач для Promise или очередь макрозадач для таймеров и I/O), и цикл событий выполняет его на следующей итерации, когда стек вызовов пуст.

Ключевые компоненты:

  • Event Loop: Однопоточный цикл, который координирует выполнение колбэков.
  • Libuv: Библиотека на C++, предоставляющая пул потоков для выполнения блокирующих системных операций.
  • Очереди: Разные приоритетные очереди для микрозадач (nextTick, Promises) и макрозадач (setTimeout, I/O).

Пример с async/await (современный подход):

const fs = require('fs').promises;

async function readFileAsync() {
  console.log('Начинаем чтение...');
  try {
    const data = await fs.readFile('example.txt', 'utf8');
    console.log('Содержимое файла:', data);
  } catch (err) {
    console.error('Ошибка чтения:', err);
  }
  console.log('Этот лог выполнится после await');
}

readFileAsync();
console.log('Этот лог выполнится ДО завершения чтения файла');

Почему так важно: Эта модель позволяет Node.js эффективно обрабатывать тысячи одновременных соединений с минимальными накладными расходами на потоки, что идеально подходит для I/O-интенсивных приложений, таких как веб-серверы или API.

Ответ 18+ 🔞

Давай разберём эту тему, а то у некоторых в голове после объяснений про event loop — полный пиздец и бардак.

Представь себе, что Node.js — это одинокий, но очень хитрожопый официант в забитом до отказа ресторане. Это и есть наш главный поток, он один, и он не может клонироваться. Его задача — не готовить еду (это системные операции, типа чтения файла или запроса в базу), а разносить заказы и принимать новые. Вот этот его бесконечный забег между кухней и столиками — это и есть цикл событий (event loop).

Когда ты просишь что-то тяжёлое и асинхронное (например, «принеси мне спагетти карбонара»), наш официант не стоит у плиты, блядь, как идиот. Он отдаёт заказ на кухню (libuv), где работают повара (это пул потоков), а сам бежит принимать заказы от других столиков. Как только кухня приготовила, она ставит готовое блюдо на пассивный столик (это очередь), а официант, как только у него руки свободны, забегает и относит его тебе. Это и есть неблокирующий I/O, ёпта.

Теперь про очереди, а то тут многие путаются, как манда с ушами. Есть срочные заказы, типа «принеси воды, я задыхаюсь» — это микрозадачи (process.nextTick(), Promise). Их официант выполняет почти мгновенно, прямо после текущего столика. А есть обычные заказы: «принеси счёт», «убери тарелки» — это макрозадачи (setTimeout, setInterval, I/O операции). Они ждут своей очереди в общем порядке.

Смотри на этом примере, тут всё видно:

const fs = require('fs').promises;

async function readFileAsync() {
  console.log('Начинаем чтение...');
  try {
    const data = await fs.readFile('example.txt', 'utf8');
    console.log('Содержимое файла:', data);
  } catch (err) {
    console.error('Ошибка чтения:', err);
  }
  console.log('Этот лог выполнится после await');
}

readFileAsync();
console.log('Этот лог выполнится ДО завершения чтения файла');

Что здесь происходит, блядь?

  1. Вызывается readFileAsync(). Видим лог "Начинаем чтение...".
  2. Доходим до await fs.readFile(...). Это наш заказ на кухню! Основной поток (официант) не ждёт, он сразу идёт дальше.
  3. Выполняется последняя строка — лог "Этот лог выполнится ДО...". Вот тебе и доказательство, что не блокируется.
  4. Тем временем на кухне (в пуле потоков libuv) идёт чтение файла. Как только файл прочитан, колбэк (а await — это просто красивый синтаксис для колбэков) попадает в очередь микрозадач.
  5. Как только стек вызовов пуст (официант разнёс все текущие заказы), он хватает из очереди нашу задачу: возвращается к await, подставляет данные в переменную data и выполняет оставшийся код внутри функции.

И вся эта, блядь, архитектура — не просто для красоты. Почему это овердохуища важно? Потому что создавать отдельный поток под каждого клиента — это как нанимать по официанту на каждый столик: дорого, и они будут друг другу мешаться. А наш один хитрый официант успевает обслуживать тысячи соединений, потому что не тратит время на ожидание. Идеально для веб-серверов, API и прочих I/O-интенсивных приложений, где основное время — это ожидание ответа от базы, диска или сети.

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