Управляемые корутины в TypeScript: как получить контроль над асинхронностью и памятью. Разбираемся во всех аспектах

от автора

Адаптивные корутины на Typescript

Адаптивные корутины на Typescript

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

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

В этой статье мы разберём, как кооперативные корутины (Coroutines) на генераторах решают эти задачи, и научимся на примере моей библиотеки ts-adaptive-coroutines, разобрав каждый аспект по кирпичикам, посмотрев на инструмент, который добавляет в TypeScript адаптивные приоритеты, арены памяти, каналы, семафоры и много-поточность.

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

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


Почему async/await — не всегда достаточно?

Наглядная иллюстрация async / await на сложных системах

Наглядная иллюстрация async / await на сложных системах

async/await — это синтаксический сахар над Promise. Он позволяет писать последовательный код, но:

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

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

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

  • Блокировка потока: если задача выполняет длинные вычисления без await, она полностью блокирует event loop, не давая выполняться другим задачам.

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


Пару слов про корутины

Что такое корутины?

Отличие корутин от обычных функций

Отличие корутин от обычных функций

Корутина (Coroutine) — это функция, которая может приостанавливать своё выполнение в определённых точках при помощи yield и передавать управление другому коду (планировщику). В отличие от async / await, где приостановка происходит только на асинхронных операциях (Promise), корутина может уступать процессор в любой момент, даже если выполняет синхронные вычисления.

Генератор — это функция, которая может возвращать несколько значений с помощью yield. Внутри моей библиотеки каждая корутина представлена генератором. Когда генератор вызывает yield с эффектом, он приостанавливается и возвращает этот эффект планировщику.

function* example() {  const a = yield 1;  console.log(a);     // будет передано при следующем next()  const b = yield 2;  return a + b;}

Библиотека ts-adaptive-coroutines, на примерах которой мы будем разбираться в аспектах работы с корутинами, использует генераторы как основу для построения управляемых корутин. А планировщик хранит множество генераторов и по очереди запускает их, позволяя им кооперативно делить процессорное время.

Почему стоит отдать выбор в пользу корутин?

Выбирая генераторы вместо чистого async/await, вы получаете полный контроль над точками переключенияasync/await автоматически приостанавливается только на await, и мы не можем вмешаться в этот процесс. Генераторы же позволяют явно указать, когда корутина готова уступить управление, и возвращать специальные эффекты — инструкции для планировщика.

Эффекты — это простые объекты, описывающие, что корутина хочет сделать: подождать, запустить другую корутину, отменить что-то и т.д. Такой декларативный подход отделяет намерение от реализации. Планировщик может интерпретировать эффекты по-разному в зависимости от контекста, что даёт гибкость (например, адаптивные приоритеты).

Такой дизайн уже был проверен в таких библиотеках, как redux-saga и effection, но мы добавляем уникальные возможности для тех, кто хочет полного контроля: арены памяти для снижения нагрузки на GC и поддержку много-поточности.


Архитектура библиотеки. Всё что нужно для эффективной работы с корутинами

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

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

  • Scheduler — центральный компонент, управляющий очередью корутин, приоритетами, таймерами и выполнением.

  • Coroutine — обёртка над генератором с состоянием, Promise для внешнего ожидания и методами отмены.

  • Effect — система декларативных инструкций для корутин, включающих в себя yieldsleepforkallracecallawaitPromiseyieldEverysetPriority.

  • Arena менеджер памяти для временных данных.

  • Pool — пул объектов для переиспользования.

  • Channel и Semaphore — примитивы синхронизации.

  • DistributedScheduler  — для планировщик для многопоточных вычислений на базе Worker-ов.

Пошаговое описание работы:

  1. Создание планировщика. Через createScheduler(options) создаётся экземпляр Scheduler. Он настраивает приоритеты, арену, пулы и очереди.

  2. Порождение корутины. Вызов scheduler.spawn(factory, options) создаёт новый объект Coroutine, оборачивающий генератор, и помещает его в очередь готовых.

  3. Цикл планировщика. Планировщик в цикле выбирает корутину с наивысшим эффективным приоритетом, выполняет её до следующего yield (или до завершения/приостановки), затем повторяет.

  4. Обработка эффектов. Если корутина вернула эффект, планировщик интерпретирует его: ставит в очередь таймера, запускает дочерние корутины, ожидает Promise и т.д.

  5. Завершение. Когда генератор завершается done: true, корутина помечается как Completed, её Promise резолвится, а ресурсы (включая участок арены) освобождаются.

Таким образом, весь процесс предсказуем и управляем, а мы можем двигаться дальше и рассматривать каждый аспект работы в деталях.


Эффекты: декларативное управление

Обычная корутина (генератор) может вызывать Promise, setTimeout, fetch напрямую, но тогда планировщик не сможет управлять этими операциями, не узнает о приостановках, не сможет отменить ожидание, ограничить параллелизм или изменить стратегию выполнения. Если же корутина возвращает через yield объект, описывающий что нужно сделать (например, «подожди 100мс» или «запусти другую корутину»), планировщик получает полный контроль.

Адаптивность заключается в том, что планировщик может динамически менять поведение: например, при высокой нагрузке игнорировать yield (продолжать выполнение), при низкой — реально уступать; или выбирать разные стратегии для fork в зависимости от доступных ресурсов.

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

Основные эффекты

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

function* longLoop() {  for (let i = 0; i < 1_000_000; i++) {    // тяжёлая работа, приостанавливается, чтобы дать доступ другим корутинам    if (i % 1000 === 0) yield yieldMain();  }}

sleep(ms), приостановить корутину на указанное время.

yield sleep(500);

fork(factory) запустит дочернюю корутину. Возвращает её handle (объект с id, promise, cancel, setPriority).

const child = yield fork(() => worker());console.log('Дочерняя корутина id:', child.id);

cancel(handleId?) отменит корутину по id (если не указан, то текущую). Отмена вызывает return() генератора, позволяя выполнить блоки finally.

yield cancel(child.id);

all(factories) — запустит несколько корутин параллельно и дождётся их все. Возвращает массив результатов.

const results = yield all([() => task1(), () => task2()]);

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

const first = yield race([() => timeout(5000), () => fetchData()]);

call(fn) — чтобы вызвать функцию, которая может возвращать Promise или простое значение. Аналог await, но контролируемый планировщиком.

const data = yield call(() => fetch('/api').then(r => r.json()));

awaitPromise(promise) будет ожидать готовый Promise.

yield awaitPromise(somePromise);

yieldEvery(n, counter?) — чтобы уступать каждые n вызовов. Полезно в циклах, где не нужно уступать на каждой итерации.

const counter = { count: 0 };for (const item of items) {  process(item);  yield yieldEvery(100, counter);}

setPriority(p) — изменить базовый приоритет текущей корутины.

yield setPriority(10);

Как планировщик обрабатывает эффекты

Когда корутина возвращает эффект, планировщик смотрит на его тип и выполняет соответствующее действие:

  • yield: поместить корутину в конец очереди.

  • sleep: добавить в кучу спящих с таймаутом для пробуждения.

  • fork: создать новую корутину и вернуть её объект.

  • call / awaitPromise: подписаться на Promise и возобновить корутину при его выполнении.

  • all / race: запустить несколько корутин и координировать их.

  • cancel: найти корутину и вызвать её doCancel().

  • yieldEvery: проверить счётчик и либо уступить, либо продолжить работу.

  • setPriority: обновить приоритет и продолжить выполнение корутины.

Эффекты делают код декларативным и легко тестируемым, что даёт возможность, например мокать эффекты или проверять их последовательность.


Планировщик и приоритеты

По своей сути - планировщик нужен для того, чтобы правильно управлять процессом выполнения маленьких программ в рамках одно или нескольких потоков

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

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

Почему без планировщика не обойтись?

Генераторы сами по себе не выполняются — их нужно постоянно вызывать через next(). Можно написать простой цикл, который перебирает генераторы, но тогда:

  • Нет приоритетов: все корутины будут выполняться в порядке FIFO.

  • Нет таймеров: нужно вручную управлять setTimeout и очередями.

  • Нет отмены: сложно корректно остановить генератор и обработать finally.

  • Нет квантования времени: одна корутина может занять поток надолго.

  • Нет нормального управления памятью.

В нашем случае, планировщик содержит:

  • Кучу для готовых корутин BinaryHeap, упорядоченную по эффективному приоритету и времени постановки.

  • Кучу спящих корутин sleepHeap, упорядоченную по времени, когда они будут пробуждены.

  • Map активных корутин activeMap, для быстрого поиска по id.

  • Множество пауз paused для корутин, приостановленных вручную.

  • Арена для временных данных.

  • Трассировщик для сбора метрик.

Основной цикл планировщика выглядит следующим образом:

  1. Обработать проснувшиеся корутины.

  2. Пересчитать приоритеты (если прошёл интервал старения).

  3. Взять корутину с наивысшим эффективным приоритетом.

  4. Выполнить её до следующего yield или завершения.

  5. Если нечего выполнять, но есть спящие корутины, то подождать до ближайшего пробуждения (с ограничением).

  6. Повторить.

О приоритетах и предотвращении голодания

Каждая корутина в библиотеке имеет базовый приоритет (число). По умолчанию он задаётся при создании, но может быть изменён через эффект setPriority.

Однако простое сравнение базовых приоритетов привело бы к голоданию низко-приоритетных задач. Поэтому у нас используется адаптивная стратегия: эффективный приоритет = базовый + бонус, зависящий от времени ожидания. Формула:

boost = boostMax * (1 - exp(-lambda * waitMs))effective = base + boost

Чем дольше корутина ждёт, тем выше её эффективный приоритет, и в какой-то момент она обгонит высоко-приоритетные задачи. Параметры (lambdaboostMax, интервал пересчёта) настраиваются через опции планировщика.

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


Управление памятью: арены и пулы

Арены и пулы - важная часть работы с памятью, при большом количестве вычислений

Арены и пулы — важная часть работы с памятью, при большом количестве вычислений

В JavaScript память управляется автоматически, но это имеет цену. Когда создаётся много короткоживущих объектов (например, при каждом yield или await), сборщик мусора вынужден часто запускаться, что может вызывать заметные паузы.

Для решения таких задач на сцену выходят Арены и Пулы. Я также реализовал их в библиотеке, чтобы вам не пришлось заниматься этим вручную. Давайте рассмотрим

Арены

Арена это заранее выделенный блок памяти ArrayBuffer, из которого можно вручную выделять участки под временные данные. Все выделения происходят последовательно, а освобождение — сбросом указателя на начало. Это невероятно быстро и не создаёт мусор.

Пример работы арены:

const arena = new Arena(1024 * 1024);   // Создается арена с 1 МБconst offset = arena.allocAligned(256); // Выделить 256 байт с выравниваниемconst view = arena.view(offset, 256);   // Можно создать view внутри ареныview.setFloat64(0, 3.14);               // Или же задать значение// ...arena.reset(0);                         // мгновенно освободить всё

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

Для повышения производительности я также создал WasmArena, которая использует WebAssembly для аллокаций. При необходимости поддержки много-поточности можно использовать арену на SharedArrayBuffer с атомарными операциями.

Пулы объектов

Помимо арен, библиотека предоставляет Pool для переиспользования объектов. Это полезно для часто создаваемых экземпляров, например, самих корутин или кадров стека.

Пример работы пула:

// Создание пулаconst pool = new Pool<MyObject>({  create: () => new MyObject(),  reset: (obj) => obj.reset()});// Выделение объекта из пулаconst obj = pool.acquire();// ... Здесь мы используем егоpool.release(obj); // И сбрасываем для повторного использования

Пулы уменьшают количество аллокаций и снижают нагрузку на GC.

Альтернативы: можно вообще не управлять памятью и полагаться на сборщик мусора. Для небольших приложений это нормально, но при высоких нагрузках паузы GC могут стать проблемой.

Как это интегрировано в библиотеке?

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


Каналы и семафоры

Канал — это способ обмена сообщениями между корутинами. Они поддерживают асинхронное ожидание, поэтому производитель может ждать, пока потребитель заберёт элемент (или наоборот).

Каналы особенно полезны для организации конвейеров и обработки потоков данных.

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

Пример работы канала:

// Создаем канал данныхconst channel = new Channel<number>(fixedBuffer(10));// Производитель задает число 42 в очередиawait channel.put(42);// Потребитель забирает число 42 из очередиconst value = await channel.take(); // 42

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

  1. fixedBuffer(capacity) — классическая очередь: при переполнении put блокируется.

  2. slidingBuffer(capacity) — при переполнении вытесняются старые элементы.

  3. droppingBuffer(capacity) — при переполнении новые элементы отбрасываются.

Семафоры же ограничивают количество одновременно выполняющихся корутин. Например, мы не хотим делать более 10 одновременных запросов к внешнему API. Семафор позволяет легко это реализовать, и остальные корутины будут ждать своей очереди.

Пример работы семафора:

// Создаем числовой семафорconst sem = new Semaphore(3);yield call(() => sem.acquire());try {  // только 3 корутины могут быть здесь одновременно} finally {  sem.release();}

Альтернативы: можно использовать обычные массивы и setTimeout, но тогда придётся вручную управлять очередями ожидания и уведомлениями, что громоздко и подвержено ошибкам. Другие библиотеки (async.js, p-limit) предоставляют подобные примитивы, но они не интегрированы с корутинами и приоритетами.


Многопоточность с воркерами

Много-поточность нужна для разделения задач на несколько ядер процессора.

Много-поточность нужна для разделения задач на несколько ядер процессора.

JavaScript в браузере и Node.js по умолчанию одно-поточный. Это означает, что все корутины (и асинхронные операции) выполняются в одном потоке, и CPU-интенсивные задачи могут блокировать event loop. Для использования нескольких ядер процессора необходимо задействовать воркеры (Web Workers или Node.js Worker Threads).

Многопоточность позволяет:

  • Выполнять тяжёлые вычисления параллельно, не блокируя UI или обработку запросов.

  • Изолировать выполнение корутин в отдельных потоках (например, для безопасности или стабильности).

  • Распределять нагрузку между ядрами.

Корутины особенно хороши, когда нужно работать с много-поточностью. Для использования нескольких ядер процессора, моя библиотека предоставляет DistributedScheduler. Он может работать в двух режимах:

  • Несколько локальных планировщиков в одном потоке — имитация параллелизма (для изоляции задач).

  • Настоящие воркеры (Web Worker или Node.js Worker) — каждый со своим планировщиком.

Для безопасной передачи задач в воркеры используется реестр фабрик. Никакого eval — только предварительно зарегистрированные функции.

// Безопасно регистрируем наш методregisterFactory('fetchData', (url) => function* () { /* ... */ });// Запускаем через воркерыconst distSched = new DistributedScheduler({ useWorkers: true, size: 4 });const handle = distSched.spawnOnWorkerByName('fetchData', ['https://api.example.com']);const result = await handle.promise;

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


Про React и корутины

React предоставляет хуки для управления состоянием useState и побочными эффектами useEffect. Для асинхронных операций обычно используют useEffect с async функцией, но это порождает проблемы:

  • Отмена при размонтировании — нужно вручную использовать AbortController и проверять флаг.

  • Пауза/возобновление — сложно реализовать (например, при сворачивании вкладки).

  • Приоритеты — невозможно задать, какая задача важнее.

  • Координация нескольких корутин — нет встроенных примитивов (например, allrace).

  • Утечки памяти — при частых запусках / остановках могут накапливаться таймеры и замыкания.

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

В качестве интересного, я включил пример интеграции корутин в React, в состав библиотеки (опционально). Они позволяют управлять корутинами внутри компонентов, автоматически привязывая их жизненный цикл к жизненному циклу компонента.

Пример базового компонента в общем контексте корутин:

import { useCoroutine, SchedulerProvider } from 'ts-adaptive-coroutines/react';// Пример компонента с использованием корутинfunction MyComponent() {  const { status, result, start, cancel } = useCoroutine(    () => function* () {      yield sleep(1000);      return 'Привет';    },    { autoStart: true }  );  return <div>{status}: {result}</div>;}

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

function PollingComponent() {  // Создаем корутину  const { status, start, pause, resume } = useCoroutine(    () => function* () {      while (true) {        const data = yield call(() => fetch('/api/status').then(r => r.json()));        setData(data);        yield sleep(5000);      }    },    { autoStart: true }  );  useEffect(() => {    const onVisibility = () => {      if (document.hidden) pause(); else resume();    };    document.addEventListener('visibilitychange', onVisibility);    return () => document.removeEventListener('visibilitychange', onVisibility);  }, [pause, resume]);  // ...}

Пример отмены запроса при уходе со страницы:

function DataLoader() {  // Создаем корутину  const { result, error, cancel } = useCoroutine(    () => function* () {      const controller = new AbortController();      yield fork(() => function* () {        yield call(() => fetch('/data', { signal: controller.signal }));        controller.abort();      }());      // ...    },    { autoStart: true }  );  // При размонтировании корутина будет отменена автоматически, вызвав abort  return <div>{result}</div>;}

Таким образом, корутины делают асинхронный код в React более управляемым и предсказуемым, чем простой async/await.


Сравнение с другими подходами и библиотеками

Ну и напоследок, давайте рассмотрим уже существующие решения и сравним с библиотекой ts-adaptive-coroutines из коробки:

Возможность

ts-adaptive-coroutines

async/await

redux-saga

RxJS

Приоритеты

✅ адаптивные

Частично

Кооперативное переключение

Управление памятью

✅ арены, пулы

Многопоточность

Отмена

✅ встроенная

Частично

Сложность освоения

Средняя

Ошибочно кажется низкой

Средняя

Высокая

Таким образом, ts-adaptive-coroutines уникальна сочетанием контроля над выполнением, памятью и многопоточностью, что делает её мощным инструментом для требовательных приложений.

Базовые бенчмарки можно посмотреть в репозитории.


Заключение

Сегодня мы рассмотрели, как работает много-поточность и корутины в TypeScript здорового человека, разобрав на примере библиотеки ts-adaptive-coroutines, открывающей полный контроль над асинхронным кодом. Благодаря продуманной архитектуре, сочетающей генераторы, эффекты, адаптивные приоритеты, арены памяти и поддержку многопоточности, она позволяет создавать предсказуемые, производительные и легко отлаживаемые системы.

Типичные сценарии использования:

  • Серверные приложения: приоритизация запросов (VIP-клиенты обслуживаются быстрее), ограничение параллелизма к базе данных, конвейеры обработки данных.

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

  • Интерактивные интерфейсы: фоновые задачи (поллинг, автосохранение), анимации с паузами, отмена операций при изменении состояния.

  • Обработка потоков данных: каналы позволяют строить сложные конвейеры с backpressure, не создавая лавину таймеров.

Мы рассмотрели все ключевые аспекты: от базовых концепций до интеграции с React и воркерами. Теперь вы готовы применять эту библиотеку в своих проектах — будь то высоко-нагруженный сервер, игра или сложный интерфейс.

Код на GitHub | Библиотека на NPM

Буду рад вашим комментариям и вопросам.

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