Анонс WebAssembly состоялся в 2015-м — но сейчас, спустя годы, всё ещё немногие могут похвастаться им в продакшне. Тем ценнее материалы о подобном опыте: информация из первых рук о том, каково с этим жить на практике, пока что в дефиците.
На конференции HolyJS доклад об опыте использования WebAssembly получил высокие оценки зрителей, и теперь специально для Хабра подготовлена текстовая версия этого доклада (видеозапись также приложена).
Меня зовут Андрей, я расскажу вам про WebAssembly. Можно сказать, что я начал заниматься вебом в прошлом веке, но я скромный, поэтому так говорить не буду. За это время я успел поработать и над бэкендом, и над фронтендом, и даже немножко рисовал дизайн. Сегодня я интересуюсь такими вещами, как WebAssembly, C++ и прочими нативными штуками. Еще я очень люблю типографику и собираю старую технику.
Сначала я расскажу о том, как мы с командой внедряли WebAssembly в нашем проекте, потом мы обсудим, нужно ли вам что-то от WebAssembly, и закончим несколькими советами на случай, если вы захотите внедрить это у себя.
Как мы внедряли WebAssembly
Я работаю в компании Inetra, мы находимся в Новосибирске и делаем несколько собственных проектов. Один из них — ByteFog. Это технология peer-to-peer доставки видео пользователям. Нашими клиентами являются сервисы, которые раздают огромное количество видео. У них есть проблема: когда случается какое-то популярное событие, например, чья-то пресс-конференция или какое-то спортивное событие, как к нему не готовься, приходит куча клиентов, наваливается на сервер, и сервер грустит. Клиенты в это время получают очень плохое качество видео.
Но ведь все смотрят один и тот же контент. Давайте попросим соседние устройства пользователей поделиться кусочками видео, и тогда мы разгрузим сервер, сэкономим полосу, а пользователи получат видео в лучшем качестве. Вот эти облачка — наша технология, наш прокси-сервер ByteFog.
Мы должны быть установлены в каждом устройстве, которое умеет показывать видео, поэтому поддерживаем очень широкий спектр платформ: Windows, Linux, Android, iOS, Web, Tizen. Какой язык выбрать, чтобы иметь единую кодовую базу на всех этих платформах? Мы выбрали C++, потому что у него оказалась больше всего плюсов :-D Если серьёзнее, мы имеем хорошую экспертизу по C++, это действительно быстрый язык, и в портативности он уступает, наверное, только С.
У нас получилось довольно большое приложение (900 классов), но оно отлично работает. Под Windows и Linux мы компилируемся в нативный код. Под Android и iOS мы собираем библиотеку, которую подключаем к приложению. Про Tizen поговорим в другой раз, а вот на Web мы раньше работали как плагин к браузеру.
Это технология Netscape Plugin API. Как видно из названия, она довольно старая, а также имеет недостаток: дает очень широкий доступ к системе, так что пользовательский код может вызвать проблему с безопасностью. Наверное, поэтому в 2015 году Chrome отключил поддержку этой технологии, и следом все браузеры присоединились к этому флешмобу. Так мы остались без веб-версии почти на два года.
В 2017 году забрезжила новая надежда. Как вы догадываетесь, это WebAssembly. В итоге мы поставили перед собой задачу портировать наше приложение в браузер. Поскольку к весне уже появилась поддержка Firefox и Chrome, а к осени 2017 года подтянулись Edge и Safari.
Использовать готовый код нам было важно, так как у нас много бизнес-логики, которую не хотелось двоить, чтобы не удвоить количество багов. Берем компилятор Emscripten. Он делает то, что нам нужно, — компилирует плюсовое приложение в браузер и воссоздает среду, привычную нативному приложению в браузере. Можно сказать, что Emscripten — это такой Browserify для C++ кода. Также он позволяет пробрасывать объекты из C++ в JavaScript и наоборот. Первая наша мысль была: сейчас возьмем Emscripten, просто скомпилируем, и все заработает. Конечно же, вышло не так. С этого начался наш путь по граблям.
Первое, с чем мы столкнулись, — зависимости. В нашей кодовой базе было несколько библиотек. Сейчас их перечислять нет смысла, но для тех, кто понимает, у нас есть Boost. Это большая библиотека, которая позволяет писать кроссплатформенный код, но с ней очень сложно настроить компиляцию. Хотелось как можно меньше кода тащить в браузер.
Архитектура Bytefog
В итоге мы выделили ядро: можно сказать, что это прокси-сервер, в котором содержится основная бизнес-логика. Этот прокси-сервер берет данные по двум источникам. Первый и основной — это HTTP, то есть канал к серверу раздачи видео, второй — наша P2P сеть, то есть канал до другого такого же прокси у какого-то другого пользователя. Отдаем мы данные в первую очередь плееру, так как наша задача — показать качественный контент пользователю. Если остаются ресурсы, мы раздаем контент в P2P сеть, чтобы другие пользователи могли его скачать. Внутри находится умный кэш, который и делает всю магию.
Скомпилировав это всё, мы столкнулись с тем, что WebAssembly выполняется в песочнице браузера. А значит, не может большего, чем дает JavaScript. В то время как нативные приложения используют много платформозависимых вещей, таких как файловая система, сеть или случайные числа. Все эти возможности придется реализовать на JavaScript с помощью того, что дает нам браузер. В этой табличке перечисленные достаточно очевидные замены.
Чтобы это стало возможным, необходимо в нативном приложении отпилить реализацию нативных возможностей и вставить там интерфейс, то есть провести некоторую границу. Затем вы реализуете это на JavaScript и оставляете нативную реализацию, а уже при сборке выбирается нужное. Итак, мы посмотрели на нашу архитектуру и нашли все места, где можно провести эту границу. Так совпало, что это транспортная подсистема.
Для каждого такого места мы определили спецификацию, то есть зафиксировали контракт: какие будут методы, какие у них будут параметры, какие типы данных. Как только вы это сделали, можно работать параллельно, каждый разработчик на своей стороне.
Что получилось в итоге? Основной канал доставки видео от провайдера мы заменили на обычный AJAX. К плееру мы выдаем данные через популярную библиотеку HLS.js, но есть принципиальная возможность интегрироваться с другими плеерами, если это будет нужно. Весь P2P-слой мы заменили на WebRTC.
В результате компиляции получается несколько файлов. Самый главный — двоичный .wasm. В нем содержится скомпилированный байт-код, который браузер будет выполнять и в котором содержится все ваше наследство C++. Но сам по себе он не работает, необходим, так называемый «клеевой код», его также генерирует компилятор. Клеевой код занимается загрузкой двоичного файла, и оба этих файла вы выкладываете на продакшен. Для целей отладки можно сгенерировать текстовое представление ассемблера — .wast-файл и sourcemap. Нужно понимать, что они могут быть очень большого размера. В нашем случае достигали 100 мегабайт и более.
Собираем бандл
Рассмотрим клеевой код поближе. Это обычный старый-добрый ES5, собранный в один файл. Когда мы его подключаем на веб-страницу, у нас появляется глобальная переменная, в которой содержится весь наш инстанциированный wasm-модуль, который готов принимать запросы в свое API.
Но подключать отдельный файл — это достаточно серьезное усложнение для библиотеки, которую будут использовать пользователи. Мы хотели бы собрать все в единый бандл. Для этого мы используем Webpack и специальную опцию компиляции MODULARIZE.
Она оборачивает клеевой код в паттерн «Модуль», и мы можем подцепить его: импортировать или использовать require, если мы пишем на ES5, — Webpack спокойно понимает эту зависимость. Возникла проблема с Babel, — ему не понравился большой объем кода, но это ES5-код, его не нужно транспилировать, мы просто добавляем его в игнор.
В погоне за количеством файлов я решил использовать опцию SINGLE_FILE. Все двоичные файлы, которые получились при компиляции, она переводит в Base64-вид и заталкивает в клеевой код в виде строки. Звучит как отличная идея, но после этого бандл у нас стал размером 100 мегабайт. На таком объеме ни Webpack, ни Babel, ни даже браузер не работают. Да и вообще, не будем же мы заставлять пользователя грузить 100 мегабайт?!
Если задуматься, то эта опция не нужна. Клеевой код самостоятельно загружает двоичные файлы. Делает он это по HTTP, значит мы из коробки получаем кэширование, можем выставить любые заголовки, которые хотим, например, включить сжатие, а WebAssembly-файлы отлично сжимаются.
Но самая крутая технология — это потоковая компиляция. То есть WebAssembly-файл, пока скачивается с сервера, может уже компилироваться в браузере по мере поступления данных, и это очень ускоряет загрузку вашего приложения. Вообще вся технология WebAssembly имеет фокус на быстром старте большой кодовой базы.
Thenable
Другая проблема с модулем — то, что он является объектом Thenable, то есть имеет метод .then(). Эта функция позволяет навесить callback на момент старта модуля, и это очень удобно. Но хотелось бы, чтобы интерфейс соответствовал Promise. Thenable — это не Promise, но ничего страшного, обернем сами. Напишем такой простой код:
return new Promise((resolve, reject) => {
Module(config).then((module) => {
resolve(module);
});
});
Создаем 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. Когда мы резолвим Promise с помощью Thenable-объекта, браузер будет разворачивать значения из этого Thenable-объекта, и для этого он вызовет метод .then(). В итоге мы резолвим Promise, передаем ему модуль. Браузер спрашивает: Thenable ли это объект? Да, это Thenable-объект. Тогда у модуля вызывается функция .then(), и в качестве callback передается сама функция resolve.
Модуль проверяет, запущен ли он. Он уже запущен, поэтому callback вызывается сразу же, и ему передается снова этот же модуль. В качестве callback у нас функция resolve, и браузер спрашивает: это Thenable-объект? Да, это 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 :-D
Схема такая:
На стороне C++ есть ядро, которому мы хотим дать доступ, например, во внешнюю сеть — покачать видео. Раньше оно это делало с помощью нативных сокетов, был какой-то HTTP-клиент, который это делал, но в WebAssembly нет нативных сокетов. Нужно как-то выкручиваться, поэтому мы отрезаем старый HTTP-клиент, в это место вставляем интерфейс, и реализацию этого интерфейса делаем в JavaScript с помощью обычного AJAX, любым способом. После этого полученный объект мы передадим обратно в C++, где его будет использовать ядро.
Сделаем простейший HTTP-клиент, который может делать только get-запросы:
class HTTPClient {
public:
virtual std::string get(std::string url) = 0;
};
На вход он принимает строку с URL-адресом, который надо скачать, и на выход
строку с результатом запроса. В C++ строки могут иметь двоичные данные, поэтому для видео это подходит. Emscripten заставляет нас написать вот
такой страшный Wrapper:
В нем главное — две вещи — имя функции на стороне C ++ (я обозначил их зеленым цветом), и соответствующие им имена на стороне JavaScript, (их обозначил синим). В итоге мы пишем декларацию связи:
Она работает как кубики Lego, из которых мы её собираем. У нас есть класс, у этого класса есть метод, и мы хотим наследоваться от этого класса, чтобы реализовать интерфейс. Это все. Мы идем в JavaScript и наследуемся. Это можно сделать двумя путями. Первый — extend. Это очень похоже на старый добрый extend из Backbone.
В модуле содержится все, что накомпилировал Emscripten, и в нем есть свойство с экспортированным интерфейсом. Мы вызываем метод extend и передаем туда объект с реализацией этого метода, то есть в функции get будет реализован какой-то способ
получения информации с помощью AJAX.
На выходе extend дает нам обычный JavaScript-конструктор. Мы можем вызывать его сколько угодно раз и сгенерировать объекты в том количестве, которое нам необходимо. Но бывает ситуация, когда у нас есть один объект, и мы хотим его просто передать на сторону C++.
Для этого нужно как-то привязать этот объект к типу, который поймет C++. Это и делает функция implement. На выходе она дает не конструктор, а уже готовый к употреблению объект, наш клиент, который мы можем отдать обратно в 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.
Следующим действием мы прорубуем окошко в этот ArrayBuffer нужного размера с определённого места и копируем туда наши данные. Несмотря на то, что операция set имеет семантику копирования, когда я смотрел на этот участок в профайлере, я не увидел долгого процесса. Я думаю, что браузер оптимизирует эту операцию с помощью move-семантики, то есть передает владение памятью от одного объекта другому.
И в нашем приложении мы также основываемся на move-семантике, чтобы экономить копирования памяти.
AdBlock
Интересная проблема, скорее, на сдачу, с Adblock. Оказывается в России все популярные блокировщики получают подписку на список RU Adlist, и в нем есть такое прекрасное правило, которое запрещает загрузку WebAssembly с сайтов третьей стороны. Например, с CDN.
Выход — не использовать CDN, а хранить все на своем домене (нам это не подходит). Либо переименовать .wasm-файл, чтобы он не подходил под это правило. Можно ещё пойти на форум этих товарищей и попытаться убедить их убрать это правило. Думаю, они оправдывают себя тем, что они борются с майнерами таким образом, правда, я не знаю, почему майнеры не могут догадаться переименовать файл.
Продакшен
В итоге, мы вышли в продакшен. Да, это было нелегко, это заняло 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.
Обработка данных локально
Можно ещё придумать обработку данных пользователя прямо у него в браузере, на его компьютере: взять загруженное изображение или с камеры, записать звук, обработать видео. Прочитать загруженный пользователем архив, или собрать его самостоятельно из пачки файлов и загрузить на сервер одним запросом.
Нейронные сети
На этой картинке изображены практически все архитектуры нейронных сетей. И, действительно, вы можете взять свою нейронную сеть, обучить и отдать на клиента, чтобы она обрабатывала живой поток с видеокамеры или микрофона. Или, например, отслеживать передвижение мышки пользователя и сделать управление жестами; распознавание лиц — возможности почти безграничны.
Например, кусочек Google Chrome, который отвечает за определение языка текста, уже доступен как WebAssembly-библиотека. Её можно подключить как npm-модуль и всё, вы используете Wasm, но работаете с обычным JS. Вы не связываетесь с нейронными сетями, С++ или чем-то ещё — всё доступно из коробки.
Есть популярная библиотека проверки орфографии HunSpell — просто ставите и используете как Wasm модуль.
Криптография
Ну и первое правило криптографии — «Не пишите свою криптографию». Если хотите подписывать данные пользователя, что-то шифровать и передавать в таком виде на сервер, генерировать устойчивые пароли или нужен ГОСТ — подключите OpenSSL. Уже есть инструкция как скомпилировать под WebAssembly. OpenSSL — это надёжный код, проверенный тысячами приложений, не нужно ничего изобретать.
Вынос вычислений с сервера
Классный use case есть на сайте wotinspector.com. Это сервис для игроков World of Tanks. Вы можете загрузить свой реплей, проанализировать его, соберется статистика по игре, нарисуется красивая карта, в общем, для профессиональных игроков очень полезный сервис.
Одна проблема — анализ такого реплея занимает много ресурсов. Если бы это происходило на сервере, наверняка это был бы закрытый платный сервис, доступный не всем. Но автор этого сервиса, Андрей Карпушин, написал бизнес-логику на С++, скомпилировал её в WebAssembly, и теперь пользователь может запустить обработку прямо у себя в браузере (а на сервер отправить, чтобы другие пользователи также получили к ним доступ).
Это интересный кейс с точки зрения монетизации сайта. Вместо того, чтобы брать деньги с пользователей, мы используем ресурсы их компьютера. Это похоже на монетизацию с помощью майнера. Но в отличие от майнера, который просто жжёт электроэнергию пользователей, а взамен приносит авторам сайта копейки, мы делаем сервис, который производит реально нужную пользователю работу. То есть пользователь согласен делиться с нами ресурсами. Поэтому эта схема работает.
Библиотеки
Также в мире существует куча библиотек, написанных за многолетнюю историю на С, С++. Например, проект FFmpeg, который является лидером по обработке видео. Многие пользуются программами для обработки видео, где внутри ffmpeg. И вот его можно запустить в браузере и кодировать видео. Это будет долго и медленно, да, но если вы делаете сервис, который генерирует аватарки или трехсекундные видеоролики, то ресурсов браузера будет достаточно.
Тоже самое с аудио — можно записывать в сжатый формат и отправлять на сервер уже маленькие файлики. И библиотека OpenCV — лидер по машинному зрению, доступна в WebAssembly, можно делать распознавание лиц и управление жестами рук. Можно работать с PDF. Можно использовать файловую базу данных SQLite, которая поддерживает настоящий SQL. Портирование SQLite под WebAssembly сделал автор Emscripten, он наверняка тестировал компилятор на нём.
Node.js
Не только браузер получает бонусы от WebAssembly, также можно использовать Node.js. Наверное, все знают Sass — препроцессор css. Он был написан на Ruby, а затем для ускорения переписан на С++ (проект libsass). Но никто не хочет запускать отдельную программу для обработки исходников, хочется встроиться в процесс сборки бандла Webpack’ом, а для этого нужен модуль для Node.js. Проект node-sass решает эту задачу, является JS-обёрткой для этой библиотеки.
Библиотека нативная, это значит мы должны компилировать её под ту платформу, под которой пользователь будет её запускать. И это приводит нас к матрице версий. Эти столбики нужно перемножить:
Это приводит к тому, что для одного релиза node-sass нужно сделать около 100 компиляций под каждую комбинацию из таблицы. Потом всё это нужно хранить, а это десятки мегабайт файлов на каждый (даже минорный) релиз. Как WebAssembly решает эту проблему: он сворачивает всю таблицу в один файл, потому что исполняемый файл WebAssembly не зависит от платформы.
Достаточно будет один раз скомпилировать код и загружать только один файл на все платформы независимо от архитектуры или версии Node. Такой проект уже есть, портированием под WebAssembly уже занимаются в проекте libsass-asm. Работа ведётся недавно, и проекту очень нужны помощники для работы. Это отличный шанс попрактиковаться с WebAssembly на реальном проекте…
Ускорение приложений
Есть популярное приложение Figma — редактор графики для web-дизайнеров. Это в какой-то мере аналог Sketch, который работает на всех платформах, потому что запускается в браузере. Он написана на С++ (о чем мало кто знает), и там изначально использовали asm.js. Приложение очень большое, поэтому стартовало не быстро.
Когда появился WebAssembly, разработчики перекомпилировали свои исходники, и старт приложения ускорился в 3 раза. Это серьезное улучшение для редактора, который должен быть готов к работе как можно быстрее.
Другое знакомое всем приложение Visual Studio Code, несмотря на то, что работает в Electron, использует нативные модули для самых критичных участков кода, поэтому у них такая же проблема с огромным количеством версий, как у Node-sass. Пожалуй, разработчики контролируют только версию Node, но для поддержки платформ ОС и архитектур им приходится пересобирать эти модули. Поэтому, я уверен, не за горами тот день, когда они тоже перейдут на WebAssembly.
Портирование приложений в браузер
Но самый крутой пример портирования кодовой базы — AutoCAD. Софту уже 30 лет, он написан на С++, и это огромная кодовая база. Продукт очень популярен в среде проектировщиков, чьи привычки давно устоялись, поэтому команде разработчиков пришлось бы совершить очень много работы по переносу всей накопившейся бизнес-логики на JavaScript, при портировании в браузер, что делало эту затею почти безнадёжной. Но теперь благодаря WebAssembly AutoCAD доступен как веб-сервис, где вы можете за 5 минут зарегистрироваться и начать им пользоваться.
Есть прикольная демка, которую сделал Фабрис Беллар, уникальный, по моему мнению, программист, поскольку он сделал много настолько популярных проектов, каких обычный программист делает, пожалуй, один за свою жизнь. Я упоминал FFMpeg — это его проект, а другая его разработка — QEMU. Возможно, мало кто о нем слышал, но на нем основана система виртуализации KVM, которая уж точно является лидером в своей области.
Беллард с 2011 года поддерживает порт QEMU для браузера. Это значит, что вы можете запустить любую систему с помощью эмулятора напрямую в своем браузере. В общем, Linux с консолью, настоящим Linux-ядром, работающим в браузере без сервера, какой-то дополнительной связи.
Можно отключить интернет, и он будет работать. Там есть bash, можно делать всё то, что и в обычном Linux. Есть и другая демка — с GUI. В ней уже можно запустить настоящий браузер. К сожалению, в демке нет сети, и не получится открыть в ней саму себя…
И, чтобы уж точно вас убедить, покажу что-то невероятное. Это Windows 2000, та самая, что была 18 лет назад, только сейчас она работает в вашем браузере. Раньше нужен был целый компьютер, а теперь достаточно просто Chrome (или FireFox).
Как вы видите, применений WebAssembly масса, я перечислил только то, что нашёл сам, а у вас возникнут новые идеи, и вы сможете их реализовать.
Как это внедрить у себя
Я хочу дать несколько советов для тех, кто задумает портировать своё приложение под WebAssembly. Первое, с чего стоит начать — с команды, конечно же. Минимальная команда — два человека, один со стороны нативных технологий и фронтендер.
Так бывает, что прикладные программисты на C++ не очень хорошо ориентируются в web-технологиях. Поэтому наша задача, как фронтендеров, если мы оказываемся в таком проекте — взять на себя эту часть работы. Но идеальная команда — те люди, кто интересуются не только своей платформой, но и хотят разобраться в той, что по другую сторону компилятора.
По счастью, в нашем проекте вышло именно так. Мой коллега Юра, большой специалист по C++, как выяснилось давно хотел изучить JavaScript, и книжка Флэнагана ему в этом очень помогла. Я же взял томик Страуструпа, и с Юриной помощью начал вникать в азы C++. В итоге за время проекта мы много рассказывали друг другу о своих основных языках, и нашли удивительно много общего у JS и C++, каким бы странным это ни казалось.
И если у вас подберётся именно такая команда — это будет идеально.
CI Pipeline
Как выглядел наш ежедневный процесс разработки? Мы вынесли все JS-артефакты в отдельный репозиторий, чтобы было удобнее настроить там сборку через Webpack. Когда появляются изменения в нативном коде, мы подтягиваем их, компилируем (порой это занимает больше всего времени), и результат компиляции копируется в проект JS. Дальше его подхватывает webpack в режиме watch, собирает бандл, и мы можем запускаем приложение в браузере или прогонять тесты.
Отладка
Конечно же, при разработке нам важна отладка. С этим, к сожалению, пока не очень хорошо.
Нужно в Chrome включить эксперименты DevTools, и мы увидим на закладке Sources папку с wasm-юнитами. Мы видим точки останова (можем остановить браузер в каком-то месте), но, к сожалению, код видим в текстовом представлении ассемблера.
Хотя наш архитектор Коля, когда в первый раз посмотрел на эту картину, пробежался глазами по листингу и сказал: «Смотрите, да это же стековая машина, вот, тут с памятью работаем, тут арифметика, всё ж понятно!». В общем, Коля умеет писать под embedded-системы, а мы не умеем, и хотели бы какой-то явной привязки к исходному коду.
Есть небольшой трюк: на максимальном уровне отладки -g4 в wast-файле появляются дополнительные комментарии, и выглядит это вот так.
Вам нужен редактор, который сможет открыть файл размером 100 мегабайт (мы выбрали FAR). Цифры — номера модулей, которые мы уже видели в консоли Chrome. E:/_work/bfg/bytefrog/… — ссылка на исходный код. С этим можно жить, но хотелось бы увидеть настоящий С++ код прямо в отладчике браузера. И это звучит, как задача для SourceMap!
SourceMap
К сожалению, с нимипока есть проблемы.
- Работает только в Firefox.
- --sourcemap-base=http://localhost опцией указываем, что надо сгенерировать SourceMap и адрес веб-сервера, где будут храниться исходники.
- Доступ к исходникам по HTTP.
- Пути к файлам исходников должны быть относительные.
- На Windows есть проблема с «:» в путях. Все пути обрезаются до двоеточия.
Последние два пункта затронули нас. CMake при сборке приводит все пути к абсолютному виду, в результате файлы невозможно найти по такому URL на веб-сервере. Мы решили это так: предобрабатываем wast-файл и все пути приводим к относительному виду, убирая заодно и двоеточия. Думаю, вы с таким не столкнётесь.
В итоге, выглядит это следующим образом:
Код С++ в отладчике браузера. Теперь мы видели всё! Слева дерево исходников, есть точки останова, видим stack trace, который нас привел к этой точке. К сожалению, если дотронуться до любого wasm-вызова в 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. Даже наши C++ разработчики не смогли объяснить, почему именно так. Но когда включается оптимизация (зеленый столбик), мы получаем время, практически совпадающее с JS. И, забегая вперед, мы получаем аналогичные результаты в нативном коде, если скомпилируем С++, как нативное приложение.
Сбор сбоев и ошибок
Мы используем 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.
Спасибо за внимание! Я готов ответить на ваши вопросы.
Если вам понравился этот доклад с конференции HolyJS, обратите внимание: 24-25 мая в Петербурге состоится следующая HolyJS. На сайте конференции уже есть описания части докладов (например, приедет создатель Node.js Ryan Dahl!), там же можно приобрести билеты — и с 1 марта они подорожают.
Автор: Andrey Nagikh