Асинхронность в современном JavaScript: от промисов до параллелизма

В мире, где веб-приложения стали сложнее операционных систем 90-х, эффективная обработка асинхронных операций перешла из разряда "желательных навыков" в категорию "без этого вы погибните под обломками своего кода". Современные кодовые базы пестрят запросами к API, обработкой файлов и сложными цепочками зависимостей. Разберёмся, как управлять этим цивилизованно.

Провал в ад колбэков: почему промисы стали спасением

Пример проблемного кода с колбэками:

javascript
fetchData('api/users', (err, users) => {
  if (err) console.error('Failed loading users');
  fetchData(`api/users/${users[0].id}/posts`, (err, posts) => {
    if (err) console.error('Failed loading posts');
    fetchData(`api/posts/${posts[0].id}/comments`, (err, comments) => {
      if (err) console.error('Failed loading comments');
      renderUI(users, posts, comments);
    });
  });
});

"Ад колбэков" демонстрирует проблемы:

  • Непрерывное увеличение вложенности
  • Установка хаотичного порядка выполнения
  • Трудности с перехватом ошибок
  • Цепочки зависимостей, приковывающие логику к конкретной последовательности

Промисы стали семантически понятным решением:

javascript
fetchData('api/users')
  .then(users => fetchData(`api/users/${users[0].id}/posts`))
  .then(posts => fetchData(`api/posts/${posts[0].id}/comments`))
  .then(comments => renderUI(comments))
  .catch(error => console.error('Failure in chain', error));

Ошибки теперь перехватываются централизованно, а вложенность контролируется за счёт последовательных then. Но настоящая революция произошла с появлением async/await.

Async/Await: синхронный стиль для асинхронной работы

Перепишем тот же пример с async/await:

javascript
async function loadUserData() {
  try {
    const users = await fetchData('api/users');
    const posts = await fetchData(`api/users/${users[0].id}/posts`);
    const comments = await fetchData(`api/posts/${posts[0].id}/comments`);
    renderUI(users, posts, comments);
  } catch (error) {
    console.error('Data loading failed', error);
  }
}

Кажется идеальным? Почти. Обратите внимание на await: каждая операция ожидает завершения предыдущей. Для независимых операций это создаёт искусственные задержки. Исправим:

javascript
async function loadUserData() {
  try {
    const [users, posts] = await Promise.all([
      fetchData('api/users'),
      fetchData('api/posts/latest')
    ]);
    
    // Обработка взаимозависимых данных после параллельной загрузки
    const comments = await fetchData(`api/posts/${posts[0].id}/comments`);
    
    renderUI(users, posts, comments);
  } catch (error) {
    console.error('Data loading failed', error);
  }
}

Promise.all запускает параллельное выполнение, await ожидает завершения всех операций. Для более сложной координации используем Promise.allSettled:

javascript
const results = await Promise.allSettled([
  fetchData('api/users'),
  fetchExperimentalFeature('api/beta')
]);

const usersResult = results[0];
const betaResult = results[1];

if (usersResult.status === 'fulfilled') {
  processUsers(usersResult.value);
} else {
  fallbackUserLoading();
}

// Бета-функциональность не критична для работы приложения
if (betaResult.status === 'fulfilled') {
  enableBetaFeature(betaResult.value);
}

Неочевидные грабли async/await

1. Потеря контроля над контекстом выполнения

javascript
async function processBatch(items) {
  for (const item of items) {
    await processItem(item); // Последовательная обработка в цикле
  }
}

Для больших наборов данных это убивает производительность. Решение – конкурентная обработка:

javascript
async function processBatch(items) {
  // Параллельный запуск с ограничением количества одновременных операций
  const concurrency = 5;
  const batches = [];
  
  for (let i = 0; i < items.length; i += concurrency) {
    const chunk = items.slice(i, i + concurrency);
    batches.push(Promise.all(chunk.map(processItem)));
  }
  
  await Promise.all(batches);
}

2. Молчаливые провалы

Следующий код завершится без ошибки, даже если запрос упадёт:

javascript
async function loadData() {
  try {
    return fetchData('api/settings');
  } catch (error) {
    // Ошибка проглотится, так как нет обработчика
  }
}

Сервис-воркеры, особенно в PWA, требуют явной обработки всех исключений:

javascript
async function loadData() {
  try {
    return await fetchData('api/settings');
  } catch (error) {
    // Контекстная обработка
    logToAnalytics('settings_load_fail', error);
    throw new Error('SETTINGS_UNAVAILABLE');
  }
}

3. Взаимоблокировки промисов (Promise Deadlock)

Рассмотрим хрестоматийный пример:

javascript
let resolveA;
const promiseA = new Promise(resolve => resolveA = resolve);
const promiseB = new Promise(resolve => resolveA(promiseB));

promiseA разрешится только когда разрешится promiseB, но promiseB разрешится только после разрешения promiseA. Deadlock гарантирован.

На практике такие ситуации возникают сложнее:

javascript
const cache = new Map();

async function getWithCache(key) {
  if (cache.has(key)) return cache.get(key);
  
  const promise = fetchResource(key);
  cache.set(key, promise);
  
  return promise;  // Теперь все последующие запросы получат этот промис
}

Если fetchResource завершится ошибкой, все зависимые запросы потерпят неудачу с идентичной ошибкой. Более надежная реализация:

javascript
const cache = new Map();

async function getWithCache(key) {
  if (cache.has(key)) return cache.get(key);
  
  const promise = fetchResource(key)
    .then(result => {
      cache.set(key, result);  // Сохраняем результат вместо промиса
      return result;
    })
    .catch(error => {
      cache.delete(key);  // Очищаем неудачный запрос
      throw error;
    });
    
  cache.set(key, promise);
  return promise;
}

Работа с асинхронными генераторами

Для потоковой обработки больших наборов данных используем асинхронные генераторы:

javascript
async function* streamResults(query) {
  let page = 1;
  
  while (true) {
    const response = await fetch(`/api/search?q=${query}&page=${page}`);
    const data = await response.json();
    
    if (!data.results.length) return;
    
    yield data.results;
    page++;
  }
}

// Использование
const searchIterator = streamResults('JavaScript');
for await (const results of searchIterator) {
  insertIntoDOM(results);
}

При аккуратном использовании эта конструкция позволяет обрабатывать гигантские наборы данных без загрузки всего массива в память.

Реактивные расширения с RxJS

Для сверхсложных асинхронных сценариев с временными интервалами, отменой операций и комбинированием потоков данных присмотритесь к RxJS:

javascript
import { fromEvent, interval, combineLatest } from 'rxjs';
import { map, switchMap, filter, debounceTime } from 'rxjs/operators';

const searchInput = document.getElementById('search');
const resultsContainer = document.getElementById('results');

fromEvent(searchInput, 'input').pipe(
  map(event => event.target.value.trim()),
  filter(query => query.length > 2),
  debounceTime(300),
  switchMap(query => 
    combineLatest([
      fetch(`/api/users?q=${query}`).then(res => res.json()),
      fetch(`/api/posts?q=${query}`).then(res => res.json())
    ])
  )
).subscribe(([users, posts]) => {
  renderResults(users.concat(posts));
});

Эта конструкция:

  1. Кэширует значения из инпута
  2. Фильтрует короткие строки
  3. Устраняет дребезг
  4. Параллельно загружает данные
  5. Автоматически отменяет предыдущий запрос при новом вводе
  6. Комбинирует результаты из разных источников

Заключительные рекомендации

Лучшие принципы управления асинхронностью:

  1. Рассматривайте время как переменную: Фиксируйте метрики выполнения операций, отслеживайте "подвисшие" промисы через Promise.race с таймером

  2. Контекст превыше удобства: Не злоупотребляйте глобальными обработчиками ошибок. Балансируйте между централизованной логикой и локальными catch-блоками

  3. Приоритет параллелизма: Массивы данных обрабатывайте через Promise.allSettled, а итеративные задачи – через пулл-воркеров

  4. Объявляйте асинхронность явно: Функции, возвращающие промисы, должны иметь в идентификаторе сигнатуру async (последовательности loadDataAsync) или глагол действия (fetch, get, process)

  5. Тестируйте асинхронность как враждебную среду: Эмулируйте сетевые задержки, отказы серверов, частичные ответы. Все, что может сломаться – сломается в продакшене

Асинхронные паттерны продолжают эволюционировать. Для проектов на современном стекле ознакомьтесь с платформенными API вроде SharedArrayBuffer для настоящей параллельной обработки и экспериментальным Async Context API для передачи контекста выполнения. Основная задача – не просто работать с асинхронностью, но заставить её работать на вас.