Закон Атвуда гласит, что любое приложение, которое можно написать на Javascript, однажды напишут на Javascript. Компилятор Emscripten делает это практически неизбежным.

 

Интро

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

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

Кроме того, до эмуляции железа нужно ещё добраться. Это совсем не простая задача, и тот факт, что она зачастую уже решена (просто не на нужном Javascript, а на другом языке программирования), вовсе не прибавляет энтузиазма. Переписывать десятки, а то и сотни тысяч строк кода с Си на Javascript — удовольствие на любителя. Люди, которым интересен этот процесс, безусловно, встречаются, но куда реже тех, кто предпочитает результат.

 

Frontend и Backend

Один из создателей Javascript однажды заметил, что этот язык превратился в своего рода машинный код для интернета. Существуют компиляторы, которые переводят в Javascript программы, написанные на Python, Java и Smalltalk. Некоторые языки с самого начала рассчитаны на переработку в Javascript — к этой категории относятся Coffeescript и используемый React.js формат JSX.

У подобного подхода долгая история, которая началась задолго до появления Javascript и даже браузеров. В прошлом компиляторы многих языков программирования не могли генерировать машинный код. Результатом их работы были промежуточные исходники на Си. Это позволяло без особых усилий переносить языки на любую платформу, где есть стандартный компилятор Си.

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

Так устроен, в частности, популярный компилятор LLVM, фронтенды которого понимают большинство распространённых языков программирования. Результат работы фронтенда — байт-код для виртуальной машины, напоминающий ассемблер несуществующего RISC-процессора с бесконечным числом регистров и сильной типизацией данных. Бэкенды LLVM поддерживают, среди прочего, системы команд процессоров x86, ARM, MIPS, PowerPC и даже мейнфреймов IBM.

Бэкенд LLVM, способный генерировать Javascript вместо машинного кода x86 или ARM — настолько очевидная идея, что его появление было вопросом времени. Самый популярный проект такого рода был начат одним из инженеров Mozilla около пяти лет назад. Он называется Emscripten.

 

Emscripten

Emscripten представляет собой компилятор Си, Си++ и других языков, поддерживаемых LLVM, в Javascript, пригодный для исполнения в браузере. Этот проект также включает в себя реализацию распространённых библиотек для работы с трехмерной графикой, звуком и вводом-выводом на базе браузерных программных интерфейсов.

На что похож Javascript, который получается в результате работы Emscripten? Во многих случаях аналогии между Си и Javascript предельно прозрачны. Переменные есть переменные, функции есть функции, ветвление есть ветвление. Циклы или выбор switch...case в Javascript приходится записывать иначе, но суть та же. Кое-чем приходится жертвовать: например, на все разновидности числовых типов Си у Javascript один ответ — float. Но, в конечном счёте, это почти ни на что не влияет.

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

Код на Javascript непрерывно следит за тем, чтобы не покинуть отведённые массиву рамки, не выходить за пределы массивов, не переполнить стек. Это тратит уйму ресурсов, но даже с учётом всех накладных расходов программы, скомпилированные Emscripten, работали всего в несколько раз медленнее, чем те же исходники, скомпилированные в машинные коды. А в 2013 году у авторов проекта появилась возможность избавиться и от этих помех.

 

Asm.js

Недостающий компонент, который позволяет добиться максимальной скорости, называется asm.js. Спецификация asm.js задаёт упрощённое подмножество Javascript, которое может быть передано для исполнения более примитивному и потому очень быстрому интерпретатору. Оно идеально подходит в качестве промежуточного языка для таких генераторов кода, как Emscripten. Поддержка asm.js уже есть в браузерах Google, Mozilla и даже Microsoft.

Рассмотрим пример кода, написанного для asm.js:

function GeometricMean(stdlib, foreign, buffer) {
    "use asm";
    var values = new stdlib.Float64Array(buffer);
    function logSum(start, end) {
        start = start|0;
        end = end|0;
        var sum = 0.0, p = 0, q = 0;
        for (p = start << 3, q = end << 3;
             (p|0) < (q|0);
             p = (p + 8)|0) {
            return +sum;
        }
    function geometricMean(start, end) {
        start = start|0;
        end = end|0;
        return +stdlib.Math.exp(+logSum(start, end) +((end - start)|0));
    }
    return { geometricMean: geometricMean };
}

Вот первое, что бросается в глаза: это обычный Javascript. Ему определённо не нужен ни специальный интерпретатор, ни предварительная обработка. Судя по всему, он заработает и без них. Не так быстро, как в интерпретаторе, знающем о существовании asm.js, но заработает.

Строка use asm, открывающая функцию, уведомляет интерпретатор, что её содержимое может считаться модулем asm.js. Каждая такая функция должна удовлетворять множеству требований.

Первое требование: функция должна принимать три аргумента. Первый аргумент (в примере он называется stdlib, но названия могут быть и другими) — это объект, содержащий функции стандартной библиотеки Javascript (она состоит преимущественно из математики и типизированных массивов). Второй (foreign) содержит все остальные внешние функции, к которым допустимо обращаться из модуля. Третий (buffer) — это объект ArrayBuffer. В asm.js он заменяет динамически выделяемую память. Для доступа к ней используются типизированные отображения TypedArray, такие как Int32Array или Float64Array. Мы видим, как это происходит в следующей за use asm строчке: программа создаёт отображение buffer, которое состоит из элементов величиной восемь байтов, интерпретируемых как числа с плавающей точкой:

var values = new stdlib.Float64Array(buffer);

Следующая странность — операции битового ИЛИ едва ли не в каждой строчке. Объяснение простое: это костыли, помогающие обойтись без встроенных средств декларации типа. Чтобы гарантировать верные типы переменных, asm.js предписывает конвертировать их принудительно. Если а — аргумент функции, который должен быть числом, ему придётся пройти операцию a|0, после которой он гарантированно станет числом.

Это не слишком удобно для человека, но нужно понимать, что asm.js — не для людей. Генератору кода всё равно, что генерировать, а интерпретатор, поддерживающий asm.js, понимает, что такая демонстративная конвертация — это вовсе не конвертация, а неуклюжая попытка задать тип переменной, и действует соответственно.

Что это даёт? Во-первых, интерпретатор и JIT-компилятор могут быть уверены, что внутри модуля тип переменных никогда не меняется. Больше не нужны постоянные проверки типа во время исполнения и принудительное приведение типов к общему знаменателю. Во-вторых, нет динамического выделения памяти и, соответственно, сборки мусора. Наконец, сама реализация объектов и организация памяти может быть более эффективной. Главное — поменьше рефлексии (и прочей динамики).

Всё это значительно повышает эффективность JIT-компиляции. Производительность кода, который выдают последние версии Emscripten, достигает 50-70 процентов от нативной скорости исполнения той же программы. Разработчики утверждают, что на устройствах, работающих под управлением Android, Javascript, который генерирует Emscripten, работает быстрее, чем Java.

 

Ограничения среды

Самые простые программы Emscripten просто перекомпилирует, не задавая лишних вопросов. Однако во многих случаях к переносу программы в браузер следует подходить так же, как к портированию на другую платформу (и ожидать похожих проблем). Любой код, который использует особенности определённой платформы или среды разработки, может стать серьёзным препятствием. От него придётся избавиться. Зависимость от библиотек, исходники которых недоступны, должна быть устранена. Не исключено, что в процессе портирования проявят себя ошибки, которых прежде удавалось избежать — другая среда исполнения и другой оптимизатор часто имеют такой эффект.

Кроме того, остаётся ещё одна важная деталь: программы, которые рассчитаны на работу в течение продолжительного времени, как правило, крутятся в цикле — они ждут поступления событий из внешнего мира и реагируют на них. Так продолжается, пока их не выключат. Браузер и, соответственно, программы на Javascript устроены совершенно иначе.

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

Любая программа на Javascript, которая не только реагирует на события DOM, но и делает нечто большее, быстро упирается в это ограничение. Лазейки, которые позволяют его обойти, тоже давно известны. Джаваскриптовые эмуляторы игровых консолей, о которых рассказывалось в статье «Байт из других миров. Как ретрокомпьютеры эмулируют на Javascript», привязывают исполнение каждого такта виртуального процессора к таймеру. В этом случае каждый виток внутреннего цикла эмулятора активирует сам браузер.

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

int main() {
    #ifdef __EMSCRIPTEN__
        emscripten_set_main_loop(mainloop, 60, 1);
    #else
        while (1) {
            mainloop();
        }
    #endif
}

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

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

Жертва значительна, но не смертельна. Emterpreter sync используется, в частности, в Em-DOSBox, браузерной версии известного эмулятора MS-DOS. Потерянная производительность не мешает этой программе успешно воспроизводить множество компьютерных игр для PC, написанных в девяностые годы, и даже Windows 95.

 

Profit

Сфера применения EmScripten не ограничивается эмуляторами и старинными игрушками (помимо DOSBox, на Emscripten перенесли Doom, Quake 3, Dune 2 и ScummVM, виртуальную машину для классических квестов LucasArts и Sierra). Попытки запустить в браузере такие популярные скриптовые языки, как Python, Ruby и Lua, тоже увенчались успехом, хотя их практичость остается сомнительной. Каждый из них требует загрузки многомегабайтного рантайма; для обычного сайта это неприемлемо.

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

Если даже после этого (особенно после этого) вы ещё не оценили всю серьёзность происходящего, остаётся добавить, что Emscripten поддержали компании Epic Games и Unity Technologies. Первая портировала на Javascript популярнейший игровой движок Unreal Engine. Он настолько популярен, что проще перечислить игры, где его нет. Другая с помощью Emscripten разработала джаваскриптовую версию Unity 3D. С такой поддержкой эта технология определённо пойдёт далеко.

Сделано в Emscripten

Angry Bots

Angry Bots
Angry Bots

Демонстрация того, как может выглядеть игра, написанная при помощи портированного на Javascript движка Unity 3D. Спойлер: она может выглядеть, как мобильная игра средней руки. Трёхмерный вооружённый гражданин бежит по железной местности и, разумеется, стреляет. Он не может не стрелять (серьёзно, я не знаю, как его остановить). Местные жители недовольны, и их можно понять.

Старые игры

Lode Runner
Lode Runner

Не так давно Internet Archive выложил на всеобщее обозрение огромное количество старинных игр для всех возможных платформ, начиная со Spacewar образца 1962 года (считается, что это первая компьютерная игра) и заканчивая, извините, Flappy Bird. Между ними есть всё, что можно придумать. За воспроизведение отвечает джаваскриптовый порт эмулятора MESS/MAME, который поддерживает без малого тысячу исторических игровых платформ.

Интерпретаторы языков программирования

Интерпретатор Scheme
Интерпретатор Scheme

На этой странице выложены интерпретаторы Python, Ruby, Scheme, Lua, Java, QBasic, Forth и множества других языков программирования. Для ценителей есть даже Brainfuck. С каждым можно поиграться прямо в браузере, сохранить введённый код и поделиться им со знакомыми в Facebook и Twitter. Знакомые оценят — особенно, если это Brainfuck.

Dead Trigger 2

Dead Trigger 2
Dead Trigger 2

Ещё одна демка Unity 3D. В Dead Trigger 2, вместо псевдоизометрии Angry Bots, мы имеем вид от первого лица и большой окровавленный топор. Низкополигональная местность, напоминающая задворки оптового рынка на окраине Москвы, не радует, но Emscripten в этом вряд ли виноват. Виноваты зомби, которые сделали эту игру.

Tappy Chicken

Tappy Chicken
Tappy Chicken

Epic Games, демонстрируя джаваскриптовую версию Unreal Engine 3, пытается впечатлить не публику, а разработчиков. Публике тут смотреть не на что: это клон Flappy Bird с сельскохозяйственным уклоном. Программистов же может впечатлить тот факт, что эта курица без особых проблем конвертируется не только в Javascript, но и в приложения для iOS и Android. Не всех программистов, конечно. Только самых впечатлительных.

Doom

Doom
Doom

«Дум» даже в DOSBox, переведённом на Javascript, остаётся «Думом». Двадцать лет почти не изменили его (монстры выглядят странно, но, вероятно, не из-за Emscripten, а из-за копирайта), только пиксели стали крупнее. Думал ли Кармак, что его передовую игру будут портировать на каждую платформу, одну другой меньше? Вряд ли. Quake 3 на Javascript, кстати, тоже есть.

VIM

Vim.js
Vim.js

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

1 комментарий

  1. Аватар

    Nick

    08.08.2015 в 01:33

    блин, придётся всё-таки освоить его

Оставить мнение