⚡ JavaScript

Многопоточность и асинхронность в JavaScript (Node.js): подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представьте, что вы — шеф-повар на кухне ресторана. Вам нужно приготовить три блюда: суп, салат и десерт. У вас есть только одна плита (это и есть наш процессор). Как вы будете готовить?

Последовательный подход (синхронный код): Вы ставите суп вариться и стоите рядом, пока он не закипит. Потом начинаете резать овощи для салата, не отвлекаясь. Затем печёте десерт. Всё делается строго по очереди. Если суп варится 30 минут, вы 30 минут просто ждёте, ничего не делая. Это очень неэффективно!

Многопоточность (как в C++ или Java): Вы нанимаете двух помощников-поваров. Теперь у вас три человека на кухне. Один варит суп, второй режет салат, третий занимается десертом. Все работают одновременно. Но! Вам нужно следить, чтобы никто не мешал друг другу (не брал одну и ту же кастрюлю, не использовал один нож). Это сложно в управлении.

Асинхронность (как в JavaScript): Вы остаётесь один, но меняете подход. Вы ставите суп на плиту, включаете таймер и, пока суп варится, идёте резать салат. Таймер «пиликает» — вы возвращаетесь, выключаете суп, ставите его остывать и продолжаете салат. Потом ставите десерт в духовку и, пока он печётся, нарезаете салат. Вы не ждёте — вы переключаетесь между задачами, когда одна из них требует ожидания (варка, запекание).

В JavaScript (Node.js) используется именно этот, третий подход. Он называется асинхронным программированием с использованием цикла событий (Event Loop). Это позволяет одному человеку (одному потоку) делать очень много дел, не простаивая в ожидании.

Важно: В Node.js нет настоящей многопоточности в классическом понимании (как в Python или Java). Есть один главный поток, который управляет всем. Но асинхронность создаёт иллюзию многозадачности, и этого достаточно для 99% веб-приложений.

Теория: как это работает изнутри

Чтобы понять асинхронность, нужно разобраться с четырьмя ключевыми понятиями: Call Stack, Web APIs (или libuv в Node.js), Callback Queue и Event Loop.

1. Call Stack (Стек вызовов)

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

2. Web APIs / libuv

Когда вы вызываете асинхронную функцию (например, setTimeout, fetch, чтение файла), она не выполняется в JavaScript. Она передаётся в специальную среду браузера (или в библиотеку libuv в Node.js). Эта среда занимается таймерами, сетевыми запросами, чтением диска. JavaScript не ждёт — он идёт дальше.

3. Callback Queue (Очередь колбэков)

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

4. Event Loop (Цикл событий)

Это бесконечный цикл, который делает одну простую вещь: проверяет, пуст ли стек вызовов. Если стек пуст, он берёт первую функцию из очереди колбэков и помещает её в стек для выполнения. И так бесконечно.

// Визуализация работы Event Loop
console.log('1. Начало');

setTimeout(() => {
  console.log('3. Таймер сработал');
}, 0); // Даже с задержкой 0 мс

console.log('2. Конец');

// Вывод в консоль:
// 1. Начало
// 2. Конец
// 3. Таймер сработал

Почему так? Потому что setTimeout передаётся в libuv, а JavaScript идёт дальше, выполняя console.log('2. Конец'). Только когда стек пуст, Event Loop берёт колбэк из очереди и выполняет его.

Синтаксис в JavaScript (Node.js): все формы и варианты

В JavaScript есть три основных способа работать с асинхронностью. Они эволюционировали от простого к сложному.

1. Callbacks (Колбэки) — старый способ

Функция, которая передаётся в другую функцию и вызывается после завершения операции. Это был первый и самый простой способ, но он приводит к «аду колбэков» (callback hell) — когда вложенность становится слишком глубокой.

const fs = require('fs');

// Читаем файл с помощью колбэка
fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Ошибка чтения:', err);
    return;
  }
  console.log('Содержимое файла:', data);
});

2. Promises (Промисы) — современный стандарт

Promise — это объект, который представляет собой результат асинхронной операции. Он может быть в трёх состояниях:

  • Pending (ожидание) — операция ещё не завершена
  • Fulfilled (выполнено) — операция успешно завершена
  • Rejected (отклонено) — произошла ошибка
// Создаём промис
const myPromise = new Promise((resolve, reject) => {
  // Асинхронная операция
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve('Успех! Данные получены');
    } else {
      reject('Ошибка! Что-то пошло не так');
    }
  }, 1000);
});

// Используем промис
myPromise
  .then(result => {
    console.log(result); // 'Успех! Данные получены'
  })
  .catch(error => {
    console.error(error); // 'Ошибка! Что-то пошло не так'
  });

3. Async/Await — сахар над промисами

Это синтаксический сахар, который делает асинхронный код похожим на синхронный. Ключевые слова async и await.

// Функция, помеченная async, всегда возвращает Promise
async function getData() {
  try {
    // await приостанавливает выполнение функции до получения результата
    const result = await myPromise;
    console.log(result);
    return result;
  } catch (error) {
    console.error('Ошибка:', error);
    throw error;
  }
}

// Вызов async функции
getData().then(data => {
  console.log('Финальные данные:', data);
});

Сравнительная таблица методов

Метод Когда использовать Плюсы Минусы
Callbacks Старые библиотеки, простые операции Простота для одного вызова Ад колбэков, сложно читать
Promises Цепочки операций, параллельные запросы Чистый код, обработка ошибок Многословность
Async/Await Сложная логика, последовательные операции Читается как синхронный код Требует понимания промисов

Примеры кода — от простого к сложному

Пример 1: Простой таймер (понимание Event Loop)

// Самый простой пример асинхронности
console.log('Старт');

// Устанавливаем таймер на 2 секунды
setTimeout(() => {
  console.log('Прошло 2 секунды');
}, 2000);

console.log('Финиш');

// Вывод:
// Старт
// Финиш
// Прошло 2 секунды

Объяснение: setTimeout не блокирует выполнение. JavaScript запоминает, что через 2 секунды нужно выполнить колбэк, и сразу идёт дальше. Когда таймер срабатывает, колбэк попадает в очередь и выполняется после того, как стек освободится.

Пример 2: Чтение файла с колбэком

const fs = require('fs');

// Асинхронное чтение файла
fs.readFile('example.txt', 'utf8', (err, data) => {
  // Этот колбэк выполнится, когда файл будет прочитан
  if (err) {
    console.error('Не удалось прочитать файл:', err.message);
    return;
  }
  console.log('Содержимое файла:', data);
});

console.log('Чтение файла запущено...');

// Вывод:
// Чтение файла запущено...
// Содержимое файла: ... (когда файл прочитается)

Объяснение: fs.readFile — асинхронная функция. Она передаёт задачу чтения файла в libuv (системный уровень) и сразу возвращает управление. Колбэк выполнится только после завершения чтения.

Пример 3: Цепочка промисов

// Симулируем получение данных пользователя
function getUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id === 1) {
        resolve({ id: 1, name: 'Иван', email: 'ivan@mail.com' });
      } else {
        reject(new Error('Пользователь не найден'));
      }
    }, 1000);
  });
}

// Симулируем получение заказов пользователя
function getOrders(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 101, product: 'Ноутбук', price: 50000 },
        { id: 102, product: 'Мышь', price: 1500 }
      ]);
    }, 500);
  });
}

// Цепочка: сначала получаем пользователя, потом его заказы
getUser(1)
  .then(user => {
    console.log('Пользователь найден:', user.name);
    // Возвращаем новый промис для продолжения цепочки
    return getOrders(user.id);
  })
  .then(orders => {
    console.log('Заказы пользователя:', orders);
    // Считаем общую сумму
    const total = orders.reduce((sum, order) => sum + order.price, 0);
    console.log('Общая сумма заказов:', total);
  })
  .catch(error => {
    console.error('Произошла ошибка:', error.message);
  });

Объяснение: Каждый .then() возвращает новый промис. Это позволяет строить цепочки асинхронных операций. Если на любом этапе происходит ошибка, управление переходит к .catch().

Пример 4: Параллельное выполнение с Promise.all

// Функция, которая возвращает промис с задержкой
function fetchData(url, delay) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Данные с ${url} получены через ${delay}мс`);
    }, delay);
  });
}

// Запускаем несколько запросов параллельно
const promises = [
  fetchData('api/users', 2000),
  fetchData('api/products', 1000),
  fetchData('api/orders', 1500)
];

// Ждём, пока ВСЕ промисы завершатся
Promise.all(promises)
  .then(results => {
    console.log('Все запросы завершены:');
    results.forEach((result, index) => {
      console.log(`${index + 1}. ${result}`);
    });
  })
  .catch(error => {
    console.error('Один из запросов упал:', error);
  });

console.log('Запросы запущены, ждём результаты...');

// Вывод:
// Запросы запущены, ждём результаты...
// (через 2 секунды)
// Все запросы завершены:
// 1. Данные с api/users получены через 2000мс
// 2. Данные с api/products получены через 1000мс
// 3. Данные с api/orders получены через 1500мс

Объяснение: Promise.all принимает массив промисов и возвращает новый промис, который выполняется, когда все промисы в массиве завершены. Это позволяет запускать независимые операции параллельно, экономя время.

Пример 5: Async/Await с обработкой ошибок

// Функция, которая может упасть
function riskyOperation() {
  return new Promise((resolve, reject) => {
    const random = Math.random();
    setTimeout(() => {
      if (random > 0.5) {
        resolve('Операция выполнена успешно');
      } else {
        reject(new Error('Операция провалилась'));
      }
    }, 1000);
  });
}

// Async функция с try/catch
async function performTask() {
  console.log('Начинаем рискованную операцию...');
  
  try {
    // Ждём результат
    const result = await riskyOperation();
    console.log('Результат:', result);
    return result;
  } catch (error) {
    console.error('Поймали ошибку:', error.message);
    // Можно вернуть значение по умолчанию
    return 'Значение по умолчанию';
  } finally {
    // Этот блок выполнится в любом случае
    console.log('Операция завершена (успешно или нет)');
  }
}

// Вызов async функции
performTask().then(finalResult => {
  console.log('Финальный результат:', finalResult);
});

Объяснение: async/await делает асинхронный код похожим на синхронный. await приостанавливает выполнение функции до получения результата промиса. try/catch/finally работает так же, как с синхронным кодом.

Пример 6: Последовательное выполнение с async/await

// Симуляция запросов к API
function apiCall(endpoint, delay) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Ответ от ${endpoint}`);
    }, delay);
  });
}

// Последовательное выполнение (каждый следующий ждёт предыдущий)
async function sequentialCalls() {
  console.log('Начинаем последовательные вызовы...');
  
  // Первый вызов
  const result1 = await apiCall('/auth', 1000);
  console.log('1:', result1);
  
  // Второй вызов (зависит от первого)
  const result2 = await apiCall('/data', 1500);
  console.log('2:', result2);
  
  // Третий вызов (зависит от второго)
  const result3 = await apiCall('/process', 500);
  console.log('3:', result3);
  
  console.log('Все вызовы завершены');
  return [result1, result2, result3];
}

sequentialCalls().then(results => {
  console.log('Итоговые данные:', results);
});

// Общее время выполнения: 1000 + 1500 + 500 = 3000 мс

Объяснение: Когда операции зависят друг от друга (например, нужно сначала авторизоваться, потом получить данные), используется последовательное выполнение. Каждый await ждёт завершения предыдущего.

Пример 7: Гонка промисов с Promise.race

// Функция с таймаутом
function fetchWithTimeout(url, timeout) {
  // Основной запрос
  const fetchPromise = new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Данные с ${url}`);
    }, 2000);
  });
  
  // Таймаут
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error(`Таймаут: запрос к ${url} занял больше ${timeout}мс`));
    }, timeout);
  });
  
  // Гонка: кто быстрее
  return Promise.race([fetchPromise, timeoutPromise]);
}

// Используем с таймаутом 1500 мс
fetchWithTimeout('api.example.com', 1500)
  .then(result => {
    console.log('Успех:', result);
  })
  .catch(error => {
    console.error('Ошибка:', error.message);
  });

// Вывод (через 1500 мс):
// Ошибка: Таймаут: запрос к api.example.com занял больше 1500мс

Объяснение: Promise.race возвращает результат первого завершившегося промиса (успех или ошибка). Это полезно для установки таймаутов на запросы.

Сравнение с Python: чем похоже и чем отличается

Python тоже имеет асинхронное программирование, но с другими подходами. Давайте сравним.

Характеристика JavaScript (Node.js) Python
Модель выполнения Один поток, Event Loop Один поток, Event Loop (asyncio)
Ключевые слова async, await async, await
Основной объект Promise Coroutine (корутина)
Создание асинхронной функции async function foo() {} async def foo():
Ожидание результата await promise await coroutine
Параллельное выполнение Promise.all() asyncio.gather()
Гонка Promise.race() asyncio.wait(..., return_when=FIRST_COMPLETED)
Настоящая многопоточность Нет (Worker Threads есть, но редко) Есть (threading, multiprocessing)
Блокирующие операции Блокируют весь Event Loop Блокируют только текущую корутину

Пример на Python для сравнения

// JavaScript
async function fetchData() {
  const response = await fetch('https://api.example.com');
  const data = await response.json();
  return data;
}
# Python
import asyncio
import aiohttp

async def fetch_data():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://api.example.com') as response:
            data = await response.json()
            return data

# Запуск
result = asyncio.run(fetch_data())
Ключевое отличие: В JavaScript await можно использовать только внутри async функции. В Python то же самое. Но в Python есть asyncio.run() для запуска асинхронной функции из синхронного кода. В JavaScript для этого нужно использовать .then() или оборачивать в async функцию.

Типичные ошибки и подводные камни

1. Забыли обработать ошибки

// Ошибка: нет catch
async function badFunction() {
  const data = await riskyOperation(); // Если упадёт, ошибка будет необработана
}

// Правильно:
async function goodFunction() {
  try {
    const data = await riskyOperation();
    return data;
  } catch (error) {
    console.error('Ошибка:', error);
    throw error; // Пробрасываем дальше
  }
}

2. Блокировка Event Loop

// Ошибка: синхронная операция внутри async функции
async function blockEventLoop() {
  console.log('Начало');
  
  // Это заблокирует Event Loop на 5 секунд!
  const start = Date.now();
  while (Date.now() - start < 5000) {
    // Пустой цикл
  }
  
  console.log('Конец (через 5 секунд)');
}

// Правильно: использовать асинхронный таймер
async function nonBlocking() {
  console.log('Начало');
  await new Promise(resolve => setTimeout(resolve, 5000));
  console.log('Конец (через 5 секунд)');
}

3. Забыли await

// Ошибка: забыли await
async function forgotAwait() {
  const data = fetchData(); // Вернёт Promise, а не данные!
  console.log(data); // Promise { <pending> }
}

// Правильно:
async function correctAwait() {
  const data = await fetchData();
  console.log(data); // Реальные данные
}

4. Последовательное выполнение вместо параллельного

// Ошибка: делаем последовательно то, что можно параллельно
async function slowExecution() {
  const user = await getUser(1); // 1 секунда
  const products = await getProducts(); // 1 секунда
  const orders = await getOrders(); // 1 секунда
  // Общее время: 3 секунды
}

// Правильно: параллельное выполнение
async function fastExecution() {
  const [user, products, orders] = await Promise.all([
    getUser(1),
    getProducts(),
    getOrders()
  ]);
  // Общее время: 1 секунда (максимальная из трёх)
}

5. Callback Hell (Ад колбэков)

// Плохо: глубоко вложенные колбэки
fs.readFile('a.txt', 'utf8', (err, dataA) => {
  fs.readFile('b.txt', 'utf8', (err, dataB) => {
    fs.readFile('c.txt', 'utf8', (err, dataC) => {
      console.log(dataA, dataB, dataC);
    });
  });
});

// Хорошо: промисы или async/await
async function readFiles() {
  const [dataA, dataB, dataC] = await Promise.all([
    fs.promises.readFile('a.txt', 'utf8'),
    fs.promises.readFile('b.txt', 'utf8'),
    fs.promises.readFile('c.txt', 'utf8')
  ]);
  console.log(dataA, dataB, dataC);
}

Лучшие практики и советы профессионалов

1. Всегда используйте async/await вместо чистых промисов

Код с async/await читается как синхронный. Это снижает когнитивную нагрузку и уменьшает количество ошибок.

2. Обрабатывайте ошибки на каждом уровне

Не надейтесь, что ошибка будет поймана где-то выше. Используйте try/catch в каждой async функции, которая может упасть.

3. Используйте Promise.all для независимых операций

Если операции не зависят друг от друга, запускайте их параллельно. Это ускорит выполнение в несколько раз.

4. Избегайте блокирующих операций

Не используйте синхронные версии функций (например, fs.readFileSync) в асинхронном коде. Они блокируют весь Event Loop.

5. Используйте таймауты для внешних запросов

Всегда устанавливайте таймауты для сетевых запросов. Используйте Promise.race с промисом таймаута.

6. Не смешивайте синхронный и асинхронный код без необходимости

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

7. Используйте линтеры и статический анализ

ESLint с правилами для асинхронного кода поможет найти потенциальные проблемы на этапе написания кода.

Вопросы для самопроверки

  1. Вопрос: Что такое Event Loop и зачем он нужен?

    Ответ: Event Loop — это бесконечный цикл, который проверяет, пуст ли стек вызовов. Если стек пуст, он берёт функцию из очереди колбэков и помещает её в стек для выполнения. Это позволяет JavaScript выполнять асинхронные операции, не блокируя основной поток.

  2. Вопрос: В чём разница между Promise.all и Promise.race?

    Ответ: Promise.all ждёт завершения всех промисов и возвращает массив результатов. Если хотя бы один промис отклонён, весь Promise.all отклоняется. Promise.race возвращает результат первого завершившегося промиса (успех или ошибку), игнорируя остальные.

  3. Вопрос: Почему setTimeout с задержкой 0 мс не выполняется сразу?

    Ответ: Потому что setTimeout передаёт колбэк в очередь, даже с задержкой 0 мс. Колбэк выполнится только после того, как стек вызовов освободится и Event Loop возьмёт его из очереди.

  4. Вопрос: Что такое «ад колбэков» и как его избежать?

    Ответ: Ад колбэков — это ситуация, когда колбэки вложены друг в друга на несколько уровней, что делает код нечитаемым. Избежать можно с помощью промисов и async/await.

  5. Вопрос: Можно ли использовать await вне async функции?

    Ответ: Нет, await можно использовать только внутри функции, объявленной с ключевым словом async. На верхнем уровне модуля (в Node.js) это также не работает без обёртки.

  6. Вопрос: Что произойдёт, если в async функции не использовать await?

    Ответ: Функция всё равно вернёт Promise, но выполнится синхронно. Все операции внутри будут выполнены последовательно, без ожидания асинхронных результатов.

  7. Вопрос: Как обработать ошибку в цепочке промисов?

    Ответ: Использовать метод .catch() в конце цепочки. Если ошибка произойдёт на любом этапе, управление перейдёт к .catch(). В async/await используется try/catch.

  8. Вопрос: Чем отличается асинхронность в JavaScript от многопоточности в Python?

    Ответ: В JavaScript один поток и Event Loop, который переключается между задачами. В Python есть настоящая многопоточность (threading) и многопроцессорность (multiprocessing), но асинхронность (asyncio) работает аналогично JavaScript — один поток и Event Loop.

  9. Вопрос: Что такое libuv в Node.js?

    Ответ: libuv — это библиотека на C++, которая обеспечивает асинхронный ввод/вывод в Node.js. Она управляет таймерами, сетевыми соединениями, чтением файлов и другими операциями, которые выполняются вне основного потока JavaScript.

  10. Вопрос: Как выполнить несколько асинхронных операций последовательно?

    Ответ: Использовать async/await с последовательными вызовами. Каждый следующий await будет ждать завершения предыдущего. Или использовать цепочку .then().

Итог и что изучить дальше

Мы прошли большой путь. Вы узнали: