Многопоточность на фронте: абсурд или прекрасное архитектурное решение? +24


Веб-сайты уже давно перестали напоминать простые листы с текстовой информацией. Сейчас это полнофункциональные приложения, порой с очень сложной и тяжелой логикой. А чем больше логики и чем сложнее она становится, тем сильнее сайт начинает замедляться, что, естественно, не нравится пользователям. Сейчас это решают разными способами, например с помощью ленивой подгрузки, а более рисковые пробуют применять микрофронты или виджетные системы. Однако есть еще один вектор, на который пока никто не обращает внимания: использование многопоточности в браузере.

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

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

Многопоточность в браузере

Для начала давайте разберемся, какие у нас есть возможности параллелить работу в браузере. С точки зрения операционной системы, любой из ниженазванных воркеров — это полноценный системный поток, и в нем есть свой отдельный eventloop, изолированный глобальный объект и прочие радости. То есть ресурсные мощности воркеров почти никак не связаны с ресурсами основного потока. И это, несомненно, плюс, особенно если сравнивать языки, где используется GIL. 

Однако минусы тоже есть. Из-за того что каждый поток такой обособленный и изолированный, есть дополнительные расходы оперативной памяти, нет шаринга между потоками данных и почти все выполняется через копирование (исключение transferable objects).

Обзор dedicated worker

Начнем с обычного dedicated воркера, который еще иногда называют веб-воркером. Это типичный представитель потоков. Даже в сыром виде работать с этим типом воркеров очень просто. Схему общего механизма можно посмотреть на картинке.

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

Для работы с веб-воркером написано очень много библиотек и обучающего материала. Одна из самых интересных библиотек, на мой взгляд, это Comlink. Если вкратце, либа позволяет работать с кодом в воркере, будто он в основном потоке. Там есть немного магии прокси, ну и промисы, конечно.

Обзор shared worker

Следующий представитель семейства «потокообразных» — shared воркер.

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

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

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

Обзор service worker

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

Он похож на shared-воркер, но живет как бы бесконечно, в том числе и когда нет ни одной открытой вкладки домена. Когда поток очень долго живет, браузеру это не нравится, поэтому браузер примерно раз в минуту пытается «усыпить» service-воркер, ибо нечего ему вхолостую работать.

Из интересного: через service-воркер реализуется поддержка пуш-уведомлений в современном вебе.

Чуть ближе к реальности 

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

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

Самые очевидные примеры — это Bitbucket, GitHub, Gmail, ВК, и у всех них есть определенные проблемы, когда показатель CPU достаточно высок. А ведь это вполне себе хорошие примеры тяжелых приложений с качественной оптимизацией.

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

Воркеры + фреймворк = «мощь, которая и не снилась моему отцу»

Посмотрел я на все это, и ко мне пришла мысль: что, если объединить фреймворки и воркеры? Впервые я задумался об этом еще в начале 2018 года. Тогда я работал на одном проекте с Angular, и меня не покидала идея вынести всю работу с API в воркер (для этого есть отдельный модуль), чтобы облегчить нормализацию данных. Небольшое исследование показало, что трудоемкость этого действия слишком велика, а количество подводных камней не прогнозируемо. Поэтому эту идею я тогда отложил до лучших времен. 

Года через два, в рамках пет-проекта, я делал минималистичный игровой фреймворк, в котором вся логика будет крутиться в воркере, а в основной поток будет прокидываться видеопоток (если что, я про Offscreen Canvas). Эта идея мне очень понравилась тогда, потому что я нигде не видел аналогов. Но как это иногда бывает с пет-проектами, мотивация обратно пропорциональна количеству и сложности кода в проекте.   

Еще через некоторое время я открыл для себя фреймворк Neo.mjs, — его пишет один человек, и он весь основан на идее раскидывания логики по разным воркерам. В его репозитории есть вот такая картинка, описывающая архитектуру:

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

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

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

Но у проекта есть большая проблема — вокруг него не образовалось комьюнити. Я думаю, потому, что это абсолютно новый фреймворк, не похожий на популярные React, Angular, Vue. Ну и еще мало средств потрачено на рекламу. Но в любом случае я надеюсь, что этот фреймворк вдохнет новую жизнь в веб-разработку, как это было со Svelte или Snowpack в свое время.

Архитектура фронтенд-приложений будущего

Идея автора Neo.mjs очень интересная, но я решил доработать архитектуру этого фреймворка, чтобы получить пример того, как она потенциально может выглядеть во фронтенд-приложениях. 

Моя версия фреймворка будущего выглядит так:

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

Это виртуальное дерево ничем особо не отличается от концепции обычного виртуального дома, за исключением того, что передает данные в DOM через постмесседж в основной поток. А еще может обрабатывать логику событий пользователя и вызывать какие-либо действия из части бизнес-логики.

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

Самое интересное, что все запросы в приложении могут идти на специальный локальный URL, потому что тут в игру вступает service-воркер. За счет его особенности перехватывать запросы мы с легкостью можем реализовать в нем адаптер связи с реальным бэком, при этом хранить конфиги приложения, подключать сериализацию и нормализацию данных, подключать интерцепторы и хранить авторизацию (например, OAuth-токен).

Только представьте, как в каком-либо санке или саге мы просто говорим «дай нам такие данные», а все хедеры, метаданные и конкретный URL для запроса подставит service-воркер. При этом волшебная способность service-воркера кешировать данные никуда не исчезает, и в зависимости от политики он может отдавать нам данные практически мгновенно. Особенно если мы открываем сайт после того, как закрыли все вкладки (например, после перезапуска браузера или компьютера).

Но это еще не все преимущества!

Дело в том, что логика в shared-воркере и service-воркере по умолчанию более безопасна, потому что скриптами на странице туда не добраться, а чтобы настроить что-то руками, нужно как минимум знать, как это сделать.

Следовательно, можно не бояться, что кто-то украдет токен авторизации или какие-то другие данные через XSS. К слову, все запросы, происходящие в воркере, также не видны с девтулз основного потока.

А еще это решение прекрасно сочетается с SSR и микрофронтами, где shared-воркер может быть как раз той шиной данных, а service-воркер — кешировать компоненты и виджеты, а виджеты можно будет не бандлить, особенно если использовать HTTP 2 или 3.

А может, мы уже в будущем?

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

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

Такой механизм реализован в GitHub. И тут все дело в том, что обычно пользователи в одном браузере открывают больше, чем одну вкладку. А каждая новая вкладка — это новое вебсокет-подключение, и, как следствие, сервак страдает от излишних нагрузок, и браузер пользователя тоже перерабатывает. 

В данном случае shared-worker помогает нам свести количество подключений до 1.

Демо + код 

Но это все теория и потенциальные возможности.

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

В итоге у меня получилось вынести состояние приложения в shared-воркер, при этом подружить его с реактивностью Vue, — таким образом, разработчик может и не знать, что вся основная логика выполнятся не в основном потоке.

А чтобы было проще для понимания, я оформил это как плагин для Vue, а по дизайну ориентировался на набирающую популярность библиотеку Pinia.

Disclaimer: это демо-вариант плагина, естественно, там есть искусственные ограничения и места, которые нужно будет еще хорошенько тестировать (как минимум нужны unit-тесты). Если вас заинтересует этот код, можете переходить на Stackblitz или Github форкать / создавать issue / присылать PR ну и ставить звездочки :)

Пример создания стора
import { Ref } from 'vue';
import { defineStore } from '../plugins/ParallelStore';

interface CounterStoreState {
  counter: Ref<number>;
}

export const useCounterStore = defineStore(
  'CounterStore',
  () => ({ counter: 0 }),
  {
    incrementAction(state: CounterStoreState, value = 1) {
      state.counter.value += value;
    },
    incrementLaterAction(state: CounterStoreState, value = 1) {
      setTimeout(() => {
        state.counter.value += value;
      }, 1000);
    },
  }
);

Пример использования стора в компоненте
<script setup lang="ts">
import { ref } from 'vue';
import { useCounterStore } from '../store/counter';

const counterStore = useCounterStore();
</script>

<template>
  <div class="card">
    <span>counterStore.counter is <strong>{{ counterStore.counter }}</strong></span>
    <br />
    <button type="button" @click="counterStore.counter++">
      Update ref by increment
    </button>
    <button type="button" @click="counterStore.incrementAction()">
      update by action
    </button>
    <button type="button" @click="counterStore.incrementLaterAction(11)">
      update by async action (after 1 sec)
    </button>
  </div>
</template>

<style scoped>
</style>

Заключение

Напоследок хотелось бы поговорить про поддержку, потому как это «бутылочное горлышко». И тут все не так однозначно. Обычные воркеры поддерживаются всеми и давно (даже IE с 10 версии). Service-воркер чуть похуже, но все еще вполне отличный результат. А вот shared-воркер… Сафари вернул его поддержку только в сентябре 2022-го, но все остальные браузеры на мобилках не поддерживают (придется использовать фолбэк на обычный воркер). Т. е. в принципе использовать можно, правда с ограничениями :)

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

Полезные ссылки




Комментарии (11):

  1. fransua
    /#24956142 / +1

    Непонятно, зачем vDOM выносить в воркер, операций обновления слишком много, они забьют канал к воркеру, и нужно будет бороться с задержкой. Все то же самое, но в воркере держать состояние и обмениваться им с main - должно быть гораздо лучше и супер фреймворк не нужен

    • i_kostyakov6
      /#24956464 / +1

      Это интересный вопрос, который нужно исследовать на практике. Обновления vDOM точно не должны происходить чаще чем раз в кадр. Но скорее всего, в таком формате, после проб и ошибок появится отдельный протокол передачи изменений в реальный дом.
      В целом, я не настаиваю на vDOM, но React и Vue сейчас работают с ним. Потенциально от него можно вообще отказаться как это делает Angular. Идея dedicated worker в том, чтобы обрабатывать логику View части.

      • fransua
        /#24956642 / +1

        Да, я как раз про логику View, что ее лучше оставить в основном потоке. Для себя представляю воркер как домен приложения, выполняющий бизнес-логику. А к нему уже можно безболезненно подключить веб страницу или нативное приложение или nodejs, electron, cli.
        Кажется, что если view не влезает в основной поток, то перенос его в воркер только усугубит дело.

        • i_kostyakov6
          /#24956708

          Возможно :)
          Нужно пробовать и бенчить

      • funca
        /#24959064

        Узкое место это обмен данными между DOM и рантаймом JavaScript. DOM API в основном синхронный и заставляет ждать результатов. К тому же браузеру приходится перепаковывать структуры данных из одного формата в другой. vDOM помогает производить максимум вычислений в пределах JS и лишь иногда синхронизироваться с настоящим DOM (может раз в 16ms или даже чаще, но это все равно не так часто как если ходить за каждым атрибутом по сто раз в DOM и обратно). При попытке вынести расчет vDOM в воркер узким местом может стать коммуникация с основным потоком. Но там есть четкая зависимость от объема данных.

  2. errashe
    /#24956438

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

    Вот с этим как жить?

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

    P.S. Юзвери уж очень сильно хотят, чтоб можно было несколько вкладок и открывать их снаружи.

    • fransua
      /#24956622 / +1

      Мы делали работу с несколькими вкладками через одну главную и childWindow.postMessage. Но это потому, что нужна была поддержка сафари. Если нет, то shared worker отлично с этим справится

  3. koil
    /#24958322

    vDOM как-то шарится между воркерами или нет? Или идея в том, что каждый dedicated worker отвечает только за свою часть vDOM? Если таки шарится, то как его синхронизировать?

  4. iassasin
    /#24958900 / +1

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

    Как я понял, обычная обработка ввода от пользователя превращается в поток следующих действий:
    Генерация события ввода символа в main thread -> трансфер события в воркер бизнес логики + сама бизнес логика -> трансфер стейта в рендед-воркер и пересчет vdom-а -> трансфер изменений/событий в main thread для отрисовки в DOM.

    Итого минимум 3 трансфера, два из которых передают полный стейт приложения (или нет, но тогда как?). А если речь заходит о крупных веб-приложениях, то в них вроде как тенденция к тому, что стейт со временем сильнее пухнет. А трансфер стейта между воркерами - это всегда аналог json serialize/deserialize, что сильно грузит CPU. А технологии для перемещения объектов между потоками с минимумом накладных расходов по крайней мере мне еще не встречались.

  5. funca
    /#24958990 / +1

    Практически во всех областях IT-разработки весь мир перешел на использование многопоточности: мобильные приложения, бэкенд, прикладное программирование

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

  6. dyadyaSerezha
    /#24966614

    Рад за веб-программеров, наконец они изобрели велосипед, на котором десктопные приложения ездят лет эдак 25. Я про использование многопоточности в GUI-приложениях и выделение всех GUI-операций в один поток, а бизнес-логику в другой/другие потоки.

    Кстати, в десктопном Хроме shared worker должен быть в своём отдельным процессе, так как каждая вкладка уже отдельный процесс.

    Да, и не надо путать шаринг и шеринг - это разные вещи. В данном случае слово шаринг используется неверно.