Обновить

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

Уровень сложностиСложный
Время на прочтение16 мин
Охват и читатели8.1K
Всего голосов 4: ↑4 и ↓0+8
Комментарии17

Комментарии 17

Есть пару вопросов автору:

1. В статье указано, что арена полезна, чтобы избежать создания множества мелких Uint8Array при сериализации в бинарный формат. Тут сразу вопрос зачем нужно вообще этим заниматься. Но даже если это и нужно не будет ли в этом случае проще и эффективнее использовать стандартный пул переиспользуемых TypedArray? Ручное управление смещениями (offsets) в ArrayBuffer усложняет код, а выигрыш по сравнению с переиспользованием одного Float64Array/Uint8Array из пула кажется сомнительным.

2. Вы упомянули, что p-limit не интегрирован с корутинами и приоритетами. Это справедливо, но для 90% сценариев ограничения параллелизма (например, лимит запросов к API) приоритеты не нужны. Не получается ли, что для типовых задач ваше решение является избыточным по сравнению с тем же p-limit, который работает с обычными async/await без перехода на тяжелые генераторы?

3. В статье упоминается возможность использования SharedArrayBuffer и воркеров, но не раскрыта мотивация. Какие конкретные кейсы оправдывают использование, если для большинства задач "тяжёлых вычислений" достаточно обычного пула Web Workers с явной передачей сообщений через postMessage?

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

1) Про пулы и TypedArray, покажу на примере игровых серверов.

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

  • Принять и разобрать тысячи различных пакетов.

  • Для каждого пакета выделить временные структуры: координаты игрока, векторы движения, состояния анимации.

  • Сформировать и отправить обновления всем игрокам в зоне видимости.

Если для каждого пакета создавать новый DataView или Uint8Array, за секунду набегают миллионы мелких аллокаций, здесь у нас будут фризы из-за сборщика мусора. Если же мы возьмем пул TypedArray - это поможет лишь частично, потому что часто пакеты передаются не фиксированной длинны и если нужны буферы побольше - то либо придется держать пулы разных размеров, усложняя код, либо выделять память, а в контексте онлайн-игр - это не есть хорошо.

Арена решает этот кейс:

  • Выделили блок на 1 КБ из арены, записали данные, отправили, и забыли.

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

  • Можно выделить переменный размер без ограничений пула.

TypedArray в общем хорош для простых однотипных данных, но если нужны переменные размеры, множество разных данных временных, то он окажется не удобным.

2) Про p-limit - в большинстве типовых задач да, хватит и его, рассмотрим пару кейсов, где с ним будет сложно:

  • Пользователь листает историю чата, и ему прилетает новое сообщение - если использовать голый p-limit, то оно останется в очереди внизу и дойдет только после всех await от получения истории (нет приоритета).

  • В онлайн играх через один сокет летит огромное количество разноплановых данных - это может быть аналитика, передвижение игроков, рассчет попаданий, статус игрового мира, и т.д. Без приоритезации - игроки вместо получения например данных о своём перемещении, будут получать аналитику вперед, что абсолютно не нужно.

  • Пользователь покинул чат, а сервер продолжает держать отложенные сообщения для рассылки для других пиров - тут помогают корутины с отменами всего древа. Корутины могут быть вложенные и отмена одной - отменяет всё древо, а при классическом async / await - древо придется отменять вручную каждый элемент, что не удобно.

Короче говоря, в простых приложениях с простой логикой - p-limit хорош для простого ограничения параллелизма, но когда появляются требования к приоритетам, отмене и кооперативности, корутины становятся более подходящим инструментом.

3) SharedArrayBuffer нужен для того, чтобы не передавать состояния из воркера в воркер, когда нужно разным задачам работать с одними данными. Для наглядности приведу примеров:

  • Обработка видео в реальном времени (например, на лайв стримах). Два воркера могут работать с одним и тем же видео-кадром, не гоняя между собой данные, например - один воркер накладывает какой-то фильтр, другой анализирует в кадре жесты, чтобы запустить "emoji" в чат.

  • Разделяемая память для игрового состояния (позиции объектов, физика), чтобы воркеры могли параллельно обновлять разные части мира, не пересылая данные туда-сюда.

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

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

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

Без обид, но выглядит так, что вы забиваете микроскопом гвозди. Делать онлайн-игру на JS-рантайме, кажется как раз тот случай, когда и так приходится бороться с массой ограничений среды. Накидывать сверху свой кастомный рантайм на генераторах, который далеко не zero-cost, в таких условиях только усугубляет проблему, а не решает её.

Ну про микроскоп и гвозди, в некоторых случаях это так, где например, async/await работают на ура, но есть случаи, где нужна приоритезация, удобная отмена всей цепочки вызовов, без блокировки event-loop-а, особенно на realtime с частыми вызовами тиков, где нужно уметь уступать для более важных вызовов.

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

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

Если же мы берем игры как пример, то такой подход помогает:

1) Убирать утечки памяти в древовидных запросах (например, игрок вышел из игры, но до этого был запущен запрос, внутри которого была пачка других обработок, все их нужно как-то нормально отменить, не спровоцировав сборки мусора, без падений сокетов).

2) Решать вопросы голодания, когда мы кучой игроков разом начинаем выполнять действие (начался новый раунд, нужно всем допустим обновить статус игроков), в случае с async await, они все грохнуться в эвент луп, обработка следующего тика задержится, и все получат зависание.

3) async await также не бесплатный, как и генераторы, при множестве маленьких вычислений в реалтайме, поток будет останавливаться на убийство всех Promise.

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

Кто не понял - это его библиотека.

Спасибо, кэп 😁

Одну статью открываешь, там: я, я, я через слово, вторую: моя, моей и так далее по падежам - где суть? - да покороче. А то одни местоимения и запах комплексов.

\s Все верно, нужно вообще ничего не писать, чтобы всякие местоимения глаза не мозолили, правда @historydev?

Можно не писать, а можно утрировать критику не в свой адрес.

Вам не кажется, что человек, который проделал титанический труд и выложил его в открытый доступ, имеет право говорить, что это его работа?

В каждом третьем предложении? - нет.

Мне кажется, статья о том, как придумать проблему там где её нет и героически решить, добавив кучу абстракций. Кооперативная многозадачность, например, нужна тогда когда у нас один вычислительный ресурс и куча задач. Но для серверов это категорически неправда - у нас куча ядер процессора и мы можем раскидывать вычисления как нам удобно по ним. Кооперативная многозадачность тут превращается в трудно отлаживаемый оверхед, увеличивая среднее время ответа на запрос без веской причины, потому что выполнение вычислений прерывается на что-то другое.

В 90% случаев, вы правы. В комментарии выше, где были вопросы, я расписал кейсы, где эти проблемы имеются.

Моя идея остаётся: я считаю что ваши прикладные проблемы можно и нужно было решать с учётом архитектуры JS runtime и видимо, глубоко изменив подход, но не пытаться заставить рантайм эмулировать то для чего он не проектировался. Я не знаю деталей вашей прикладной проблемы, но уже сталкивался с подобным наслоением абстракций - каждый раз оказывалось, что абстракции появлялись либо вследствие желания применить простаивающие знания, или из-за того что человек пытается подстроить экосистему под что-то ему уже привычное вместо того чтобы проектировать под то с чем работает. Независимо от случая я почти уверен что существуют более простые и надёжные решения.

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

1) Даже в многоядерных серверах каждый отдельный Node.js-процесс, это однопоточная среда, где одновременно выполняется только одна задача. async/await базово реализует кооперативную многозадачность, но проблема в том, что он уступает только Promise, без возможности остановки в произвольных точках.

2) Рантайм не эмулирует того, для чего не проектировался - генераторы поддерживаются рантаймом, специально созданы для реализации, например, итераторов. Мы лишь поверх ставим понятное управление над всем этим.

Примерами того, где используются такие подходы:

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

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

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

Отчасти вы меня убедили, но тогда встаёт другой вопрос - зачем брать для проблемы среду, которая заведомо плохо подходит? Подобные вещи как-то логичнее писать на плюсах или расте, не рискуя получить фризы из-за GC

Безусловно, если нужна максимальная производительность - тут выступает Rust или плюсы, однако это уже другой вопрос, вопрос выбора самого языка для сервера, допустим. Только в реальности, выбор языка не сводиться только к его производительности, иначе всё было бы написано на Rust / C++ поголовно. Я бы и сам мечтал о таком, но веб-серверы, чаты, браузерные игры, инструменты автоматизации, часто пишутся на других языках. Это дешевле, проще и быстрее. И это дает отстрелы в колени, которые нужно как-то нивелировать. Тут же еще решают сроки, бюджеты, сложность поиска разработчиков и т.д.

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации