company_banner

Работа с Worker “как хочется“, а не “как можно”

    В этой статье будет использоваться ГРЯЗНЫЙ, небезопасный, "костыльный", страшный и т. д. метод eval. Слабонервным не читать!


    Сразу скажу, что некоторые проблемы удобства использования решить не удалось: в коде, который будет передан в worker, нельзя использовать замыкание.
    Работа с Worker "как хочется", а не "как можно"


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


    Что нужно, чтобы сделать работу с worker удобнее? На мой взгляд, следующее:


    • Возможность запускать в worker произвольный код в произвольный момент времени
    • Возможность передавать в worker сложные данные (экземпляры классов, функции)
    • Возможность получения Promise с ответом из worker.

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


    • добавление библиотек/файлов в worker
    • запуск работы

    Файл внутри worker


    Перед тем как приступить к созданию worker, нужно описать файл, который будет работать в worker и поддерживать описанный нами протокол. Я люблю ООП, поэтому это будет класс с названием WorkerBody. Этот класс должен подписаться на событие от родительского окна.


    self.onmessage = (message) => {
        this.onMessage(message.data);
    };

    Теперь мы можем слушать события от родительского окна. События у нас есть двух видов: те, на которые подразумевается ответ, и все остальные. Обработаем события.
    Добавление библиотек и файлов в worker делается при помощи API importScripts.


    И самое страшное: для запуска произвольной функции мы будем использовать eval.


    ...
    
    onMessage(message) {
      switch (message.type) {
        case MESSAGE_TYPE.ADD_LIBS:
            this.addLibs(message.libs);
            break;
        case MESSAGE_TYPE.WORK:
            this.doWork(message);
            break;
        }
    }
    
    doWork(message) {
        try {
            const processor = eval(message.job);
            const params = this._parser.parse(message.params);
            const result = processor(params);
            if (result && result.then && typeof result.then === 'function') {
                 result.then((data) => {
                     this.send({ id: message.id, state: true, body: data });
                 }, (error) => {
                     if (error instanceof Error) {
                          error = String(error);
                     }
                     this.send({ id: message.id, state: false, body: error });
                 });
            } else {
               this.send({ id: message.id, state: true, body: result });
            }
        } catch (e) {
           this.send({ id: message.id, state: false, body: String(e) });
        }
    }
    
    send(data) {
        data.body = this._serializer.serialize(data.body);
        try {
            self.postMessage(data);
        } catch (e) {
            const toSet = {
              id: data.id,
              state: false,
              body: String(e)
            };
            self.postMessage(toSet);
        }
    }
    

    Метод onMessage отвечает за получение сообщения и выбор обработчика, doWork — запускает переданную функцию, а send отправляет ответ в родительское окно.


    Парсер и сериализатор


    Теперь, когда у нас есть содержимое worker, надо научиться сериализовать и парсить любые данные, чтобы передавать их в worker. Начнем с сериализатора. Мы хотим иметь возможность передавать в worker любые данные, в том числе — экземпляры классов, классы и функции. Но с помощью нативных возможностей worker мы можем передать только JSON-like данные. Чтобы обойти этот запрет, нам понадобится eval. Все, что не может принять JSON, мы обернем в соответствующие строковые конструкции и запустим на другой стороне. Чтобы сохранить иммутабельность, полученные данные клонируются на лету, и то, что не может быть сериализовано обычными способами, заменяется служебными объектами, а они в свою очередь заменяются обратно парсером на другой стороне. На первый взгляд может показаться, что эта задача несложная, но существует множество подводных камней. Самое страшное ограничение такого подхода — невозможность использовать замыкание, что несет в себе несколько иной стиль написания кода. Начнем с самого простого, с функции. Для начала надо научиться отличать функцию от конструктора класса.


    Попробуем отличить:


    static isFunction(Factory){
    
            if (!Factory.prototype) {
                // Arrow function has no prototype
                return true;
            }
    
            const prototypePropsLength = Object.getOwnPropertyNames(Factory.prototype)
                        .filter(item => item !== 'constructor')
                        .length;
    
            return prototypePropsLength === 0 && Serializer.getClassParents(Factory).length === 1;
    }
    
    static getClassParents(Factory) {
            const result = [Factory];
            let tmp = Factory;
            let item = Object.getPrototypeOf(tmp);
    
            while (item.prototype) {
                result.push(item);
                tmp = item;
                item = Object.getPrototypeOf(tmp);
            }
    
            return result.reverse();
        }
    

    Первым делом мы выясним, есть ли у функции прототип. Если его нет — это точно функция. Затем мы смотрим на количество свойств в прототипе, и, если в прототипе только конструктор и функция не является наследником другого класса, мы считаем, что это — функция.


    Обнаружив функцию, мы просто заменяем ее служебным объектом с полями __type = "serialized-function" и template, который равен шаблону данной функции (func.toString()).


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


    static isInstance(some) {
            const constructor = some.constructor;
            if (!constructor) {
                return false;
            }
            return !Serializer.isNative(constructor);
        }
    
    static isNative(data) {
            return /function .*?\(\) \{ \[native code\] \}/.test(data.toString());
    }
    

    Мы считаем что объект является обычным, если у него нет конструктора или его конструктор — нативная функция. Опознав экземпляр класса, мы заменяем его служебным объектом с полями:


    • __type — 'serialized-instance'
    • data — данные, которые были в экземпляре
    • index — индекс класса этого экземпляра в служебном списке классов.

    Чтобы передать данные, нам необходимо сделать дополнительное поле: в нем мы будем хранить список всех уникальных классов, которые мы передаем. Самое сложное заключается в том, чтобы при обнаружении класса брать не только его шаблон, но и шаблон всех родительских классов и сохранять их как самостоятельные классы — чтобы каждый "родитель" был передан не более одного раза, — и сохранить проверку на instanceof. Определить класс несложно: это — функция, которая не прошла нашу проверку Serializer.isFunction. При добавлении класса мы проверяем наличие такого класса в списке сериализованных данных и добавляем только уникальные. Код, который собирает класс в шаблон, — довольно большой и лежит тут.


    В парсере мы сначала обходим все переданные нам классы и компилируем их, если ранее они не были переданы. Затем мы рекурсивно обходим каждое поле данных и заменяем служебные объекты на скомпилированные данные. Самое интересное — в экземпляре класса. У нас есть класс и есть данные, которые были в его экземпляре, но мы не можем просто так создать экземпляр, ведь вызов конструктора может иметь параметры, которых у нас нет. На помощь нам приходит почти забытый метод Object.create, который возвращает объект с заданным прототипом. Так мы избегаем вызова конструктора и получаем экземпляр класса, а затем просто переписываем в экземпляр свойства.


    Создание worker


    Для успешной работы worker нам необходимо иметь парсер и сериализатор внутри worker и снаружи, поэтому мы берем сериализатор, и превращаем в шаблон сериализатор, парсер и тело worker. Из шаблона делаем блоб и создаем ссылку на скачивание через URL.createObjectURL (данный способ может не работать при некоторых "Content-Security-Policy"). Данный способ также подходит для запуска произвольного кода из строки.


    _createWorker(customWorker) {
        const template = `var MyWorker = ${this._createTemplate(customWorker)};`;
        const blob = new Blob([template], { type: 'application/javascript' });
        return new Worker(URL.createObjectURL(blob));
    }
    
    _createTemplate(WorkerBody) {
        const Name = Serializer.getFnName(WorkerBody);
        if (!Name) {
          throw new Error('Unnamed Worker Body class! Please add name to Worker Body class!');
        }
    
        return [
          '(function () {',
          this._getFullClassTemplate(Serializer, 'Serializer'),
          this._getFullClassTemplate(Parser, 'Parser'),
          this._getFullClassTemplate(WorkerBody, 'WorkerBody'),
          `return new WorkerBody(Serializer, Parser)})();`
        ].join('\n');
    }
    

    Результат


    Таким образом, у нас получилась простая в использовании библиотека, которая может запустить произвольный код в worker. Она поддерживает классы из TypeScript. Например:


    const wrapper = workerWrapper.create();
    
    wrapper.process((params) => {
        // This code in worker. Cannot use closure!
        // do some hard work
        return 100; // or return Promise.resolve(100)
    }, params).then((result) => {
        // result = 100;
    });
    
    wrapper.terminate() // terminate for kill worker process

    Дальнейшие планы


    Данная библиотека, к сожалению, далека от идеала. Необходимо добавить поддержку сеттеров и геттеров на классах, объектах, прототипах, статичных свойствах. Мы также хотели бы добавить кэширование, сделать альтернативный запуск скриптов без eval через URL.createObjectURL и добавить в сборку файл с содержимым worker (если недоступно создание "на лету"). Приходите в репозиторий!

    Waves
    51,30
    Компания
    Поддержать автора
    Поделиться публикацией

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

      +2
      Боюсь показаться глупым, но каков кейс использования сабжа?
        0
        Например библиотеки по считыванию QR кода сильно тормозят main процесс при своей работе, с помощью данной библиотеки ими можно пользоваться проще.
        В целом случаи когда нужны воркеры не очень часто встречаются, но неудобство их использования заставляет разработчиков искать решения без них. У нас были случаи когда пользоваться воркером удобно.
          +3
          Это кейс использования воркеров. Но почему не перенести весь необходимый код сразу в них, зачем жонглировать eval'ами?
            –2
            Этот подход позволяет не менять архитектуру, не настраивать особым образом сборку проекта. Легко и просто перенести любой загруженный код в воркер, не переписывая всё вокруг.
              +5
              Если такая проблема вынести нужные части кода в воркер — возможно, проблема как раз с архитектурой?

              Нет, в принципе, я согласен, что было бы круто иметь возможность просто выполнить произвольную функцию в другом потоке. Но статья полна оговорок, примечаний и извинений. И либа, при всём уважении к вашей креативности, выглядит очень костыльно. И есть подозрение, что при дальнейшем её использовании будут найдены ещё не одни подводные грабли. Точно ли это адекватная цена за удобство?
                +1
                Данная библиотека используется в проектах и хорошо справляется с поставленными задачами. По мере расширений требований к ней добавляется новый функционал. Самая большая проблема в ней — невозможность использования замыкания.

                А что в этой библиотеке кажется вам костыльным (кроме «eval»)?
                Получилась библиотека с удобным АПИ, которая позволяет
                просто выполнить произвольную функцию в другом потоке
                Она сырая, но только пользователи и issue на гитхабе помогут сделать её лучше.
                В целом я хотел получить способ удобно пользоваться многопоточностью в javascript. В каком-то виде это получилось, пользоваться или нет — личное дело каждого. Так как воркеры не особо востребованы — статья больше исследовательская, чем призыв к использованию.
                  +1
                  Ну, с моей точки зрения воркеры — это достаточно удобная многопоточность. Настройка сборки происходит один раз. А архитектурные ограничения — повод избавиться от спагетти, разделить код на менее связанные компоненты. Если думать о том и другом заранее, то всё хорошо. Соответственно, ваш проект (согласно вашему же описанию) мне видится как некий хак, чтобы быстро впихнуть многопоточность туда, где она изначально не задумывалась.
                    +1

                    Ну описания нашего проекта в этом треде нет. Библиотека это «некий хак» для удобной работы с воркерами, я с этим согласен. На мой взгляд запуск дополнительного потока не должен требовать подготовки в виде настройки сборщика, это должен быть просто вызов асинхронного метода. Это гораздо удобнее в любой архитектуре. Это также уменьшает порог входа в данную технологию.

                      +1
                      В общем-то, я с вами согласен. И когда для этого появится механизм на уровне языка, с удовольствием им воспользуюсь при случае. Но я не хочу каждый раз, используя этот механизм, думать о том, какие ограничения есть у библиотеки, его реализующей (а они есть), и что в ней может пойти не так (а оно может). Проще настроить сборку и быть спокойным.

                      P.S. Ни в коем случае не имею в виду, что все должны думать так же, как я.
                        +2
                        Sirion, сам с нетерпением жду поддержки этого на уровне языка.
            0
            Для библиотек считывания нужно передавать данные по изображению: они и так потребляют много памяти (даже в ч/б), а тут надо ещё сериализовывать данные чтобы передать тому же воркеру. Есть ли измерения на тему того, окупаются ли эти затраты?
          +2
          Aingis, я не совсем понимаю в чём вопрос. Суммарное время работы кода увеличилось (не значительно, хотя это конечно зависит от объема передаваемых данных), так как нужно серриализовывать и парсить данные. Но за счёт того что обработка картинки происходит в воркере исчезло замирание интерфейса. Когда глазом перестали быть заметны тормоза — то да, я считаю что эти затраты окупаются. Возможно в дальнейшим мы добавим сущность типизированного массива, чтобы ускорить парсинг и серриализацию.
            0
            В дополнение к статье) Я думаю, что весь код воркера стоит разбивать на составляющие и, тем самым, параллелить выполнение наиболее затратных операций и методов в самих воркерах. Каждому воркеру стоит назначать не более трех функций. Т.о., весь код, перенесенный в воркеры, будет значительно быстрее отрабатывать.
            Т.е. можно запустить целую группу воркеров, которые, в целом, могут быть организованы так, как описывается в микросервисной архитектуре. Я, единственное, не знаю, как множество одновременно работающих воркеров будет потреблять ресурсы)))
            0
            case MESSAGE_TYPE.ADD_LIBS:
            

            Это артефакт копипасты? Поначалу решил, что вы собираетесь отдельно передавать в воркер новые методы.
              0
              Нет, по задумке во внуторь воркера можно подключать библиотеки. В протоколе общения с воркером есть тип сообщения в котором указана ссылка на файл, который будет подключен в воркер через importScript.

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

            Самое читаемое