Асинхронное программирование в JavaScript: полное руководство на практике

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Асинхронное программирование в JavaScript: от колбэков до async/await

JavaScript — это однопоточный язык, но его модель выполнения позволяет эффективно обрабатывать тысячи одновременных операций без блокировки основного потока. Это возможно благодаря асинхронному программированию. В Node.js, где операции ввода-вывода (чтение файлов, запросы к базе данных, сетевые вызовы) занимают 90% времени, понимание асинхронности становится критическим навыком. В этом руководстве мы разберем три эволюционных этапа работы с асинхронностью: колбэки, промисы и async/await, а также заглянем под капот Event Loop.

1. Колбэки (Callbacks): фундамент асинхронности

Колбэк — это функция, которая передается в другую функцию как аргумент и вызывается после завершения асинхронной операции. Это самый старый и базовый паттерн в JavaScript. В Node.js многие встроенные модули (fs, http) используют колбэки.

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

const fs = require('fs');

console.log('Начинаем чтение файла...');

fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Ошибка:', err);
    return;
  }
  console.log('Содержимое файла:', data);
});

console.log('Код выполняется дальше...');

Вывод в консоль покажет, что "Код выполняется дальше..." появится раньше, чем содержимое файла. Это и есть неблокирующее поведение: основной поток не ждет завершения чтения, а продолжает выполнение.

Проблема колбэков: Callback Hell

При вложенных асинхронных операциях код превращается в "адскую пирамиду":

fs.readFile('user.json', (err, userData) => {
  if (err) throw err;
  const user = JSON.parse(userData);
  db.query(`SELECT * FROM posts WHERE user_id=${user.id}`, (err, posts) => {
    if (err) throw err;
    fs.writeFile('user_posts.json', JSON.stringify(posts), (err) => {
      if (err) throw err;
      console.log('Готово!');
    });
  });
});

Такой код сложно читать, отлаживать и поддерживать. Решение — промисы.

2. Промисы (Promises): цепочки и обработка ошибок

Promise — это объект, представляющий будущий результат асинхронной операции. Он может находиться в одном из трех состояний: pending (ожидание), fulfilled (выполнено успешно) или rejected (отклонено с ошибкой).

Создание простого промиса:

const readFilePromise = (filename) => {
  return new Promise((resolve, reject) => {
    fs.readFile(filename, 'utf8', (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};

readFilePromise('example.txt')
  .then(data => console.log('Данные:', data))
  .catch(err => console.error('Ошибка:', err));

Цепочки промисов (Chaining)

Главное преимущество — возможность выстраивать последовательные операции без вложенности:

readFilePromise('user.json')
  .then(userData => {
    const user = JSON.parse(userData);
    return dbQueryPromise(`SELECT * FROM posts WHERE user_id=${user.id}`);
  })
  .then(posts => {
    return writeFilePromise('user_posts.json', JSON.stringify(posts));
  })
  .then(() => console.log('Готово!'))
  .catch(err => console.error('Ошибка в цепочке:', err));

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

Когда нужно выполнить несколько независимых операций одновременно:

const promise1 = readFilePromise('file1.txt');
const promise2 = readFilePromise('file2.txt');
const promise3 = readFilePromise('file3.txt');

Promise.all([promise1, promise2, promise3])
  .then(results => {
    console.log('Все файлы прочитаны:', results);
  })
  .catch(err => console.error('Один из промисов упал:', err));

Promise.all завершается успешно, только если все промисы выполнились успешно. Если хотя бы один упал — сработает catch.

3. Async/Await: синтаксический сахар для работы с промисами

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

Базовый пример:

const fs = require('fs').promises; // используем встроенную промисную версию

async function processUserData() {
  try {
    const userData = await fs.readFile('user.json', 'utf8');
    const user = JSON.parse(userData);
    console.log('Пользователь загружен:', user.name);
    
    const posts = await dbQueryPromise(`SELECT * FROM posts WHERE user_id=${user.id}`);
    await fs.writeFile('user_posts.json', JSON.stringify(posts));
    
    console.log('Готово!');
  } catch (err) {
    console.error('Ошибка:', err);
  }
}

processUserData();

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

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

async function readMultipleFiles() {
  try {
    const [data1, data2, data3] = await Promise.all([
      fs.readFile('file1.txt', 'utf8'),
      fs.readFile('file2.txt', 'utf8'),
      fs.readFile('file3.txt', 'utf8')
    ]);
    
    console.log('Результаты:', data1, data2, data3);
  } catch (err) {
    console.error('Ошибка:', err);
  }
}

Важные правила:

  • await можно использовать только внутри async-функции
  • Обрабатывайте ошибки через try/catch — иначе они будут "проглочены"
  • Не используйте await в циклах forEach — используйте for...of или Promise.all

4. Event Loop: как JavaScript управляет асинхронностью

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

Основные фазы Event Loop: