Как работать с потоками в JavaScript: оптимизация асинхронных запросов

от автора

Асинхронное программирование — это тема, которая часто вызывает трудности у новичков JS. Неопытные разработчики сталкиваются со множеством вопросов, связанных с обработкой параллельных операций, управлением потоками данных и «адом колбэков». Тем не менее понимание и освоение асинхронности является важным шагом на пути к эффективной разработке современных веб-приложений. В этой статье разберем основные инструменты, такие как промисы и async/await, и покажем, как они могут облегчить обработку данных в фоновом режиме и повысить читаемость кода.

Привет, Хабр! Меня зовут Олег Волков, я ведущий frontend-разработчик IBS. В этой статье я хочу поделиться выжимкой из книги Кайла Симпсона «Вы не знаете JS. Асинхронная обработка & оптимизация». Лично мне она открыла глаза. Уверен, что другие JS-специалисты найдут ее не менее полезной!

Вообще, бородатый программист-техасец Кайл Симпсон, который, по собственному определению, «борется за людей, стоящих за пикселями», написал целую серию книг про JavaScript. В частности, у него есть лаконичные руководства про замыкания и объекты, ES6, типы и грамматические конструкции. Все книги Симпсона хорошо читаются и структурируют знания, причем рассчитаны они не столько на новичков, сколько на вполне себе уверенных пользователей языка. Советую к прочтению все шесть, но сегодня расскажу только ключевые идеи и принципы, изложенные в одной из них.

Проблема однопоточности JavaScript

Одна из основных особенностей языка — однопоточность. В отличие от многих других языков программирования JavaScript работает в одном основном потоке, что означает выполнение кода поочередно — по одной операции за раз. Если в коде возникает длинная операция, это блокирует весь поток, не позволяя другим задачам выполняться.

Покажу на примере, как JavaScript блокирует интерфейс пользователя на время выполнения долгой операции:

function longTask() {   const endTime = Date.now() + 3000; // Задержка на 3 секунды   while (Date.now() < endTime) {     // имитация долгой задачи   }   console.log("Задача завершена"); } console.log("Начало"); longTask(); console.log("Конец");

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

Ответ JavaScript на ограничения, связанные с однопоточностью, — это асинхронное программирование. Однопоточность работает в связке с циклом событий, который управляет очередями задач и позволяет выполнять асинхронный код. Код выполняется в стеке вызовов (Call Stack), а задачи из очередей добавляются в стек, когда он освобождается.

Вот простой пример:

console.log("Начало");  setTimeout(() => {   console.log("Асинхронная задача завершена"); }, 2000);  console.log("Конец");

Функция setTimeout отправляет задачу в очередь, а основной поток продолжает выполнять другие действия. После трех секунд функция delay вызывается, не блокируя основной поток, то есть не в том порядке, в каком мы видим это в коде, а асинхронно.

JavaScript предоставляет несколько ключевых инструментов для управления асинхронностью. К ним относятся колбэки, промисы и функция async/await. Остановлюсь на каждом из этих инструментов подробно.

Решение проблемы однопоточности: колбэки

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

Вот базовый пример асинхронного колбэка с таймером:

function task1() {   setTimeout(() => console.log("Задача 1 выполнена"), 3000; } function task2() {   setTimeout(() => console.log("Задача 2 выполнена"), 1000; }  task1(); task2();

Результат выполнения → Начало → Конец → Задача выполнена → Колбэк выполнен. Мы используем setTimeout, чтобы задать разное время задержки для разных задач в очереди. В моем примере: три секунды для первой задачи и одну секунду для второй. Функция asyncTask запускает асинхронную задачу с setTimeout и затем вызывает колбэк после ее завершения. Таким образом, вторая задача, хоть и была вызвана позже, в действительности будет выполнена раньше первой.

Проблемы колбэков

Исторически все началось именно с колбэков. Однако они часто приводили к сложным конструкциям, известным как «ад колбэков». Когда колбэки вложены друг в друга, они делают код трудночитаемым и плохо поддерживаемым → ошибка перехватывается и передается через колбэк. Это затрудняет управление ошибками, так как все ошибки должны быть явно обработаны в каждом колбэке. Кроме того, вложенные функции быстро усложняют структуру кода, увеличивают отступы и усложняют понимание последовательности выполнения. Такая структура не масштабируется при большом количестве вложенных колбэков, усложняя отладку.

asyncTask(() => {   console. log("Шаг 1 завершен");   asyncTask(() »> {     console. log("Шаг 2 завершен");     asyncTask(() »> {     console. log("Шаг 3 завершен");     }):   }): }):
function asyncTaskwithError (callback) (   setTimeout (() > {     try {       throw new Error ("Ошибка в задаче");     } catch (error) {       callback(error); // передача ошибки через коллбэк     }   },1000); }  asyncTaskWithError ((error) => {   if (error) {     console.error ("Ошибка:", error-message);   } else {     console.log("Задача выполнена успешно");   } });

«Адское» несовершенство колбэков привело к созданию более удобных инструментов, таких как промисы и async/await, о них расскажу ниже.

Решение проблемы однопоточности: промисы

Следующий эволюционный этап JavaScript на пути борьбы с однопоточностью — это промисы. Они представляют собой объект, который может находиться в одном из трех состояний: ожидание, выполнен или отклонен. Другими словами, это объект, представляющий завершение или неудачу асинхронной операции и ее результат. Промисы позволяют обрабатывать асинхронные операции как последовательные шаги.

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

function fetchData()) {   return new Promise((resolve) => {     setTimeout(() => {       resolve("Данные получены");     }, 3000);   }); }  console.log("Запрос данных...");  fetchData().then((result) => {   console.log(result); // "Данные получены" });  console.log("Ожидание данных...");  async function fetchDataAsync()) {   console.log("Запрос данных...");   const data = await fetchData();   console.log(data); // "Данные получены" }  fetchDataAsync();

Пример из книги показывает, как создать промис и использовать его для выполнения асинхронной задачи:

function asyncTask() {   return new Promise((resolve, reject) => {     setTimeout(() => {       resolve("Задача завершена");     }, 1000);   }); }  console.log("Начало");  asyncTask().then((result) => {   console.log(result); // "Задача завершена" }).catch((error) => {   console. error("Ошибка:", error); });  console.log("Конец");

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

Промисы можно объединять в цепочки через .then() для последовательного выполнения асинхронных операций/задач, что позволяет избежать вложенности колбэков, упростить структуру и повысить читаемость кода.

function firstTask() {   return new Promise((resolve) => {     setTimeout(() => resolve("Первая задача завершена"), 1000);   }): }  function secondTask(result) {   return new Promise((resolve) => {     setTimeout(() => resolve(`$(result), вторая задача завершена`), 1000);   }): }  firstTask()   .then((result) => {     console.log(result);     return secondTask(result);   ))   .then((result) => {     console.log(result);   })   .catch((error) => {     console.error("Ошибка:", error);   });

Параллельное выполнение промисов

Помимо цепочной обработки, вместе с промисами в JavaScript мы также получили методы для параллельного выполнения операций, главным из которых является Promise.all.

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

В примере я создал три промиса. Каждый из них выполняет асинхронную задачу с помощью setTimeout, который реализует задержку выполнения. Метод Promise.all принимает массив из промисов и запускает их параллельно. Он завершится только тогда, когда завершатся все задачи в массиве:

const task1 = new Promise((resolve) => setTimeout(() => resolve("Задача 1"), 1000)); const task2 = new Promise((resolve) => setTimeout(() => resolve("Задача 2"), 2000)); const task3 = new Promise((resolve) => setTimeout(() => resolve("Задача 3"), 1500)); Promise.all([task1, task2, task3])   .then((results) => {     console.log("Все задачи завершены:", results);   })   .catch((error) => {     console.error("Ошибка:", error);   });

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

В целом промисы — это удобный способ работы с асинхронными операциями, который решает многие проблемы колбэков, такие как вложенность и сложная обработка ошибок. Однако у этого инструмента есть и свой недостаток, а именно некоторый «эффект домино». В случае ошибки хотя бы одного из промисов у нас мгновенно вызывается блок catch и выполнение других промисов завершается ошибкой.

Решение проблемы однопоточности: async/await

Третий инструмент для работы в асинхронностью в JavaScript — это async/await. Операторы async/await останавливают выполнение функции до тех пор, пока промис не завершится, что избавляет нас от необходимости выстраивать сложные цепочки через .then() или использовать многоуровневые колбэки. Это делает код более легким для понимания и поддержания, особенно при сложных операциях. Можно сказать, что async/await — это синтаксический «сахар» над промисами, который значительно упрощает работу и позволяет писать чистый асинхронный код в линейном стиле, похожем на синхронный.

Как видно на примере ниже, функция asyncTask выполняется асинхронно, но код внутри нее выглядит как синхронный благодаря конструкции await:

async function asyncTask() {   console.log("Запуск задачи...");   const result = await new Promise((resolve) => {     setTimeout(() => resolve("Задача завершена"), 1000);   }):   console.log(result); // "Задача завершена" }  console.log("Начало"); asyncTask(); console.log("Конец");

Что удобно, мы можем использовать конструкцию try…catch для перехвата любых исключений. Как работает: если промис завершается с ошибкой, она перехватывается в блоке catch, упрощая обработку ошибок в асинхронном коде.

async function errorProneTask() {   try {     const result = await new Promise((_, reject) => {       setTimeout(() => reject(new Error("Ошибка выполнения задачи")), 1000);     });     console.log(result);   } catch (error) {     console.error("Обнаружена ошибка:", error.message);   } } errorProneTask();

Async/await. Параллельный и последовательный вызов промисов

На примерах ниже продемонстрированы два разных подхода к выполнению асинхронных задач — последовательный (sequentialTasks), при котором каждая задача начинает выполняться только после завершения предыдущей, и параллельный (parallelTasks), при котором промисы запускаются одновременно:

async function sequentialTasks() {   const task1 = await new Promise((resolve) => setTimeout(() => resolve("Задача 1 завершена"), 1000));   const task2 = await new Promise((resolve) => setTimeout(() => resolve("Задача 2 завершена"), 1000));   console.log(task1, task2); } async function parallelTasks() {   const [task1, task2] = await Promise.all([     new Promise((resolve) => setTimeout(() => resolve("Задача 1 завершена"), 1000)),     new Promise((resolve) => setTimeout(() => resolve("Задача 2 завершена"), 1000))   ]);   console.log(task1, task2); }

Последовательное выполнение задач занимает больше времени, но тем не менее полезно в тех случаях, когда необходимо строго соблюдать порядок выполнения задач. Например, если Задача 2 зависит от результата Задачи 1. В свою очередь, параллельное выполнение позволяет значительно сократить время, но при этом не всегда дает нам возможность контролировать порядок завершения задач. В частности, setTimeout не будет работать с сетевыми запросами и может привести к ошибкам.

Async/await. Асинхронные итерации с циклом for…of

Небольшая, но полезная фича — асинхронная итерация по массиву промисов. Каждая задача выполняется с задержкой, и for…of ждет выполнения текущей перед переходом к следующей.

async function asyncLoop() {   const tasks = [1, 2, 3].map((num) =>     new Promise((resolve) => setTimeout(() => resolve(`Задача ${num} завершена`), 1000 * num))   );      for (const task of tasks) {     console.log(await task);   } }  asyncLoop();

Асинхронные генераторы

Перейду к самому сложному для начинающих пункту — асинхронным генераторам. Они позволяют работать с потоками данных постепенно, загружая и обрабатывая данные по частям. Это особенно удобно, когда мы имеем дело с большими объемами данных или поступающими потоками.

Асинхронные генераторы используются с for await…of, которая позволяет ждать завершения каждой операции перед запуском следующей. Функция asyncGenerator создает поток асинхронных данных, а for await…of позволяет обрабатывать их по мере поступления.

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

async function* asyncGenerator() {   yield new Promise((resolve) => setTimeout(() => resolve("Данные 1"), 1000));   yield new Promise((resolve) => setTimeout(() => resolve("Данные 2"), 1000));   yield new Promise((resolve) => setTimeout(() => resolve("Данные 3"), 1000)); } async function processGenerator() {   for await (const data of asyncGenerator()) {     console.log(data);   } } processGenerator();

Этот подход упрощает управление асинхронными последовательностями и делает код более гибким и удобным для работы с динамическими данными.

Преимущества использования асинхронных генераторов

1. Постепенная обработка больших объемов данных:

  • Проблема: работа с большими объемами данных, например загрузка файлов, обработка потоков данных, может потреблять значительное количество памяти и ресурсов, если данные загружаются и обрабатываются целиком.

  • Решение: асинхронные генераторы позволяют получать данные по частям (в виде «частичных» данных, известных как чанки) и обрабатывать их постепенно, без ожидания загрузки всей последовательности. Это снижает использование памяти и ускоряет обработку.

  • Пример: при чтении большого файла асинхронный генератор может последовательно обрабатывать порции данных, освобождая память после завершения каждой порции, и переходить к следующей.

2. Управление задержками и параллельными операциями:

  • Проблема: асинхронные задачи, которые включают задержки, например задержку при ожидании данных из сети, могут блокировать выполнение других операций, если не контролировать их выполнение.

  • Решение: цикл for await…of в сочетании с асинхронными генераторами позволяет контролировать выполнение каждой задачи по мере ее завершения. Это помогает избежать блокировки всего кода и обрабатывать только завершенные части данных, улучшая скорость реакции программы.

  • Пример: при работе с асинхронными API-запросами for await…of позволяет поочередно обрабатывать ответы от каждого запроса по мере их выполнения, что упрощает управление параллельными операциями.

3. Улучшение читаемости и поддержки кода:

  • Асинхронные итераторы и генераторы обеспечивают линейную, понятную структуру обработки данных, избавляя от вложенных колбэков и усложненных цепочек .then().

  • Код, использующий асинхронные генераторы и for await…of, легче отлаживать, так как он представлен линейными конструкциями. Это особенно полезно при асинхронной обработке данных, когда необходимо последовательно обработать результаты, поступающие по мере выполнения операций.

4. Гибкость при обработке ошибок:

  • В асинхронных генераторах легко управлять ошибками через try…catch, что делает код более устойчивым и позволяет корректно обрабатывать ошибки на каждом этапе итерации.

  • Это значительно улучшает стабильность кода, так как ошибки в одной итерации не прерывают всю последовательность, позволяя гибко реагировать на сбои и продолжать обработку оставшихся данных.

Таким образом, асинхронные итераторы идеально подходят для работы с потоками данных, например, при чтении данных по сети или из файлов.

Еще один пример работы асинхронных интераторов:

async function fetchData() {    const dataChunks = ["Часть 1", "Часть 2", "Часть 3"];   for (const chunk of dataChunks) {     await new Promise((resolve) => setTimeout(resolve, 1000)); // эмуляция задержки     yield chunk;   } } async function processData() {   for await (const chunk of fetchData()) {     console.log(`Получены данные: ${chunk}`);   } } processData();

Здесь fetchData — асинхронный оператор, который поочередно вызывает элементы из массива dataChunks. В каждой итерации генератора перед yield используется await с промисом, реализующим задержку в одну секунду, что позволяет получать данные постепенно. Мы дожидаемся готовности очередного фрагмента данных, после чего выводим его в консоль.

Недетерминированность в асинхронном коде

Одна из больших проблем асинхронности — это недетерминированность, из-за которой результат или порядок выполнения задач не всегда можно предсказать. В асинхронном JavaScript это связано с тем, что задачи выполняются параллельно в очереди задач, но движок JavaScript обрабатывает их последовательно. Даже если задачи запущены в определенном порядке, результат их выполнения может прийти в произвольном порядке.

Недетерминированность может привести к непредсказуемым багам, когда порядок выполнения задач нарушается и код работает нестабильно.

Кроме того, при параллельных запросах к одним и тем же данным нередко возникает «состояние гонки» (race condition), когда несколько задач пытаются одновременно изменить одни и те же данные, а результат зависит от того, какая задача завершится первой.

Наглядный пример состояния гонки с промисами, где два промиса пытаются обновить одни и те же данные:

let sharedData = 0;  function IncrementData()) {   return new Promise((resolve) => {     setTimeout(() => {       sharedData += 1;       resolve(sharedData);     }, Math.random() * 2000);   }); }  async function processData() {   const resulti = incrementData();   const result2 = incrementata();      console.log(await result1, await result2);   }  processData();

Здесь обе задачи запускаются одновременно, и каждая пытается изменить sharedData, но порядок завершения будет зависеть от случайного времени задержки. Результат выполнения может быть, например, 1 2 или 2 2. Эта непредсказуемость и создает условия для «состояния гонки», когда результат зависит от того, какая задача завершится первой.

Недетерминированность может вызвать непредсказуемое поведение и баги, особенно когда задачи зависят друг от друга. Понимание ее природы и использование механизмов последовательного или контролируемого параллельного выполнения позволяет значительно улучшить стабильность и предсказуемость асинхронного кода.

Резюме

JavaScript, будучи однопоточным, требует тщательного управления задачами, чтобы избежать блокировок и зависаний. В этом ему помогает цикл событий, который управляет очередями задач и микрозадач и позволяет асинхронному коду выполняться после освобождения основного потока. Решение проблемы однопоточности — одна из главных причин популярности асинхронного программирования в JavaScript.

Асинхронность в JavaScript — это не просто способ «ускорить» код, а мощный инструмент для создания отзывчивых и плавных интерфейсов. Синхронный код легко блокирует интерфейс, что делает его непригодным для сложных приложений. Асинхронные подходы, такие как промисы и async/await, помогают JavaScript более эффективно управлять ресурсами, предоставляя пользователям лучший опыт.

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


ссылка на оригинал статьи https://habr.com/ru/articles/869624/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *