Хабрахабр

Разработка под WebAssembly: реальные грабли и примеры

Тем ценнее материалы о подобном опыте: информация из первых рук о том, каково с этим жить на практике, пока что в дефиците. Анонс WebAssembly состоялся в 2015-м — но сейчас, спустя годы, всё ещё немногие могут похвастаться им в продакшне.

На конференции HolyJS доклад об опыте использования WebAssembly получил высокие оценки зрителей, и теперь специально для Хабра подготовлена текстовая версия этого доклада (видеозапись также приложена).

Можно сказать, что я начал заниматься вебом в прошлом веке, но я скромный, поэтому так говорить не буду. Меня зовут Андрей, я расскажу вам про WebAssembly. Сегодня я интересуюсь такими вещами, как WebAssembly, C++ и прочими нативными штуками. За это время я успел поработать и над бэкендом, и над фронтендом, и даже немножко рисовал дизайн. Еще я очень люблю типографику и собираю старую технику.

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

Как мы внедряли WebAssembly

Я работаю в компании Inetra, мы находимся в Новосибирске и делаем несколько собственных проектов. Один из них — ByteFog. Это технология peer-to-peer доставки видео пользователям. Нашими клиентами являются сервисы, которые раздают огромное количество видео. У них есть проблема: когда случается какое-то популярное событие, например, чья-то пресс-конференция или какое-то спортивное событие, как к нему не готовься, приходит куча клиентов, наваливается на сервер, и сервер грустит. Клиенты в это время получают очень плохое качество видео.

Давайте попросим соседние устройства пользователей поделиться кусочками видео, и тогда мы разгрузим сервер, сэкономим полосу, а пользователи получат видео в лучшем качестве. Но ведь все смотрят один и тот же контент. Вот эти облачка — наша технология, наш прокси-сервер ByteFog.

Какой язык выбрать, чтобы иметь единую кодовую базу на всех этих платформах? Мы должны быть установлены в каждом устройстве, которое умеет показывать видео, поэтому поддерживаем очень широкий спектр платформ: Windows, Linux, Android, iOS, Web, Tizen. Мы выбрали C++, потому что у него оказалась больше всего плюсов 😀 Если серьёзнее, мы имеем хорошую экспертизу по C++, это действительно быстрый язык, и в портативности он уступает, наверное, только С.

Под Windows и Linux мы компилируемся в нативный код. У нас получилось довольно большое приложение (900 классов), но оно отлично работает. Про Tizen поговорим в другой раз, а вот на Web мы раньше работали как плагин к браузеру. Под Android и iOS мы собираем библиотеку, которую подключаем к приложению.

Как видно из названия, она довольно старая, а также имеет недостаток: дает очень широкий доступ к системе, так что пользовательский код может вызвать проблему с безопасностью. Это технология Netscape Plugin API. Так мы остались без веб-версии почти на два года. Наверное, поэтому в 2015 году Chrome отключил поддержку этой технологии, и следом все браузеры присоединились к этому флешмобу.

Как вы догадываетесь, это WebAssembly. В 2017 году забрезжила новая надежда. Поскольку к весне уже появилась поддержка Firefox и Chrome, а к осени 2017 года подтянулись Edge и Safari. В итоге мы поставили перед собой задачу портировать наше приложение в браузер.

Берем компилятор Emscripten. Использовать готовый код нам было важно, так как у нас много бизнес-логики, которую не хотелось двоить, чтобы не удвоить количество багов. Можно сказать, что Emscripten — это такой Browserify для C++ кода. Он делает то, что нам нужно, — компилирует плюсовое приложение в браузер и воссоздает среду, привычную нативному приложению в браузере. Первая наша мысль была: сейчас возьмем Emscripten, просто скомпилируем, и все заработает. Также он позволяет пробрасывать объекты из C++ в JavaScript и наоборот. С этого начался наш путь по граблям. Конечно же, вышло не так.

В нашей кодовой базе было несколько библиотек. Первое, с чем мы столкнулись, — зависимости. Это большая библиотека, которая позволяет писать кроссплатформенный код, но с ней очень сложно настроить компиляцию. Сейчас их перечислять нет смысла, но для тех, кто понимает, у нас есть Boost. Хотелось как можно меньше кода тащить в браузер.

Архитектура Bytefog

В итоге мы выделили ядро: можно сказать, что это прокси-сервер, в котором содержится основная бизнес-логика. Этот прокси-сервер берет данные по двум источникам. Первый и основной — это HTTP, то есть канал к серверу раздачи видео, второй — наша P2P сеть, то есть канал до другого такого же прокси у какого-то другого пользователя. Отдаем мы данные в первую очередь плееру, так как наша задача — показать качественный контент пользователю. Если остаются ресурсы, мы раздаем контент в P2P сеть, чтобы другие пользователи могли его скачать. Внутри находится умный кэш, который и делает всю магию.

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

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

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

Основной канал доставки видео от провайдера мы заменили на обычный AJAX. Что получилось в итоге? Весь P2P-слой мы заменили на WebRTC. К плееру мы выдаем данные через популярную библиотеку HLS.js, но есть принципиальная возможность интегрироваться с другими плеерами, если это будет нужно.

Самый главный — двоичный .wasm. В результате компиляции получается несколько файлов. Но сам по себе он не работает, необходим, так называемый «клеевой код», его также генерирует компилятор. В нем содержится скомпилированный байт-код, который браузер будет выполнять и в котором содержится все ваше наследство C++. Для целей отладки можно сгенерировать текстовое представление ассемблера — .wast-файл и sourcemap. Клеевой код занимается загрузкой двоичного файла, и оба этих файла вы выкладываете на продакшен. В нашем случае достигали 100 мегабайт и более. Нужно понимать, что они могут быть очень большого размера.

Собираем бандл

Рассмотрим клеевой код поближе. Это обычный старый-добрый ES5, собранный в один файл. Когда мы его подключаем на веб-страницу, у нас появляется глобальная переменная, в которой содержится весь наш инстанциированный wasm-модуль, который готов принимать запросы в свое API.

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

Возникла проблема с Babel, — ему не понравился большой объем кода, но это ES5-код, его не нужно транспилировать, мы просто добавляем его в игнор. Она оборачивает клеевой код в паттерн «Модуль», и мы можем подцепить его: импортировать или использовать require, если мы пишем на ES5, — Webpack спокойно понимает эту зависимость.

Все двоичные файлы, которые получились при компиляции, она переводит в Base64-вид и заталкивает в клеевой код в виде строки. В погоне за количеством файлов я решил использовать опцию SINGLE_FILE. На таком объеме ни Webpack, ни Babel, ни даже браузер не работают. Звучит как отличная идея, но после этого бандл у нас стал размером 100 мегабайт. Да и вообще, не будем же мы заставлять пользователя грузить 100 мегабайт?!

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

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

Thenable

Другая проблема с модулем — то, что он является объектом Thenable, то есть имеет метод .then(). Эта функция позволяет навесить callback на момент старта модуля, и это очень удобно. Но хотелось бы, чтобы интерфейс соответствовал Promise. Thenable — это не Promise, но ничего страшного, обернем сами. Напишем такой простой код:

return new Promise((resolve, reject) => );
});

Создаем Promise, стартуем наш модуль, и как callback вызываем функцию resolve и передаем туда тот модуль, который у нас инстанцировался. Все вроде бы очевидно, все прекрасно, запускаем — что-то не так, у нас завис браузер, у нас зависли DevTools, и у компьютера греется процессор. Мы ничего не понимаем — какая-то рекурсия или бесконечный цикл. Отлаживать это довольно сложно, и когда мы прервали работу JavaScript, мы оказались в функции Then в модуле Emscripten.

Module[‘then’] = function(func) { if (Module[‘calledRun’]) { func(Module); } else { Module[‘onRuntimeInitialized’] = function() { func(Module); }; }; return Module;
};

Давайте посмотрим на нее подробнее. Участок

Module[‘onRuntimeInitialized’] = function() { func(Module); };

отвечает за навешивание callback. Тут все понятно: асинхронная функция, которая вызывает наш callback. Все, как мы хотим. Есть другая часть этой функции.

if (Module[‘calledRun’]) { func(Module);

Она вызывается, когда модуль уже стартовал. Тогда callback синхронно вызывается сразу же, и ему передается в параметр модуль. Это имитирует поведение Promise, и вроде бы это то, что мы ожидаем. Но что же тогда не так?

Когда мы резолвим Promise с помощью Thenable-объекта, браузер будет разворачивать значения из этого Thenable-объекта, и для этого он вызовет метод .then(). Если внимательно почитать документацию, оказывается, что есть очень тонкий момент про Promise. Браузер спрашивает: Thenable ли это объект? В итоге мы резолвим Promise, передаем ему модуль. Тогда у модуля вызывается функция .then(), и в качестве callback передается сама функция resolve. Да, это Thenable-объект.

Он уже запущен, поэтому callback вызывается сразу же, и ему передается снова этот же модуль. Модуль проверяет, запущен ли он. Да, это Thenable-объект. В качестве callback у нас функция resolve, и браузер спрашивает: это Thenable-объект? В результате мы впадаем в бесконечный цикл, из которого браузер не возвращается никогда. И все начинается снова.

В результате я просто удаляю метод .then() перед resolve, и это работает. Элегантного решения этой проблемы я не нашел.

Emscripten

Итак, модуль мы скомпилировали, JS собрали, но чего-то не хватает. Наверное, нам нужно сделать какую-то полезную работу. Для этого нужно передать данные и связать два мира — JS и C++. Как это сделать? Emscripten предоставляет целых три возможности:

  • Первая — это функции ccall и cwrap. Чаще всего вы их встретите в каких-то туториалах по WebAssembly, но для реальной работы они не годятся, так как не поддерживают возможности C++.
  • Вторая — это WebIDL Binder. Он уже поддерживает C++ функции, с ним уже можно работать. Это серьезный язык описания интерфейсов, которым пользуются, например, W3C для своей документации. Но мы не захотели нести его в свой проект и воспользовались третьей опцией
  • Embind. Можно сказать, что это нативный способ связи объектов для Emscripten, он основан на шаблонах C++ и позволяет делать очень много вещей по пробросу разных сущностей из C++ в JS и обратно.

Embind позволяет:

  • Вызывать из JavaScript-кода функции C++
  • Создавать JS-объекты из C++ класса
  • Из C++ кода обратиться к API браузера (если вы зачем-то этого хотите, можно, например, написать фронтенд-фреймворк целиком на C++).
  • Главное для нас: реализовать на JavaScript интерфейс, описанный на C++.

Обмен данными

Последний пункт важен, так как это именно то действие, которое вы будете постоянно делать при портировании приложения. Поэтому я бы хотел остановиться на нем подробнее. Сейчас будет код на C++, но не пугайтесь, это почти как TypeScript 😀

Схема такая:

Раньше оно это делало с помощью нативных сокетов, был какой-то HTTP-клиент, который это делал, но в WebAssembly нет нативных сокетов. На стороне C++ есть ядро, которому мы хотим дать доступ, например, во внешнюю сеть — покачать видео. После этого полученный объект мы передадим обратно в C++, где его будет использовать ядро. Нужно как-то выкручиваться, поэтому мы отрезаем старый HTTP-клиент, в это место вставляем интерфейс, и реализацию этого интерфейса делаем в JavaScript с помощью обычного AJAX, любым способом.

Сделаем простейший HTTP-клиент, который может делать только get-запросы:

class HTTPClient {
public: virtual std::string get(std::string url) = 0;
};

На вход он принимает строку с URL-адресом, который надо скачать, и на выход
строку с результатом запроса. В C++ строки могут иметь двоичные данные, поэтому для видео это подходит. Emscripten заставляет нас написать вот
такой страшный Wrapper:

В итоге мы пишем декларацию связи: В нем главное — две вещи — имя функции на стороне C ++ (я обозначил их зеленым цветом), и соответствующие им имена на стороне JavaScript, (их обозначил синим).

У нас есть класс, у этого класса есть метод, и мы хотим наследоваться от этого класса, чтобы реализовать интерфейс. Она работает как кубики Lego, из которых мы её собираем. Мы идем в JavaScript и наследуемся. Это все. Первый — extend. Это можно сделать двумя путями. Это очень похоже на старый добрый extend из Backbone.

Мы вызываем метод extend и передаем туда объект с реализацией этого метода, то есть в функции get будет реализован какой-то способ
получения информации с помощью AJAX. В модуле содержится все, что накомпилировал Emscripten, и в нем есть свойство с экспортированным интерфейсом.

Мы можем вызывать его сколько угодно раз и сгенерировать объекты в том количестве, которое нам необходимо. На выходе extend дает нам обычный JavaScript-конструктор. Но бывает ситуация, когда у нас есть один объект, и мы хотим его просто передать на сторону C++.

Это и делает функция implement. Для этого нужно как-то привязать этот объект к типу, который поймет C++. Сделать это можно, например, вот так: На выходе она дает не конструктор, а уже готовый к употреблению объект, наш клиент, который мы можем отдать обратно в C++.

var app = Module.makeApp(client, …)

Допустим, у нас есть фабрика, которая создает наше приложение, и в параметры она принимает свои зависимости, например, client и что-нибудь еще. Когда эта функция отработает, мы получим объект нашего приложения, который уже содержит API, которое нам нужно. Можно сделать наоборот:

val client = val::global(″client″);
client.call<std::string>(″get″, val(...) );

Прямо из C++ взять из глобальной области видимости браузера наш client. Причем на месте client может быть любое API браузера, начиная от консоли, заканчивая DOM API, WebRTC — всё, что вам заблагорассудится. Далее мы вызываем методы, которые есть у этого объекта, а все значения оборачиваем в магический класс val, который предоставляет нам Emscripten.

Ошибки биндинга

В целом это всё, но когда вы начнете разработку, вас подстерегают ошибки биндинга. Они выглядят как-то так:

Если это все просуммировать, то нужно следить, чтобы совпадали (легко опечататься и получить ошибку биндинга): Emscripten старается помогать нам и объяснять, что же происходит не так.

  • Имена
  • Типы
  • Количество параметров

Синтаксис Embind непривычен не только для фронтендеров, но и для людей, которые занимаются C++. Это некий DSL, в котором легко сделать ошибку, нужно за этим следить. Говоря об интерфейсах, когда вы реализуете на JavaScript какой-то интерфейс, нужно, чтобы он точно соответствовал тому, что вы описали в своем контракте.

Мой коллега Юра, который занимался проектом со стороны C++, использовал Extend, чтобы проверять свои модули. У нас произошел интересный случай. Я использовал implement для интеграции этих модулей в JS-проект. У него они отлично работали, поэтому он их закоммитил и передал мне. Когда мы разобрались, оказалось, что при биндинге в названиях функций получилась опечатка. И у меня они работать перестали.

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

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

Extend и ES6

Другая проблема с Extend в том, что он не поддерживает ES6-классы. Когда вы наследуетесь объектом, порожденным от ES6-класса, Extend ожидает, что в нём все свойства перечислимые, но с ES6 это не так. Методы находятся в прототипе, и у них enumerable: false. Я использую вот такой костыль, в котором пробегаюсь по прототипу и включаю enumerable: true:

function enumerateProto(obj) { Object.getOwnPropertyNames(obj.prototype) .forEach(prop => Object.defineProperty(obj.prototype, prop, {enumerable: true}) )
}

Надеюсь, когда-нибудь удастся избавиться от него, так как в сообществе Emscripten идут разговоры об улучшении поддержки ES6.

Оперативная память

Говоря про C++, нельзя не затронуть память. Когда мы проверяли всё на видео SD-качества, у нас всё было отлично, работало просто идеально! Как только мы сделали FullHD-тест — ошибка нехватки памяти. Не беда, есть опция TOTAL_MEMORY, которая задает стартовое значение памяти для модуля. Сделали полгигабайта, все хорошо, но как-то это негуманно для пользователей, ведь память мы резервируем у всех, но не все имеют подписку на FullHD-контент.

Она позволяет растить память
постепенно по мере надобности. Есть другая опция — ALLOW_MEMORY_GROWTH. Когда вы все их использовали, происходит выделение нового куска памяти. Работает это так: Emscripten по умолчанию даёт модулю для работы 16 мегабайт. Так происходит до тех пор, пока не достигнете 4 ГБ. Туда копируются все старые данные, и у вас еще остается столько же места для новых.

Тогда остальная память будет использована неэффективно. Допустим, вы выделили 256 мегабайт памяти, но вы точно знаете, вы посчитали, что вашему приложению достаточно 192. Хотелось бы как-то этого избежать. Вы ее выделили, забрали у пользователя, но ничего с ней не делаете. Тогда на третьем шаге мы достигаем 192 мегабайт, и это именно то, что нам нужно. Есть небольшой трюк: мы начинаем работу с увеличенной в полтора раза памятью. Поэтому я рекомендую использовать обе эти опции совместно. Мы сократили потребление памяти на тот остаток и сэкономили лишнее выделение памяти, а чем дальше, тем они занимают больше времени.

Dependency Injection

Казалось бы это все, но дальше грабли пошли побольше. Есть проблема с Dependency Injection. Пишем простейший класс, в котором нужна зависимость.

class App { constructor(httpClient) { this.httpClient = httpClient }
}

Например, наш HTTP-клиент мы передаем в наше приложение. Сохраняем в свойство класса. Казалось бы, все будет работать хорошо.

Module.App.extend( ″App″, new App(client)
)

Мы наследуемся от интерфейса на C++, сначала создаем наш объект, передаем ему зависимость, а потом происходит наследование. В момент наследования Emscripten делает что-то невероятное с объектом. Проще всего думать, что он убивает старый объект, создает новый на основе своего шаблона и перетаскивает туда все публичные методы. Но при этом состояние объекта теряется, и вы получаете объект, который не сформирован и не работает правильно. Решить эту проблему довольно просто. Надо использовать конструктор, который работает после стадии наследования.

class App { _construct(httpClient) { this.httpClient = httpClient this._parent._construct.call(this) }
}

Мы делаем практически то же самое: сохраняем зависимость в поле объекта, но это уже тот объект, который получился после наследования. Нужно не забыть пробросить вызов конструктора в родительский объект, который находится на стороне C++. Последняя строчка — это аналог метода super() в ES6. Вот так происходит наследование в этом случае:

const appConstr = Module.App.extend( ″App″, new App()
) const app = new appConstr(client)

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

Хитрость с указателем

Другая проблема — передача объектов по указателю из C++ в JavaScript. Мы уже делали HTTP-клиент. Для упрощения мы упустили одну важную деталь.

std::string get(std::string url)

Метод возвращает значение сразу, то есть получается, что запрос должен быть синхронным. Но ведь AJAX-запросы на то и AJAX, что они асинхронные, поэтому в реальной жизни метод будет возвращать либо ничего, либо мы можем вернуть ID запроса. А вот чтобы было кому вернуть ответ, вторым параметром мы передаем listener, в котором будут callback-и со стороны C++.

void get(std::string url, Listener listener)

В JS это выглядит так:

function get(url, listener) { fetch(url).then(result) => { listener.onResult(result) })
}

Мы имеем функцию get, которая принимает этот объект listener. Мы запускаем скачивание файла и вешаем callback. Когда файл скачался, мы дергаем у listener нужную функцию и передаем в нее результат.

Казалось бы, план хороший, но когда функция get завершится, будут уничтожены все локальные переменные, а вместе с ними и параметры функции, то есть указатель будет уничтожен, а runtime emscripten уничтожит объект на стороне C++.

В итоге, когда дело дойдет до вызова строчки listener.onResult(result), listener уже не будет существовать, и при обращении к нему возникнет ошибка доступа к памяти, которая приведет к краху приложения.

Хотелось бы этого избежать, и решение есть, но на то, чтобы найти его, ушло несколько недель.

function get(url, listener) { const listenerCopy = listener.clone() fetch(url).then((result) => { listenerCopy.onResult(result) listenerCopy.delete() })
}

Оказывается, есть метод клонирования указателя. Почему-то он не документирован, но отлично работает, и позволяет увеличить счетчик ссылок в указателе Emscripten. Это позволяет подвесить его в замыкании, и тогда, когда мы запустим наш callback, наш listener будет доступен по этому указателю и можно работать так, как нам нужно.

Самое важное — не забыть удалить этот указатель, иначе это приведёт к ошибке утечки памяти, а это очень плохо.

Быстрая запись в память

Когда мы качаем видео — это относительно большие объемы информации, и хотелось бы сократить количество копирования данных туда-сюда, чтобы сэкономить и память, и время. Есть один трюк, как записать большой объем информации напрямую в память WebAssembly со стороны JavaScript.

var newData = new Uint8Array(…);
var size = newData.byteLength;
var ptr = Module._malloc(size);
var memory = new Uint8Array( Module.buffer, ptr, size ); memory.set(newData);

newData — это наши данные в виде типизированного массива. Мы можем взять его длину и запросить выделение памяти нужного нам размера у модуля WebAssembly. Функция malloc вернет нам указатель, который является просто индексом массива, в котором содержится вся память WebAssembly. Со стороны JavaScript он выглядит просто как ArrayBuffer.

Несмотря на то, что операция set имеет семантику копирования, когда я смотрел на этот участок в профайлере, я не увидел долгого процесса. Следующим действием мы прорубуем окошко в этот ArrayBuffer нужного размера с определённого места и копируем туда наши данные. Я думаю, что браузер оптимизирует эту операцию с помощью move-семантики, то есть передает владение памятью от одного объекта другому.

И в нашем приложении мы также основываемся на move-семантике, чтобы экономить копирования памяти.

AdBlock

Интересная проблема, скорее, на сдачу, с Adblock. Оказывается в России все популярные блокировщики получают подписку на список RU Adlist, и в нем есть такое прекрасное правило, которое запрещает загрузку WebAssembly с сайтов третьей стороны. Например, с CDN.

Либо переименовать .wasm-файл, чтобы он не подходил под это правило. Выход — не использовать CDN, а хранить все на своем домене (нам это не подходит). Думаю, они оправдывают себя тем, что они борются с майнерами таким образом, правда, я не знаю, почему майнеры не могут догадаться переименовать файл. Можно ещё пойти на форум этих товарищей и попытаться убедить их убрать это правило.

Продакшен

В итоге, мы вышли в продакшен. Да, это было нелегко, это заняло 8 месяцев и хочется спросить себя, а стоило ли оно того. На мой взгляд — стоило:

Не нужно устанавливать

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

Единая кодовая база и отладка на разных платформах

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

Быстрый релиз

Мы получили быстрый релиз, так как можем релизиться как простое web-приложение и с каждым новым релизом обновлять C++ код. Это не сравнится с тем, как релизить новые плагины, мобильное приложение или SmartTV-приложение. Релиз зависит только от нас: когда захотим, тогда он и выйдет.

Быстрая обратная связь

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

Не у всех есть C++ приложение но, если оно у вас есть, и вы хотите, чтобы оно было в браузере — WebAssembly для вас стопроцентный use case. Я считаю, что все эти проблемы стоили этих плюсов.

Где применить

Не все пишут на С++. Но не только С++ доступен для WebAssembly. Да, это исторически самая первая платформа, которая была доступна ещё в asm.js — ранней технологии Mozilla. Кстати, поэтому она имеет довольно хорошие инструменты, т.к. они старше самой технологии.

Rust

Новый язык Rust, который также разрабатывает Mozilla, сейчас догоняет и перегоняет С++ в отношении инструментов. Все идет к тому, что они сделают самый классный процесс разработки под WebAssembly.

Lua, Perl, Python, PHP, etc.

Почти все языки, которые интерпретируются, уже тоже доступны в WebAssembly, так как их интерпретаторы написаны на С++, их просто скомпилировали в WebAssembly и теперь можно крутить PHP в браузере.

Go

В версии 1.11 они сделали бета-версию компиляции в WebAssembly, в 2.0 обещают релизную поддержку. У них поддержка появилась позже, так как WebAssembly не поддерживает garbage collector, а Go — язык с управляемой памятью. Поэтому им пришлось затаскивать свой garbage collector под WebAssembly.

Kotlin/Native

Примерно такая же история с Kotlin. Их компилятор имеет экспериментальную поддержку, но им также придется что-то сделать с garbage collector. Я пока не знаю, какой там статус.

3D-графика

Что ещё можно придумать? Первое, что вертится на языке — 3D-приложения. И, действительно, исторически asm.js и WebAssembly начались с портирования игр в браузеры. И неудивительно, что сейчас все популярные движки имеют экспорт в WebAssembly.

Обработка данных локально

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

Нейронные сети

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

Её можно подключить как npm-модуль и всё, вы используете Wasm, но работаете с обычным JS. Например, кусочек Google Chrome, который отвечает за определение языка текста, уже доступен как WebAssembly-библиотека. Вы не связываетесь с нейронными сетями, С++ или чем-то ещё — всё доступно из коробки.

Есть популярная библиотека проверки орфографии HunSpell — просто ставите и используете как Wasm модуль.

Криптография

Ну и первое правило криптографии — «Не пишите свою криптографию». Если хотите подписывать данные пользователя, что-то шифровать и передавать в таком виде на сервер, генерировать устойчивые пароли или нужен ГОСТ — подключите OpenSSL. Уже есть инструкция как скомпилировать под WebAssembly. OpenSSL — это надёжный код, проверенный тысячами приложений, не нужно ничего изобретать.

Вынос вычислений с сервера

Классный use case есть на сайте wotinspector.com. Это сервис для игроков World of Tanks. Вы можете загрузить свой реплей, проанализировать его, соберется статистика по игре, нарисуется красивая карта, в общем, для профессиональных игроков очень полезный сервис.

Если бы это происходило на сервере, наверняка это был бы закрытый платный сервис, доступный не всем. Одна проблема — анализ такого реплея занимает много ресурсов. Но автор этого сервиса, Андрей Карпушин, написал бизнес-логику на С++, скомпилировал её в WebAssembly, и теперь пользователь может запустить обработку прямо у себя в браузере (а на сервер отправить, чтобы другие пользователи также получили к ним доступ).

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

Библиотеки

Также в мире существует куча библиотек, написанных за многолетнюю историю на С, С++. Например, проект FFmpeg, который является лидером по обработке видео. Многие пользуются программами для обработки видео, где внутри ffmpeg. И вот его можно запустить в браузере и кодировать видео. Это будет долго и медленно, да, но если вы делаете сервис, который генерирует аватарки или трехсекундные видеоролики, то ресурсов браузера будет достаточно.

И библиотека OpenCV — лидер по машинному зрению, доступна в WebAssembly, можно делать распознавание лиц и управление жестами рук. Тоже самое с аудио — можно записывать в сжатый формат и отправлять на сервер уже маленькие файлики. Можно использовать файловую базу данных SQLite, которая поддерживает настоящий SQL. Можно работать с PDF. Портирование SQLite под WebAssembly сделал автор Emscripten, он наверняка тестировал компилятор на нём.

Node.js

Наверное, все знают Sass — препроцессор css. Не только браузер получает бонусы от WebAssembly, также можно использовать Node.js. Но никто не хочет запускать отдельную программу для обработки исходников, хочется встроиться в процесс сборки бандла Webpack’ом, а для этого нужен модуль для Node.js. Он был написан на Ruby, а затем для ускорения переписан на С++ (проект libsass). Проект node-sass решает эту задачу, является JS-обёрткой для этой библиотеки.

И это приводит нас к матрице версий. Библиотека нативная, это значит мы должны компилировать её под ту платформу, под которой пользователь будет её запускать. Эти столбики нужно перемножить:

Потом всё это нужно хранить, а это десятки мегабайт файлов на каждый (даже минорный) релиз. Это приводит к тому, что для одного релиза node-sass нужно сделать около 100 компиляций под каждую комбинацию из таблицы. Как WebAssembly решает эту проблему: он сворачивает всю таблицу в один файл, потому что исполняемый файл WebAssembly не зависит от платформы.

Такой проект уже есть, портированием под WebAssembly уже занимаются в проекте libsass-asm. Достаточно будет один раз скомпилировать код и загружать только один файл на все платформы независимо от архитектуры или версии Node. Это отличный шанс попрактиковаться с WebAssembly на реальном проекте… Работа ведётся недавно, и проекту очень нужны помощники для работы.

Ускорение приложений

Есть популярное приложение Figma — редактор графики для web-дизайнеров. Это в какой-то мере аналог Sketch, который работает на всех платформах, потому что запускается в браузере. Он написана на С++ (о чем мало кто знает), и там изначально использовали asm.js. Приложение очень большое, поэтому стартовало не быстро.

Это серьезное улучшение для редактора, который должен быть готов к работе как можно быстрее. Когда появился WebAssembly, разработчики перекомпилировали свои исходники, и старт приложения ускорился в 3 раза.

Пожалуй, разработчики контролируют только версию Node, но для поддержки платформ ОС и архитектур им приходится пересобирать эти модули. Другое знакомое всем приложение Visual Studio Code, несмотря на то, что работает в Electron, использует нативные модули для самых критичных участков кода, поэтому у них такая же проблема с огромным количеством версий, как у Node-sass. Поэтому, я уверен, не за горами тот день, когда они тоже перейдут на WebAssembly.

Портирование приложений в браузер

Софту уже 30 лет, он написан на С++, и это огромная кодовая база. Но самый крутой пример портирования кодовой базы — AutoCAD. Но теперь благодаря WebAssembly AutoCAD доступен как веб-сервис, где вы можете за 5 минут зарегистрироваться и начать им пользоваться. Продукт очень популярен в среде проектировщиков, чьи привычки давно устоялись, поэтому команде разработчиков пришлось бы совершить очень много работы по переносу всей накопившейся бизнес-логики на JavaScript, при портировании в браузер, что делало эту затею почти безнадёжной.

Я упоминал FFMpeg — это его проект, а другая его разработка — QEMU. Есть прикольная демка, которую сделал Фабрис Беллар, уникальный, по моему мнению, программист, поскольку он сделал много настолько популярных проектов, каких обычный программист делает, пожалуй, один за свою жизнь. Возможно, мало кто о нем слышал, но на нем основана система виртуализации KVM, которая уж точно является лидером в своей области.

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

Там есть bash, можно делать всё то, что и в обычном Linux. Можно отключить интернет, и он будет работать. В ней уже можно запустить настоящий браузер. Есть и другая демка — с GUI. К сожалению, в демке нет сети, и не получится открыть в ней саму себя…

Это Windows 2000, та самая, что была 18 лет назад, только сейчас она работает в вашем браузере. И, чтобы уж точно вас убедить, покажу что-то невероятное. Раньше нужен был целый компьютер, а теперь достаточно просто Chrome (или FireFox).

Как вы видите, применений WebAssembly масса, я перечислил только то, что нашёл сам, а у вас возникнут новые идеи, и вы сможете их реализовать.

Как это внедрить у себя

Я хочу дать несколько советов для тех, кто задумает портировать своё приложение под WebAssembly. Первое, с чего стоит начать — с команды, конечно же. Минимальная команда — два человека, один со стороны нативных технологий и фронтендер.

Поэтому наша задача, как фронтендеров, если мы оказываемся в таком проекте — взять на себя эту часть работы. Так бывает, что прикладные программисты на C++ не очень хорошо ориентируются в web-технологиях. Но идеальная команда — те люди, кто интересуются не только своей платформой, но и хотят разобраться в той, что по другую сторону компилятора.

Мой коллега Юра, большой специалист по C++, как выяснилось давно хотел изучить JavaScript, и книжка Флэнагана ему в этом очень помогла. По счастью, в нашем проекте вышло именно так. В итоге за время проекта мы много рассказывали друг другу о своих основных языках, и нашли удивительно много общего у JS и C++, каким бы странным это ни казалось. Я же взял томик Страуструпа, и с Юриной помощью начал вникать в азы C++.

И если у вас подберётся именно такая команда — это будет идеально.

CI Pipeline

Как выглядел наш ежедневный процесс разработки? Мы вынесли все JS-артефакты в отдельный репозиторий, чтобы было удобнее настроить там сборку через Webpack. Когда появляются изменения в нативном коде, мы подтягиваем их, компилируем (порой это занимает больше всего времени), и результат компиляции копируется в проект JS. Дальше его подхватывает webpack в режиме watch, собирает бандл, и мы можем запускаем приложение в браузере или прогонять тесты.

Отладка

Конечно же, при разработке нам важна отладка. С этим, к сожалению, пока не очень хорошо.

Мы видим точки останова (можем остановить браузер в каком-то месте), но, к сожалению, код видим в текстовом представлении ассемблера. Нужно в Chrome включить эксперименты DevTools, и мы увидим на закладке Sources папку с wasm-юнитами.

В общем, Коля умеет писать под embedded-системы, а мы не умеем, и хотели бы какой-то явной привязки к исходному коду. Хотя наш архитектор Коля, когда в первый раз посмотрел на эту картину, пробежался глазами по листингу и сказал: «Смотрите, да это же стековая машина, вот, тут с памятью работаем, тут арифметика, всё ж понятно!».

Есть небольшой трюк: на максимальном уровне отладки -g4 в wast-файле появляются дополнительные комментарии, и выглядит это вот так.

Цифры — номера модулей, которые мы уже видели в консоли Chrome. Вам нужен редактор, который сможет открыть файл размером 100 мегабайт (мы выбрали FAR). С этим можно жить, но хотелось бы увидеть настоящий С++ код прямо в отладчике браузера. E:/_work/bfg/bytefrog/… — ссылка на исходный код. И это звучит, как задача для SourceMap!

SourceMap

К сожалению, с нимипока есть проблемы.

  • Работает только в Firefox.
  • --sourcemap-base=http://localhost опцией указываем, что надо сгенерировать SourceMap и адрес веб-сервера, где будут храниться исходники.
  • Доступ к исходникам по HTTP.
  • Пути к файлам исходников должны быть относительные.
  • На Windows есть проблема с «:» в путях. Все пути обрезаются до двоеточия.

CMake при сборке приводит все пути к абсолютному виду, в результате файлы невозможно найти по такому URL на веб-сервере. Последние два пункта затронули нас. Думаю, вы с таким не столкнётесь. Мы решили это так: предобрабатываем wast-файл и все пути приводим к относительному виду, убирая заодно и двоеточия.

В итоге, выглядит это следующим образом:

Теперь мы видели всё! Код С++ в отладчике браузера. К сожалению, если дотронуться до любого wasm-вызова в stack trace, провалимся в ассемблер, это досадный баг, который, думаю, будет исправен. Слева дерево исходников, есть точки останова, видим stack trace, который нас привел к этой точке.

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

Но мы можем привязать их к конкретному месту ассемблера по сгенерированному имени «var0».

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

Профайлер

Также можно взглянуть на профайлер. Он работает и в Chrome, и в Firefox. В Firefox получше — он «разматывает» имена, и их видно так, как они есть в исходном коде.

Chrome их немного кодирует (для тех, кто понимает, это Mangled имена функций), но, если прищуриться, можно понять, к чему они относятся.

Производительность

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

  • Рантайм. Замер производительности зависит от runtime, который вы используете. Замеры в С++ будут отличаться от замеров в Rust или Go.
  • Потери на границе JS — Wasm. Измерять математику не имеет смысла, потому что потери производительности происходят на пересечении границы JS и Wasm. Чем больше вы делаете вызовов туда-сюда, чем больше перебрасываете объектов, тем сильнее проседает скорость. Браузеры сейчас работают над этой проблемой, и постепенно ситуация улучшается.
  • Технология развивается. Те замеры, которые сделали сегодня, не будут иметь смысла завтра, а уж тем более через пару месяцев.
  • Wasm ускоряет старт приложения. Wasm не обещает, что ускорит ваш код или заменит JS. Команда WebAssembly сфокусирована на том, чтобы ускорять запуск больших кодовых баз приложений.
  • В синтетике вы получаете скорость на уровне JS.

Мы сделали простой тест: графические фильтры для изображения.

  • wasp_cpp_bench
  • Chrome 65.0.3325.181 (64-bit)
  • Core i5-4690
  • 24gb ram
  • 5 замеров; отброшены max и min; усреднение

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

Это видно на примере фильтра Grayscale. С++, скомпилированный без оптимизации, ведет себя каким-то странным образом. Но когда включается оптимизация (зеленый столбик), мы получаем время, практически совпадающее с JS. Даже наши C++ разработчики не смогли объяснить, почему именно так. И, забегая вперед, мы получаем аналогичные результаты в нативном коде, если скомпилируем С++, как нативное приложение.

Сбор сбоев и ошибок

Мы используем Sentry, и с ним есть проблема — из стектрейсов пропадают фреймы wasm. Оказалось, что библиотека traceKit, которую использует клиент Sentry — Raven, — просто содержит регулярное выражение, в котором не учтено, что wasm существует. Мы сделали патч, и, наверное, скоро его отправим pull request, а пока применяем при npm install нашего JS-проекта.

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

Итого

  • WebAssembly уже можно использовать в бою, и наш проект это доказал.
  • Портировать даже большое приложение — реально. У нас это заняло 8 месяцев, львиную долю которого мы потратили на рефакторинг своего приложения на C++, чтобы выделить границы, интерфейсы и так далее.
  • Инструменты пока слабые, но работа в этом направлении ведется, так как WebAssembly — на самом деле будущее веба.
  • Скорость — на уровне JS. Современные JS-машины оптимизируют программный код до такой степени, что он просто «проваливается» в машинные инструкции, и выполняется с той скоростью, с которой может ваш процессор.

Если возьметесь за работу, рекомендую:

  • Берите Emscripten и Embind. Это хорошие и рабочие технологии.
  • Если понадобится что-то странное в Emscripten — загляните в тесты. Документация есть, но охватывает не всё, а файл тестов содержит 3000 строк всех возможных ситуаций использования Emscripten.
  • Для сбора ошибок подойдет Sentry.
  • Отлаживайте в Firefox.

Я готов ответить на ваши вопросы. Спасибо за внимание!

На сайте конференции уже есть описания части докладов (например, приедет создатель Node.js Ryan Dahl!), там же можно приобрести билеты — и с 1 марта они подорожают. Если вам понравился этот доклад с конференции HolyJS, обратите внимание: 24-25 мая в Петербурге состоится следующая HolyJS.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть