Асинхронное программирование в 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:
- timers: выполняются колбэки setTimeout, setInterval
- pending callbacks: колбэки ввода-вывода, отложенные на предыдущую итерацию
-
Блоги
Рекомендаций по книгам
Читайте также
Безопасная аутентификация в Rust: пишем защищённый бэкенд
Деплой TypeScript приложения на VPS: пошаговое руководство
HashMap vs Hashtable в Java: различия и внутреннее устройство
Оптимизация производительности Rust приложений: советы и примеры
Наследование в C++: public, private, protected и производительность