Дізнайтеся, як створити високоефективний паралельний процесор у JavaScript, використовуючи асинхронні ітератори. Освойте одночасне керування потоками для значного прискорення додатків з інтенсивною обробкою даних.
Розблокування високоефективного JavaScript: глибокий аналіз паралельних процесорів-помічників ітераторів для одночасного керування потоками
У світі сучасної розробки програмного забезпечення продуктивність – це не функція, а фундаментальна вимога. Від обробки великих наборів даних у серверній службі до обробки складних взаємодій API у веб-додатку, здатність ефективно керувати асинхронними операціями є надзвичайно важливою. JavaScript, з його однопоточною моделлю, керованою подіями, вже давно чудово справляється з задачами, обмеженими вводом-виводом. Однак, у міру зростання обсягів даних, традиційні методи послідовної обробки стають значними вузькими місцями.
Уявіть, що вам потрібно отримати деталі для 10 000 продуктів, обробити файл журналу розміром з гігабайт або згенерувати мініатюри для сотень зображень, завантажених користувачами. Обробка цих завдань один за одним є надійною, але дуже повільною. Ключ до розблокування значного підвищення продуктивності полягає в одночасності – обробці кількох елементів одночасно. Саме тут сила асинхронних ітераторів, у поєднанні з власною стратегією паралельної обробки, змінює спосіб обробки потоків даних.
Цей вичерпний посібник призначений для розробників JavaScript середнього та просунутого рівня, які хочуть вийти за межі основних циклів `async/await`. Ми дослідимо основи ітераторів JavaScript, заглибимося в проблему послідовних вузьких місць і, найголовніше, створимо потужний, багаторазовий Паралельний процесор-помічник ітераторів з нуля. Цей інструмент дозволить вам керувати одночасними задачами над будь-яким потоком даних з точним контролем, роблячи ваші програми швидшими, ефективнішими та масштабованішими.
Розуміння основ: Ітератори та асинхронний JavaScript
Перш ніж ми зможемо створити наш паралельний процесор, ми повинні мати чітке розуміння основних концепцій JavaScript, які роблять це можливим: протоколи ітераторів та їх асинхронні аналоги.
Сила ітераторів та ітерованих об’єктів
В основі протокол ітератора забезпечує стандартний спосіб створення послідовності значень. Об’єкт вважається ітерованим, якщо він реалізує метод з ключем `Symbol.iterator`. Цей метод повертає об’єкт ітератора, який має метод `next()`. Кожен виклик `next()` повертає об’єкт з двома властивостями: `value` (наступне значення в послідовності) і `done` (логічне значення, що вказує, чи завершена послідовність).
Цей протокол є магією, що стоїть за циклом `for...of`, і він вбудований багатьма вбудованими типами:
- Масиви: `['a', 'b', 'c']`
- Рядки: `"hello"`
- Карти: `new Map([['key1', 'value1'], ['key2', 'value2']])`
- Множини: `new Set([1, 2, 3])`
Краса ітерованих об’єктів полягає в тому, що вони представляють потоки даних у лінивий спосіб. Ви отримуєте значення по одному, що неймовірно ефективно для великих або навіть нескінченних послідовностей, оскільки вам не потрібно зберігати весь набір даних у пам’яті одночасно.
Поява асинхронних ітераторів
Стандартний протокол ітератора є синхронним. Що, якщо значення в нашій послідовності недоступні одразу? Що, якщо вони надходять з мережевого запиту, курсора бази даних або потоку файлів? Саме тут з’являються асинхронні ітератори.
Протокол асинхронного ітератора є близьким родичем свого синхронного аналога. Об’єкт є асинхронно ітерованим, якщо він має метод, позначений ключем `Symbol.asyncIterator`. Цей метод повертає асинхронний ітератор, чий метод `next()` повертає `Promise`, який переходить у стан виконаного з результатом `{ value, done }`.
Це дозволяє нам працювати з потоками даних, які надходять з часом, використовуючи елегантний цикл `for await...of`:
Приклад: Асинхронний генератор, який видає числа із затримкою.
async function* createDelayedNumberStream() {
for (let i = 1; i <= 5; i++) {
// Simulate a network delay or other async operation
await new Promise(resolve => setTimeout(resolve, 500));
yield i;
}
}
async function consumeStream() {
const numberStream = createDelayedNumberStream();
console.log('Starting consumption...');
// The loop will pause at each 'await' until the next value is ready
for await (const number of numberStream) {
console.log(`Received: ${number}`);
}
console.log('Consumption finished.');
}
// Output will show numbers appearing every 500ms
Цей патерн є фундаментальним для сучасної обробки даних у Node.js і браузерах, дозволяючи нам витончено обробляти великі джерела даних.
Представляємо пропозицію щодо помічників ітераторів
Хоча цикли `for...of` є потужними, вони можуть бути імперативними та багатослівними. Для масивів у нас є багатий набір декларативних методів, таких як `.map()`, `.filter()` і `.reduce()`. Пропозиція TC39 щодо помічників ітераторів має на меті надати ту саму виразну силу безпосередньо ітераторам.
Ця пропозиція додає методи до `Iterator.prototype` і `AsyncIterator.prototype`, дозволяючи нам об’єднувати операції в ланцюжок для будь-якого ітерованого джерела, не перетворюючи його спочатку на масив. Це кардинально змінює ефективність використання пам’яті та чіткість коду.
Розглянемо цей сценарій «до і після» для фільтрації та відображення потоку даних:
До (зі стандартним циклом):
async function processData(source) {
const results = [];
for await (const item of source) {
if (item.value > 10) { // filter
const processedItem = await transform(item); // map
results.push(processedItem);
}
}
return results;
}
Після (з запропонованими асинхронними помічниками ітераторів):
async function processDataWithHelpers(source) {
const results = await source
.filter(item => item.value > 10)
.map(async item => await transform(item))
.toArray(); // .toArray() is another proposed helper
return results;
}
Хоча ця пропозиція ще не є стандартною частиною мови у всіх середовищах, її принципи формують концептуальну основу для нашого паралельного процесора. Ми хочемо створити операцію, подібну до `map`, яка не просто обробляє один елемент за раз, а запускає кілька операцій `transform` паралельно.
Вузьке місце: Послідовна обробка в асинхронному світі
Цикл `for await...of` є фантастичним інструментом, але він має вирішальну характеристику: він є послідовним. Тіло циклу не починається для наступного елемента, доки операції `await` для поточного елемента не будуть повністю завершені. Це створює обмеження продуктивності при роботі з незалежними задачами.
Проілюструємо це поширеним реальним сценарієм: отримання даних з API для списку ідентифікаторів.
Уявіть, що у нас є асинхронний ітератор, який видає 100 ідентифікаторів користувачів. Для кожного ідентифікатора нам потрібно зробити виклик API, щоб отримати профіль користувача. Припустимо, що кожен виклик API займає в середньому 200 мілісекунд.
async function fetchUserProfile(userId) {
// Simulate an API call
await new Promise(resolve => setTimeout(resolve, 200));
return { id: userId, name: `User ${userId}`, fetchedAt: new Date() };
}
async function fetchAllUsersSequentially(userIds) {
console.time('SequentialFetch');
const profiles = [];
for await (const id of userIds) {
const profile = await fetchUserProfile(id);
profiles.push(profile);
console.log(`Fetched user ${id}`);
}
console.timeEnd('SequentialFetch');
return profiles;
}
// Assuming 'userIds' is an async iterable of 100 IDs
// await fetchAllUsersSequentially(userIds);
Який загальний час виконання? Оскільки кожен `await fetchUserProfile(id)` має завершитися до того, як почнеться наступний, загальний час становитиме приблизно:
100 користувачів * 200 мс/користувач = 20 000 мс (20 секунд)
Це класичне вузьке місце, пов’язане з вводом-виводом. Поки наш процес JavaScript чекає на мережу, його цикл подій здебільшого неактивний. Ми не використовуємо всю потужність системи або зовнішнього API. Хронологія обробки виглядає так:
Задача 1: [---ОЧІКУВАННЯ---] Готово
Задача 2: [---ОЧІКУВАННЯ---] Готово
Задача 3: [---ОЧІКУВАННЯ---] Готово
...і так далі.
Наша мета – змінити цю хронологію на щось на зразок цього, використовуючи рівень одночасності 10:
Задача 1-10: [---ОЧІКУВАННЯ---][---ОЧІКУВАННЯ---]... Готово
Задача 11-20: [---ОЧІКУВАННЯ---][---ОЧІКУВАННЯ---]... Готово
...
З 10 одночасними операціями ми теоретично можемо скоротити загальний час з 20 секунд до всього 2 секунд. Це стрибок у продуктивності, якого ми прагнемо досягти, створюючи власний паралельний процесор.
Створення паралельного процесора-помічника ітераторів JavaScript
Тепер ми переходимо до суті цієї статті. Ми створимо функцію генератора async, яку ми назвемо `parallelMap`, яка приймає асинхронне ітероване джерело, функцію відображення та рівень одночасності. Він створить новий асинхронний ітерований об’єкт, який видає оброблені результати, коли вони стають доступними.
Основні принципи проектування
- Обмеження одночасності: Процесор ніколи не повинен мати більше ніж вказану кількість обіцянок функції `mapper` в польоті одночасно. Це важливо для управління ресурсами та дотримання обмежень швидкості зовнішнього API.
- Ліниве споживання: Він повинен витягувати з вихідного ітератора лише тоді, коли є вільний слот у його пулі обробки. Це гарантує, що ми не буферизуємо все джерело в пам’яті, зберігаючи переваги потоків.
- Обробка зворотного тиску: Процесор повинен природним чином призупинятися, якщо споживач його виводу повільний. Асинхронні генератори досягають цього автоматично за допомогою ключового слова `yield`. Коли виконання призупинено в `yield`, нові елементи не витягуються з джерела.
- Невпорядкований вивід для максимальної пропускної здатності: Щоб досягти максимально можливої швидкості, наш процесор видаватиме результати, як тільки вони будуть готові, не обов’язково у вихідному порядку введення. Ми обговоримо, як зберегти порядок пізніше як розширену тему.
Реалізація `parallelMap`
Давайте створимо нашу функцію крок за кроком. Найкращим інструментом для створення власного асинхронного ітератора є `async function*` (асинхронний генератор).
/**
* Creates a new async iterable that processes items from a source iterable in parallel.
* @param {AsyncIterable|Iterable} source The source iterable to process.
* @param {Function} mapperFn An async function that takes an item and returns a promise of the processed result.
* @param {object} options
* @param {number} options.concurrency The maximum number of tasks to run in parallel.
* @returns {AsyncGenerator} An async generator that yields the processed results.
*/
async function* parallelMap(source, mapperFn, { concurrency = 5 }) {
// 1. Get the async iterator from the source.
// This works for both sync and async iterables.
const asyncIterator = source[Symbol.asyncIterator] ?
source[Symbol.asyncIterator]() :
source[Symbol.iterator]();
// 2. A set to keep track of the promises for the currently processing tasks.
// Using a Set makes adding and deleting promises efficient.
const processing = new Set();
// 3. A flag to track if the source iterator is exhausted.
let sourceIsDone = false;
// 4. The main loop: continues as long as there are tasks processing
// or the source has more items.
while (!sourceIsDone || processing.size > 0) {
// 5. Fill the processing pool up to the concurrency limit.
while (processing.size < concurrency && !sourceIsDone) {
const nextItemPromise = asyncIterator.next();
const processingPromise = nextItemPromise.then(item => {
if (item.done) {
sourceIsDone = true;
return; // Signal that this branch is done, no result to process.
}
// Execute the mapper function and ensure its result is a promise.
// This returns the final processed value.
return Promise.resolve(mapperFn(item.value));
});
// This is a crucial step for managing the pool.
// We create a wrapper promise that, when it resolves, gives us both
// the final result and a reference to itself, so we can remove it from the pool.
const trackedPromise = processingPromise.then(result => ({
result,
origin: trackedPromise
}));
processing.add(trackedPromise);
}
// 6. If the pool is empty, we must be done. Break the loop.
if (processing.size === 0) break;
// 7. Wait for ANY of the processing tasks to complete.
// Promise.race() is the key to achieving this.
const { result, origin } = await Promise.race(processing);
// 8. Remove the completed promise from the processing pool.
processing.delete(origin);
// 9. Yield the result, unless it's the 'undefined' from a 'done' signal.
// This pauses the generator until the consumer requests the next item.
if (result !== undefined) {
yield result;
}
}
}
Розбиття логіки
- Ініціалізація: Ми отримуємо асинхронний ітератор з джерела та ініціалізуємо `Set` під назвою `processing`, щоб він діяв як наш пул одночасності.
- Заповнення пулу: Внутрішній цикл `while` є двигуном. Він перевіряє, чи є місце в наборі `processing`, і чи має `source` ще елементи. Якщо так, він витягує наступний елемент.
- Виконання задачі: Для кожного елемента ми викликаємо `mapperFn`. Уся операція — отримання наступного елемента та його відображення — обертається в обіцянку (`processingPromise`).
- Відстеження обіцянок: Найскладніша частина — знати, яку обіцянку видалити з набору після `Promise.race()`. `Promise.race()` повертає виконане значення, а не сам об’єкт обіцянки. Щоб вирішити цю проблему, ми створюємо `trackedPromise`, яка переходить у стан виконаного з об’єктом, що містить як остаточний `result`, так і посилання на себе (`origin`). Ми додаємо цю обіцянку відстеження до нашого набору `processing`.
- Очікування на найшвидшу задачу: `await Promise.race(processing)` призупиняє виконання, доки не закінчиться перша задача в пулі. Це серце нашої моделі одночасності.
- Видача та поповнення: Коли задача закінчується, ми отримуємо її результат. Ми видаляємо відповідну `trackedPromise` з набору `processing`, що звільняє слот. Потім ми `yield` результат. Коли цикл споживача запитує наступний елемент, наш основний цикл `while` триває, і внутрішній цикл `while` спробує заповнити порожній слот новою задачею з джерела.
Це створює саморегулюючий конвеєр. Пул постійно вичерпується `Promise.race` і поповнюється з вихідного ітератора, підтримуючи стабільний стан одночасних операцій.
Використання нашого `parallelMap`
Повернемося до нашого прикладу з отриманням користувачів і застосуємо нашу нову утиліту.
// Assume 'createIdStream' is an async generator yielding 100 user IDs.
const userIdStream = createIdStream();
async function fetchAllUsersInParallel() {
console.time('ParallelFetch');
const profilesStream = parallelMap(userIdStream, fetchUserProfile, { concurrency: 10 });
for await (const profile of profilesStream) {
console.log(`Processed profile for user ${profile.id}`);
}
console.timeEnd('ParallelFetch');
}
// await fetchAllUsersInParallel();
З одночасністю 10 загальний час виконання тепер становитиме приблизно 2 секунди замість 20. Ми досягли 10-кратного покращення продуктивності, просто обернувши наш потік за допомогою `parallelMap`. Краса полягає в тому, що споживаючий код залишається простим, читабельним циклом `for await...of`.
Практичні випадки використання та глобальні приклади
Цей патерн призначений не лише для отримання даних користувачів. Це універсальний інструмент, який можна застосувати до широкого кола проблем, поширених у глобальній розробці додатків.
Високопродуктивні взаємодії API
Сценарій: Додаток для фінансових послуг потребує збагачення потоку даних транзакцій. Для кожної транзакції він повинен викликати два зовнішні API: один для виявлення шахрайства, а інший для конвертації валюти. Ці API мають обмеження швидкості 100 запитів на секунду.
Рішення: Використовуйте `parallelMap` із налаштуванням `concurrency` `20` або `30` для обробки потоку транзакцій. `mapperFn` здійснюватиме два виклики API за допомогою `Promise.all`. Обмеження одночасності гарантує високу пропускну здатність, не перевищуючи обмеження швидкості API, що є критично важливим для будь-якого додатка, який взаємодіє зі сторонніми сервісами.
Масштабна обробка даних та ETL (вилучення, перетворення, завантаження)
Сценарій: Платформа аналізу даних у середовищі Node.js потребує обробки CSV-файлу розміром 5 ГБ, що зберігається у хмарному кошику (наприклад, Amazon S3 або Google Cloud Storage). Кожен рядок потрібно перевірити, очистити та вставити в базу даних.
Рішення: Створіть асинхронний ітератор, який зчитує файл з потоку хмарного сховища рядок за рядком (наприклад, за допомогою `stream.Readable` у Node.js). Передайте цей ітератор до `parallelMap`. `mapperFn` виконуватиме логіку перевірки та операцію `INSERT` бази даних. `concurrency` можна налаштувати на основі розміру пулу з’єднань бази даних. Цей підхід дозволяє уникнути завантаження файлу розміром 5 ГБ у пам’ять і паралелізує повільну частину конвеєра вставлення бази даних.
Конвеєр перекодування зображень і відео
Сценарій: Глобальна платформа соціальних медіа дозволяє користувачам завантажувати відео. Кожне відео необхідно перекодувати в кілька роздільних здатностей (наприклад, 1080p, 720p, 480p). Це завдання, що вимагає великих обчислювальних ресурсів.
Рішення: Коли користувач завантажує пакет відео, створіть ітератор шляхів до файлів відео. `mapperFn` може бути асинхронною функцією, яка запускає дочірній процес для запуску інструменту командного рядка, як-от `ffmpeg`. `concurrency` слід встановити на кількість доступних ядер ЦП на машині (наприклад, `os.cpus().length` у Node.js), щоб максимізувати використання обладнання, не перевантажуючи систему.
Розширені концепції та міркування
Хоча наш `parallelMap` є потужним, реальні програми часто вимагають більшої деталізації.
Надійна обробка помилок
Що станеться, якщо один із викликів `mapperFn` буде відхилено? У нашій поточній реалізації `Promise.race` буде відхилено, що призведе до того, що весь генератор `parallelMap` видасть помилку та завершиться. Це стратегія «швидкого збою».
Часто потрібен більш стійкий конвеєр, який може пережити окремі збої. Цього можна досягти, обернувши свій `mapperFn`.
const resilientMapper = async (item) => {
try {
return { status: 'fulfilled', value: await originalMapper(item) };
} catch (error) {
console.error(`Failed to process item ${item.id}:`, error);
return { status: 'rejected', reason: error, item: item };
}
};
const resultsStream = parallelMap(source, resilientMapper, { concurrency: 10 });
for await (const result of resultsStream) {
if (result.status === 'fulfilled') {
// process successful value
} else {
// handle or log the failure
}
}
Збереження порядку
Наш `parallelMap` видає результати не по порядку, надаючи пріоритет швидкості. Іноді порядок виводу повинен відповідати порядку введення. Це вимагає іншої, складнішої реалізації, яку часто називають `parallelOrderedMap`.
Загальна стратегія для впорядкованої версії:
- Обробляйте елементи паралельно, як і раніше.
- Замість того, щоб одразу видавати результати, зберігайте їх у буфері або карті, позначені їх вихідним індексом.
- Підтримуйте лічильник для наступного очікуваного індексу, який потрібно видати.
- У циклі перевірте, чи доступний результат для поточного очікуваного індексу в буфері. Якщо так, видайте його, збільште лічильник і повторіть. Якщо ні, зачекайте, поки завершиться більше задач.
Це додає накладні витрати та використання пам’яті для буфера, але необхідно для робочих процесів, залежних від порядку.
Пояснення зворотного тиску
Варто повторити одну з найелегантніших особливостей цього підходу на основі асинхронного генератора: автоматичну обробку зворотного тиску. Якщо код, який використовує наш `parallelMap`, повільний — наприклад, записує кожен результат на повільний диск або перевантажений мережевий сокет — цикл `for await...of` не запитуватиме наступний елемент. Це призводить до того, що наш генератор призупиняється на рядку `yield result;`. Під час призупинення він не циклічно перебирає, не викликає `Promise.race` і, що найважливіше, не заповнює пул обробки. Ця відсутність попиту поширюється назад до вихідного ітератора, який не зчитується. Увесь конвеєр автоматично сповільнюється, щоб відповідати швидкості свого найповільнішого компонента, запобігаючи вибухам пам’яті від надмірного буферування.
Висновок і перспективи на майбутнє
Ми пройшли шлях від основних концепцій ітераторів JavaScript до створення складного, високоефективного інструменту паралельної обробки. Перейшовши від послідовних циклів `for await...of` до керованої моделі одночасності, ми продемонстрували, як досягти значного покращення продуктивності для задач з інтенсивною обробкою даних, обмежених вводом-виводом і ЦП.
Основні висновки:
- Послідовність – це повільно: Традиційні асинхронні цикли є вузьким місцем для незалежних задач.
- Одночасність є ключем: Паралельна обробка елементів значно скорочує загальний час виконання.
- Асинхронні генератори є ідеальним інструментом: Вони забезпечують чисту абстракцію для створення власних ітерованих об’єктів із вбудованою підтримкою важливих функцій, таких як зворотний тиск.
- Контроль є важливим: Керований пул одночасності запобігає виснаженню ресурсів і поважає обмеження зовнішньої системи.
Оскільки екосистема JavaScript продовжує розвиватися, пропозиція щодо помічників ітераторів, ймовірно, стане стандартною частиною мови, забезпечуючи міцну, власну основу для маніпулювання потоками. Однак логіка паралелізації — керування пулом обіцянок за допомогою такого інструменту, як `Promise.race` — залишиться потужним, вищим рівнем патерном, який розробники можуть реалізувати для вирішення конкретних проблем продуктивності.
Я закликаю вас взяти функцію `parallelMap`, яку ми створили сьогодні, і поекспериментувати з нею у своїх власних проектах. Визначте свої вузькі місця, будь то виклики API, операції з базою даних чи обробка файлів, і подивіться, як цей патерн одночасного керування потоками може зробити ваші програми швидшими, ефективнішими та готовими до вимог світу, керованого даними.