RegExp — известный способ создать себе новую проблему. Стандарты между ES5.1 и ES2026 накопили множество мощных способов создания проблем, и все их пришлось добавить в Carakan. Добавление началось вместе с ES2015, но рассказ о регулярках я отложил, чтобы описать всю эволюцию в один заход. Даже с вырезанными техническими деталями получилось много, так что я не стану останавливаться на самой сути регулярных выражений; если вы это читаете, то точно знакомы с ними.
Сначала — о том, как это было сделано в оригинальном коде.
В Opera 12.15 был свой модуль RegExp, реализующий весь основной синтаксис и API регулярок ES5.1 — gim, захваты, обратные ссылки, lookahead, строковые match/replace/search/split — и добавляющий к ним собственные расширения. Некоторые из них опередили стандарт на годы: именованные группы и ссылки на них в стиле Python ((?P<name>...) и (?P=name)), пересечения классов ([a-z&&[^aeiou]] — строчные латинские согласные), коды символов в фигурных скобках и «расширенный» режим, в котором пробелы между элементами шаблона игнорируются. Модуль поддерживал и «липкий» поиск, который позже появится в стандарте как флаг y.
Именованные группы и пересечения классов были доступны из JavaScript-объекта RegExp без оговорок. А вот флаги x и y спрятаны за макросом ES_NON_STANDARD_REGEXP_FEATURES, который в исходниках 12.15 нигде не определён: модуль эти режимы поддерживал, но наружу их не выпускал. Макрос, правда, могла передавать извне система сборки — наличие фичи в бинарнике 12.15 я не проверял. Как бы то ни было, это качественная и полная реализация для того времени.
Причём регулярки в Presto — вовсе не часть Carakan. Это самостоятельный модуль modules/regexp: Carakan отдаёт ему текст шаблона с флагами и получает обратно скомпилированный объект. Всё «джаваскриптовое» — lastIndex, массив с результатом, методы String — Carakan делает сам, а модуль знает только одно: где в строке нашлось совпадение и где его группы. Эта граница существовала в оригинальном коде и очень пригодилась: часть новых возможностей потребовала менять только модуль, часть — только Carakan, и ни для одной не пришлось переписывать основной интерпретатор JavaScript.
Сам движок не исполняет текст выражения напрямую: сначала он компилирует его в байт-код.
Регулярка — это, по сути, маленькая программа на маленьком языке. Разбирать её текст заново при каждом поиске расточительно, поэтому компилятор (RE_Compiler) один раз переводит шаблон в последовательность простых команд для крошечной виртуальной машины. Это похоже на процессор со своим набором инструкций, только инструкций немного, и все они про текст: «сравни символ», «сравни строку», «проверь, входит ли символ в класс», «запомни развилку», «начни группу», «закончи группу», «перейди», «успех».
Каждая инструкция — 32-битное слово: в младшем байте код операции, в старших битах короткий аргумент. Если аргумент не влез, он занимает следующие слова. Длинные литералы и классы символов хранятся отдельно: литерал сравнивается одной инструкцией, а класс вроде [a-z0-9_] становится отдельным объектом RE_Class. Для первых 256 символов это таблица принадлежности (по байту на символ), для остальных — список диапазонов, так что ASCII-шаблоны, самые частые, проверяются одним обращением к таблице.
Вот, например, как упрощённо выглядит байт-код для /a(b|bc)d/:
0 MATCH_CHARACTER_CS 'a'
1 CAPTURE_START 1
2 PUSH_CHOICE → 5
3 MATCH_CHARACTER_CS 'b'
4 JUMP → 6
5 MATCH_STRING_CS "bc"
6 CAPTURE_END 1
7 MATCH_CHARACTER_CS 'd'
8 SUCCESS
CS в названиях — case-sensitive, для флага i есть парные CI-инструкции. В исходниках есть родной дизассемблер RE_Compiler::Disassemble, печатающий ровно такие листинги, но он выключен даже в отладочной сборке, а места его вызова вдобавок закрыты #if 0.
Интерпретатор байт-кода — это цикл, который берёт очередную инструкцию, смотрит на её код и выполняет соответствующую ветку switch. Надёжно, но на каждую проверку символа приходится ещё и расход на разбор самой инструкции. JIT убирает этого посредника: он переводит байт-код в машинный код процессора, и регулярка превращается в обычную функцию, которая сразу сравнивает символы, не читая никаких инструкций. Этот JIT никак не связан с JIT самого JavaScript, просто одна технология, решающая схожие задачи в разных подсистемах.
Поэтому скомпилированная JS-функция может вызвать регулярку в интерпретаторе, а интерпретируемая — скомпилированную регулярку.
Генератор RegExp JIT (RE_Native) проходит по байт-коду и выдаёт машинные проверки символов, классов, границ, циклов и захватов. Под каждую архитектуру у него своя часть: IA-32 (её используют и x86-, и x64-сборки), ARM и MIPS.
Когда компилировать, решает простое правило. Литерал — /.../ прямо в тексте программы — сразу, ещё при разборе скрипта, проходит попытку компиляции в машинный код. Объект, созданный через new RegExp(), начинает с интерпретатора и попадает в JIT, только если его вызвали больше 10 раз или скормили строку длиннее 256 символов. Логика понятна: литерал почти наверняка будет работать многократно, а динамически собранное выражение может оказаться одноразовым, и компилировать его — только тратить время.
Покрывает JIT не всё. Сложные вложенные откаты, обратные ссылки, некоторые проверки позиции помечаются как «не поддерживается» и дальше всегда исполняются интерпретатором.
Насколько JIT ускоряет поиск? Меньше, чем можно было бы подумать. На современной машине (Core Ultra 9 275HX, усреднённое по 10 замерам) поиск даты выражением /[0-9]{4}-[0-9]{2}-[0-9]{2}/ занимает 194 мс в интерпретаторе и 172 мс с JIT, а поиск записи в логе /ERROR [A-Z]+ [0-9]+/ — 187 и 163 мс. Ускорение — в 1,13–1,15 раза. Не в разы, но зато бесплатно.
Теперь по шагам: что происходит при вызове exec. Возьмём то же выражение и строку "xabcd".
Шаг первый: найти, откуда вообще стоит начинать. Запускать полное сопоставление с каждой позиции строки дорого, поэтому ещё при компиляции строится фильтр RE_Searcher — таблица на 256 символов, в которой отмечено, с каких символов совпадение может начаться. Наше выражение обязано начинаться с a, так что x пропускается без запуска машины, и первый кандидат — позиция 1. Типичный приём старой Opera: сначала дешёвая фильтрация, потом дорогая работа, и только для правдоподобных кандидатов.
Шаг второй: исполнить байт-код с этой позиции. Машина помнит номер текущей инструкции, позицию в строке и стопку «закладок». Каждая закладка говорит: «если дальше не выйдет — вернись к инструкции N и позиции P».
| Инструкция | Позиция | Что происходит |
|---|---|---|
MATCH_CHARACTER_CS 'a' |
1 → 2 | a на месте |
CAPTURE_START 1 |
2 | запомнили начало группы |
PUSH_CHOICE → 5 |
2 | закладка: «если что — пробуй bc с позиции 2» |
MATCH_CHARACTER_CS 'b' |
2 → 3 | b на месте |
JUMP → 6 |
3 | перепрыгиваем вторую альтернативу |
CAPTURE_END 1 |
3 | группа — b |
MATCH_CHARACTER_CS 'd' |
3 | во входе c — неудача |
| откат | 2 | снимаем закладку, отменяем конец группы |
MATCH_STRING_CS "bc" |
2 → 4 | bc на месте |
CAPTURE_END 1 |
4 | группа — bc |
MATCH_CHARACTER_CS 'd' |
4 → 5 | d на месте |
SUCCESS |
5 | готово |
Шаг третий: вернуть результат. Модуль отдаёт наружу только пары «начало — длина» для всего совпадения и каждой группы: (1, 4) и (2, 2). Уже Carakan превращает их в привычный массив ["abcd", "bc"] с index: 1. Если бы закладки кончились, а совпадения так и не нашлось, фильтр выдал бы следующего кандидата, и всё повторилось бы с новой позиции.
Такой способ поиска называется «бэктрекингом» — перебором с возвратом. У него есть неприятная оборотная сторона, зато он прямо и сравнительно просто воспроизводит обратные ссылки и тот порядок перебора вариантов, которого требует JavaScript. Классический конечный автомат, например, обратных ссылок не умеет вовсе.
Самое интересное в реализации Opera — то, как устроена стопка закладок:
Choice) хранятся в собственном списке в куче. Ни в сопоставлении, ни в откате рекурсии нет: длинная строка с тысячами развилок не превращается в тысячи вложенных вызовов функций и не роняет процесс переполнением стека. (Парсер, компилятор и анализатор для JIT рекурсивны, но для них есть отдельные ограничения глубины.)/a*ab/ и строку из тысячи a с b в конце. Жадный a* сначала съедает все a, а потом должен уметь отдавать их назад по одной — то есть, по-хорошему, после каждой a нужна своя закладка. Вместо тысячи записей Opera хранит одну: с какой позиции начали, сколько вариантов осталось и на сколько шагать.a* или [0-9]+ исполняются отдельными инструкциями-циклами (LOOP_CHARACTER_*, LOOP_CLASS) прямо в C++, без прохода по байт-коду на каждый символ, и закладку создают, только если продолжению шаблона может понадобиться что-то вернуть.Память для закладок и захватов выделяется блоками, видимыми сборщику мусора Carakan, и освобождается целиком по окончании поиска — никакой возни с россыпью мелких объектов.
С каждой новой итерацией стандарта требования к механизму росли, и новые режимы появлялись один за другим:
u (ES2015) — полноценная поддержка Unicode, чтобы вы могли использовать эмодзи 💩 в ваших выражениях;y (ES2015) — «липкий» поиск;s (ES2018) — режим dotAll, убирающий необходимость хаков а-ля [\s\S];d (ES2022) — позиции найденных групп, которые движок теперь обязан уметь возвращать;v (ES2024) — новый Unicode-режим, расширяющий u: он позволяет проворачивать операции над множествами символов (пересечения, вычитания) и работать со строками внутри классов, а заодно строже относится к синтаксису.Улучшения не ограничились новыми режимами: синтаксические возможности паттернов тоже расширились весьма значительно. Самым технически сложным добавлением стали ретроспективные проверки (lookbehind assertions), введённые в ES2018. Раньше левый контекст можно было только включить в само совпадение — /\$(\d+)/ — и потом вытаскивать нужную группу. Проверить то, что идёт до совпадения, не включая его в результат, было невозможно, а теперь стали поддерживаться выражения вроде:
/(?<=\$)\d+/ // число, только если перед ним стоит $
/(?<!\$)\d+/ // число, перед которым нет $
Там же, в ES2018, появились именованные группы — (?<year>\d{4}), с результатом в match.groups.year вместо безликого match[1] — и свойства Unicode: \p{Script=Greek} находит греческие символы, а \p{Letter} — любые буквы любого алфавита, без ручного перечисления диапазонов. ES2025 разрешил включать флаги только для части выражения (в (?i:hello) world регистр игнорируется лишь в первом слове) и повторять одно имя группы в разных ветках альтернативы.
Сам JS тоже упростил взаимодействие с объектом. Появились встроенное экранирование (RegExp.escape) и matchAll, позволяющий перебрать все совпадения вместе с группами, а конструктор new RegExp() стал не таким строгим: в него можно передать готовую регулярку с новыми флагами — new RegExp(/abc/g, "i"), — раньше это заканчивалось TypeError. Улучшения эволюционные, очень упрощающие жизнь разработчику, но заметно усложняющие реализацию — пусть и не столько сам движок регулярок, сколько встроенные функции вокруг него.
Модернизация Carakan до ES2015 потребовала значительных переделок, и главная из них — флаг u.
Строки в JavaScript — это UTF-16: каждая кодовая точка Unicode занимает одну или две 16-битные «кодовые единицы». Всё, что не поместилось в первые 65 536 значений, включая наш 💩, записывается суррогатной парой, и "💩".length будет равен 2. Старый движок регулярок о парах ничего не знал: для него 💩 — две независимые 16-битные кодовые единицы, и /^.$/.test("💩") возвращает false. С флагом u машина обязана читать полноценные кодовые точки: собрать пару при чтении и сопоставить её как одно целое.
Звучит просто, но забудьте это слово, когда работаете с Unicode, ведь:
u-режиме идёт по таблицам Unicode, а без u — по старому алгоритму, который ради совместимости запрещает не-ASCII символам превращаться в ASCII: знак кельвина \u212A (выглядит как K, но это другой символ) при /iu совпадает с латинской k, а при /i — нет;\b при сочетании u и i тоже считаются по-новому.При этом индексы в результате остаются в кодовых единицах: /./u на "💩" даёт совпадение длиной 2, потому что так JavaScript измеряет строки. Старые фильтр стартовых позиций и JIT пришлось гарантированно обходить.
Для u существующий RegExp JIT не расширялся. Unicode-шаблоны направлялись в байт-кодный интерпретатор, поскольку машинный исполнитель работал с 16-битными элементами и не умел корректно представлять кодовые точки. Для старых шаблонов JIT использовался по-прежнему.
Флаг y в Opera уже существовал как внутренний режим «не искать дальше», и его оставалось сделать стандартным, со всем контрактом lastIndex.
Следующим пришёл \p{...} из ES2018, и ему понадобились данные, которых в старом модуле не было вовсе: какие символы — буквы, какие — цифры, какой символ к какой письменности относится. Пришлось подложить таблицы Unicode 17.0: имена свойств и их синонимы, общие категории, письменности, бинарные свойства символов и строк. При компиляции \p{Script=Greek} превращается в обычный набор диапазонов RE_Class, и во время поиска это такой же класс, как [a-z], только побольше.
ICU здесь не используется: в отличие от Intl из прошлой части, регулярки и разбор идентификаторов живут на собственных таблицах и работают даже в сборке без Intl. Цена — объём: основная масса добавленного в модуль кода — не логика, а эти самые таблицы. Их источник — Unicode Character Database, официальный набор текстовых файлов консорциума: UnicodeData.txt, списки свойств и их синонимов, письменности, emoji-данные — всего одиннадцать файлов. Их версия и контрольные суммы закреплены в манифесте, импорт сверяет SHA-256 и ничего не скачивает без явной просьбы, а генератор на Python детерминированно пересобирает все общие Unicode-таблицы разом. Переход на следующую версию Unicode потребует нового манифеста и перегенерации всего набора.
Итоговый файл данных для RegExp — 9 096 строк и около 700 КиБ исходного текста: 883 имени и синонима свойств, 368 наборов с 22 127 диапазонами и 3 953 emoji-последовательности. В бинарнике это 287 КиБ упакованных данных по расчёту до выравнивания, а измеренный прирост Opera.dll под Windows x64 относительно базовой сборки — 284 КиБ.
Часть новинок обошлась почти бесплатно — благодаря тому, как был устроен старый модуль.
d. Машина и так всегда возвращала пары «начало — длина», просто Carakan оставлял от них только подстроки. Теперь при d он дополнительно строит indices: /b(c)/d.exec("abcd").indices — это [[1, 3], [2, 3]]. Алгоритм поиска не поменялся вовсе.s. Раньше инструкция точки была без аргументов и всегда отвергала перевод строки. Теперь у неё есть признак dotAll, при котором эта проверка пропускается.(?i:...) нужен стек флагов во время исполнения — но нет. Режим известен уже при компиляции: при входе в группу компилятор меняет свои текущие настройки, при выходе восстанавливает, и каждая инструкция внутри сразу получает нужный вариант — CI вместо CS, точку с признаком dotAll или без. Машина о модификаторах не знает вообще.(?P<name>...) уже умели хранить имена, и для исполнителя именованная группа — обычная группа с номером. Новыми были стандартный синтаксис, groups в результате и $<name> в замене.(?<year>\d{4})-\d{2}|\d{2}-(?<year>\d{4}), и компилятор проверяет это, отслеживая путь по вложенным альтернативам. Одноимённые группы связываются в цепочку, а в groups.year попадает та, что реально участвовала в совпадении. Для \k<year> понадобилась единственная новая инструкция — MATCH_NAMED_CAPTURE, которая ищет в цепочке сработавшую группу.RegExp.escape. Вообще не касается машины: это обычная функция, превращающая строку в безопасный кусок шаблона. Забавная деталь — первую цифру или латинскую букву она кодирует всегда: RegExp.escape("2026") вернёт \x32026 — закодированную двойку (\x забирает ровно две цифры) и нетронутый хвост 026. Иначе при склейке с шаблоном двойка могла бы прилипнуть к стоящему перед ней \1 и превратить его в \12.Вся машина построена на движении вперёд: каждая инструкция, потребляющая текст, сдвигает позицию вправо (назад машина возвращается только откатом), фильтр смотрит на первый символ, закладки помнят, куда вернуться, если впереди не сложилось. Lookbehind требует заглянуть влево.
Простой способ — «отступить на N символов и проверить» — работает во многих движках (например, в модуле re у Python), где содержимое lookbehind обязано иметь фиксированную длину. JavaScript такой скидки не даёт: внутри может стоять \d+, альтернативы разной длины, группы. Стандарт описывает это так: содержимое lookbehind сопоставляется справа налево. Это видно даже по результату:
/^(\d+)(\d+)/.exec("1053") // ["1053", "105", "3"]
/(?<=(\d+)(\d+))$/.exec("1053") // ["", "1", "053"]
В первом случае машина идёт слева направо, первой к цифрам подходит первая группа, и жадной оказывается она. Во втором машина идёт справа налево, первой оказывается вторая группа — и забирает всё, что может, оставив первой единственную цифру.
Движок регулярок постепенно научился ходить в обе стороны:
SET_DIRECTION, переключающая направление чтения.(?<=ab) читается как «сначала b, потом a, двигаясь влево».MATCH_STRING_CS в нём сравнивает литерал с тем, что стоит непосредственно перед текущей позицией, и после успеха сдвигает позицию влево. Для u появился парный механизм, узнающий суррогатную пару с хвоста.Одну вещь всё же пришлось отключить — фильтр стартовых позиций. Он угадывает кандидатов только по символам, которые шаблон потребляет вперёд, и об «оглядывающемся» контексте ничего не знает. Поэтому выражение, где lookbehind есть хоть в одном месте, проверяется с каждой позиции строки. Исключение — флаг y: с ним поиск и так идёт только с lastIndex.
До v класс символов был множеством отдельных символов: спросили про один символ — получили «да» или «нет». Флаг v превращает классы в маленькую алгебру множеств:
/[\p{Letter}&&\p{Script=Greek}]/v // пересечение: только греческие буквы
/[\p{Letter}--\p{ASCII}]/v // вычитание: все буквы, кроме латиницы ASCII
/[\q{ab|a|xyz}]/v // класс из строк
/^\p{RGI_Emoji_Flag_Sequence}$/v // свойство строк: флаги стран
Последние два — принципиально новое. Флаг 🇺🇦 — это не один символ, а два «региональных индикатора» подряд, то есть четыре кодовые единицы. Класс перестаёт отвечать на вопрос «подходит ли этот символ» и начинает отвечать на вопрос «какая из нескольких строк начинается здесь». А если подходит несколько строк разной длины — какую взять?
Стандарт требует: сначала самую длинную. Для этого внутри RE_Class появился RE_UnicodeSet — маленькое дерево операций, узлами которого могут быть набор символов, свойство строк, явный список строк, объединение, пересечение, вычитание и дополнение. При проверке класс находит самую длинную подходящую строку, а более короткие варианты кладёт в закладки — те же самые, что и для обычных альтернатив:
/^[\q{ab|a}]b$/v.exec("ab") // ["ab"]
Сначала класс забирает ab, но шаблон ждёт дальше b, а строка кончилась. Откат — и класс пробует a, после чего b совпадает. Отдельного движка для строковых классов не понадобилось.
Синтаксис в режиме v строже: ряд символов внутри класса ((, ), {, }, /, -, | и другие) обязательно экранировать, && и -- стали операциями, а прочие сдвоенные знаки вроде !! или ## зарезервированы на будущее.
Разбирает всё это отдельный рекурсивный парсер, написанный для v с нуля. При этом листья дерева — диапазоны и наборы символов — хранятся всё в том же старом RE_Class. Глубина вложенности ограничена 128 уровнями. Стандарт такого не требует, это защита от переполнения стека рекурсией, а само число унаследовано от тех самых пересечений классов из Opera 12.15, где тот же предел ограничивал вложенные &&[...].
Всё новое, что меняет само сопоставление, реализовано в байт-коде и его интерпретаторе. Реализовывать те же возможности в машинном генераторе задачи не было: он лишь научился распознавать новые инструкции и режимы и отказываться (пока что) от их компиляции.
| Что в выражении | JIT |
|---|---|
| Старые шаблоны времён ES5 | да, как раньше |
Флаг d |
да: индексы строятся уже после поиска |
| Именованные группы, в том числе с повторяющимися именами | да: для машины это обычные номера |
u, v и всё, что с ними: \p{...}, строковые классы |
нет |
s и локальные модификаторы |
нет |
| Lookbehind | нет |
Именованная обратная ссылка \k<name> |
нет |
«Да» здесь означает «может», если в выражении нет ничего из нижней половины таблицы и прочих конструкций, от которых генератор отказывался и раньше.
Консервативный отказ правильнее частичной машинной реализации, которая иногда может выдать неверный результат. Старые выражения сохранили свой быстрый путь, новые получили семантику в интерпретаторе. Но это и очевидный долг: код, активно использующий \p{...}, v или lookbehind, от RegExp JIT не получает ничего.
Обиднее всего за s: для машинного кода это, казалось бы, одна пропущенная проверка. Подвоха тут и правда нет — генератору нужно научиться читать новый аргумент инструкции точки, не выдавать проверку перевода строки и пройти проверку на IA-32, ARM и MIPS. Не сделано просто потому, что целью была корректность, а безопасный путь отступления уже существовал. С модификаторами сложнее: старый анализатор машинного кода рассчитывает, что режимы i, m и s едины для всего выражения, а здесь они могут меняться несколько раз внутри одного шаблона. Это не невозможно, просто это отдельная работа на всех архитектурах.
У бэктрекинга есть обратная сторона. Классический пример из учебников — /^(a+)+$/ на строке из a с восклицательным знаком в конце. Совпадения нет, но чтобы это доказать, наивный движок переберёт все способы разделить a между внутренним и внешним +, и каждый новый символ удваивает работу.
Carakan на эту удочку так просто не попадётся. Внутренний a+ исполняется специальной инструкцией-циклом, а однотипные закладки внешнего + упаковываются в одну — то самое сжатие, о котором шла речь выше. В итоге тридцать a проверяются меньше чем за миллисекунду. Но стоит чуть изменить пример, и ловушка срабатывает:
/^(a|aa)*$/.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!")
Здесь число путей растёт как числа Фибоначчи. На той же машине строка из 30 a проверяется за 59 мс, из 32 — за 158, из 34 — за 410, а из 36 — уже больше секунды: каждые два лишних символа — примерно в 2,6 раза больше работы. Это называется ReDoS — отказ в обслуживании через регулярку, и он вполне реален: в 2019 году одно неудачное выражение уронило значительную часть Cloudflare на 27 минут.
Защитные механизмы Opera здесь помогают лишь отчасти. Собственный стек закладок в куче не даст упасть с переполнением системного стека, сжатие закладок экономит память, а проверка кванта времени каждые 65 535 откатов не даст поиску бесконечно удерживать один квант исполнения. Но лимита как такового нет: исчерпав квант, контекст уступает управление планировщику и в следующем кванте продолжает ту же работу. Ни диалога, ни исключения, ни отмены — поиск может идти сколько угодно.
Защищаться от этого можно по-разному: явным бюджетом откатов, дедлайном, отказом от опасных шаблонов или вторым исполнителем с гарантированно линейным временем (например, автоматом Томпсона, как в RE2) для выражений без обратных ссылок и прочих конструкций, которым действительно нужен откат.
V8, например, по состоянию на сентябрь 2026 года всё ещё держит такой исполнитель за флагами в статусе экспериментального. При включении на него переключаются после 50 000 откатов, и то не для всех шаблонов: обратные ссылки, u/v, i и часть квантификаторов ему недоступны, а lookaround, поддержанный лишь в конце 2024 года, требует ещё одного флага. В Carakan ничего подобного нет. Конкретные вложенные квантификаторы вроде ^(a+)+$ старые оптимизации обезвреживают, но сам класс уязвимости никуда не делся с 2013 года.
WeakRef и FinalizationRegistry появились в ES2021, и это, пожалуй, самые необычные возможности из всего списка. Остальное — синтаксис, методы, флаги — это фичи языка. Эти же относятся к сборщику мусора.
Про GC я уже немного рассказывал. Напомню главное: объект жив, пока до него можно дотянуться по цепочке ссылок из «корней» — глобальных переменных, стека вызовов, внутренних списков движка. Всё, до чего дотянуться нельзя, сборщик вправе убрать.
Обычно это ровно то, что нужно. Но представьте кэш: скажем, картинки, которые страница однажды загрузила и декодировала. Положите их в обычный Map по адресу — и кэш будет держать каждую картинку вечно, даже когда её давно выкинули со страницы. Классическая утечка памяти, оформленная как оптимизация.
Будь ключом сама картинка, хватило бы WeakMap из ES2015: запись в нём живёт, пока жив ключ. Но здесь ключ — строка с адресом, а «слабо держать» нужно значение. Для этого и нужна слабая ссылка: «я знаю, где лежит объект, но держать его не буду». Если на объект не осталось других, сильных, ссылок, сборщик его уберёт, а слабая ссылка просто опустеет. Кэш превращается в Map из адресов в WeakRef, а опустевшие записи из него надо время от времени вычищать — и тут пригодится FinalizationRegistry, пусть и с оговорками, о которых ниже.
| Связь | Что остаётся в живых |
|---|---|
обычная ссылка A → B |
B, пока достижим A |
WeakRef → объект |
объект — только если к нему есть другой сильный путь |
WeakMap: ключ → значение |
значение — пока достижимы таблица и ключ, причём ключ — сам по себе |
FinalizationRegistry: цель → значение |
цель не удерживается, значение удерживается реестром |
«Сам по себе» в строке про WeakMap важно: если ключ достижим только через собственное значение, эта петля не спасает ни ключ, ни значение.
Полифиллом такое не сделать. Решение о смерти объекта принимает только сборщик, потому что только он видит весь граф ссылок целиком, и никакой счётчик ссылок или хитрое свойство объекта его не заменят.
WeakReflet object = { data: "cached" };
const reference = new WeakRef(object);
object = null;
const value = reference.deref();
if (value !== undefined)
use(value);
deref() возвращает объект, если тот ещё жив, и undefined, если сборщик его уже убрал. При этом object = null сам по себе ничего не убирает: сборка может начаться через миллисекунду, через минуту или не начаться вовсе. Два одинаковых запуска программы не обязаны видеть опустевшую ссылку в одном и том же месте. Таков контракт: слабая ссылка сообщает «может быть, когда-нибудь».
Когда же наступает это «когда-нибудь»? Carakan запускает сборку прежде всего по объёму выделенной памяти, включая ту, что числят за собой внешние объекты. После каждой сборки порог пересчитывается от объёма выживших данных: в настольной конфигурации следующая сборка начнётся, когда куча дорастёт примерно до четырёх таких объёмов, но не раньше 512 КиБ и не позже ещё одного гигабайта роста. Вдобавок раз в секунду отдельный механизм присматривает за неактивными кучами и собирает те, где с прошлой сборки прошло не меньше десяти секунд и что-то успело произойти.
Слабо ссылаться можно на объект или на символ — но только не на тот, что лежит в глобальном реестре Symbol.for. Такой символ всегда можно заново получить по его строковому ключу, «потерять» его невозможно, и слабая ссылка на него была бы враньём. Встроенные символы вроде Symbol.iterator тоже живут вечно, но держать их слабо стандарт разрешает: граница проведена именно по реестру. Символы, кстати, разрешили не сразу: в ES2021 годились только объекты, а незарегистрированные символы добавили в ES2023, вместе с возможностью делать их ключами WeakMap. Числа и строки не подходят тем более: у них нет «личности», которую можно потерять.
У deref() есть неочевидная гарантия. Посмотрите на такой код:
if (reference.deref() !== undefined)
reference.deref().render();
Если бы сборщик мог сработать между двумя вызовами, второй deref() вернул бы undefined, и код упал бы на ровном месте. Поэтому стандарт обещает: объект, который deref() хоть раз вернул, доживёт до конца текущего задания — синхронного куска кода вместе с цепочкой Promise, запущенных следом. Речь только о тех реакциях Promise, что выполняются сразу следом: Promise, который разрешится позже — по таймеру или ответу сети, — относится уже к следующему заданию и жизнь объекта не продлевает. То же правило действует при создании WeakRef: только что переданный объект не исчезнет прямо из-под рук.
В Carakan для этого заведён список weak_kept_alive в ES_Runtime. Конструктор WeakRef и каждый успешный deref() добавляют туда объект, а сам список — обычный корень для сборщика: всё, что в нём лежит, считается достижимым. Очищает его планировщик, и только тогда, когда очередь Promise окончательно опустела.
Отсюда следствие: показать опустевшую ссылку вживую не так-то просто. В jsshell есть скрытая функция __collectGarbage__() (Test262 получает её же под именем $262.gc()), но прямолинейное «создали WeakRef, обнулили переменную, вызвали сборку» undefined не покажет никогда: объект обязан дожить до конца задания. Нужно дождаться следующего. В оболочке без таймеров проще всего сделать это через задачу очистки FinalizationRegistry:
var reference;
var trigger = new FinalizationRegistry(function () {
var pressure = [];
for (var i = 0; i < 10000; ++i)
pressure.push({});
__collectGarbage__();
print(String(reference.deref()));
});
Promise.resolve().then(function () {
var target = { value: 42 };
reference = new WeakRef(target);
print(reference.deref().value);
trigger.register({}, 0);
__collectGarbage__();
});
42
undefined
Первая сборка убирает безымянный объект, зарегистрированный в trigger, и ставит его callback в очередь. Перед вызовом callback оболочка дорабатывает цепочку Promise и очищает weak_kept_alive, а уже внутри callback выполняется серия выделений памяти, и следующая сборка убирает исходный target. Дополнительные выделения нужны только для воспроизводимости примера, к контракту WeakRef они отношения не имеют.
Внутри и WeakRef, и каждая регистрация в FinalizationRegistry устроены одинаково — это объект ES_Weak_Cell с пятью полями:
| Поле | Ссылка | Что в нём |
|---|---|---|
target |
слабая | цель, за которой следим: объект или символ |
held_value |
сильная | значение для callback |
unregister_token |
слабая | ключ для отмены регистрации |
owner |
сильная | внутреннее состояние реестра |
next |
сильная | следующая регистрация того же реестра |
WeakRef пользуется только target, остальные поля у него пустые. Сам реестр устроен в два слоя: видимый JavaScript-объект и внутреннее состояние, на которое ссылаются и он, и скрытая служебная функция очистки. В состоянии лежат callback, голова односвязного списка ячеек, глобальный объект нужного окна, та самая функция очистки и поля для очереди.
Новому объекту нужна новая метка типа в заголовке GC, а с этим, как вы помните, беда: шесть бит, 64 значения, свободных нет. Решение знакомое по BigInt — взять существующую метку (GCTAG_ES_Boxed_List) и добавить в заголовок признак MASK_IS_WEAK_CELL. Обходу кучи это не мешает: размер каждого объекта записан в самом заголовке и из метки не выводится. А при пометке сборщик видит признак и применяет к ячейке особое правило — идёт по held_value, owner и next, но не трогает target и unregister_token.
На том же приёме «метка плюс признак» живут, помимо BigInt и слабой ячейки, внутренний элемент класса, привязка модуля и встроенные строки. Свободных меток не осталось вовсе: 0 — свободный блок памяти, с 1 по 62 — реальные виды объектов и служебных структур, 63 — «ещё не инициализировано».
FinalizationRegistryЕсли WeakRef отвечает на вопрос «жив ли ещё объект», то FinalizationRegistry позволяет узнать, что он умер:
const registry = new FinalizationRegistry(function (resourceId) {
removeCachedResource(resourceId);
});
const token = {};
let wrapper = makeWrapper();
registry.register(wrapper, 42, token);
// Если регистрация больше не нужна:
registry.unregister(token);
register принимает три аргумента: цель, за которой следим (объект или незарегистрированный символ, реестр держит её слабо), значение, которое получит callback (его — сильно), и необязательный ключ отмены (снова слабо и снова объект или символ). Один ключ может отменить сразу несколько регистраций, и unregister сообщает, нашлось ли хоть что-то.
Почему значение для callback держится сильно, понятно: к моменту вызова передавать было бы уже нечего. Но отсюда же растёт главная ловушка:
registry.register(wrapper, wrapper); // TypeError: стандарт такое ловит
registry.register(wrapper, { owner: wrapper }); // а такое — нет
Во втором случае удерживаемое значение ссылается на сам объект, реестр держит значение сильно — и объект не умрёт, пока жив сам реестр. Callback не наступит, ошибки не будет, просто тихая утечка.
Реестр и сам должен быть кому-то нужен. Если программа потеряла и объект, и реестр, сборщик вправе убрать всю конструкцию целиком, не вызывая никаких callback.
И главное: FinalizationRegistry — не деструктор. Сборка может случиться нескоро, процесс может завершиться вовсе без неё, а браузер не обязан вызывать callback перед закрытием страницы. Закрывать так файлы, сокеты или транзакции нельзя. Подчищать кэш, вести диагностику, освобождать ресурс «на всякий случай», если основной путь его закрытия почему-то не сработал, — можно.
Вернёмся к примеру с кэшем ресурсов. Раз вызов callback не гарантирован, уборка через реестр — только «по возможности», и пустые записи в Map всё равно могут копиться. Есть и ловушка похитрее — гонка поколений. Если по тому же адресу уже лежит новая картинка, запоздавший callback от старой не должен её удалить. Поэтому в реестр передаётся адрес вместе с самой слабой ссылкой и перед удалением проверяется, что в кэше лежит именно она:
const cache = new Map();
const cleanup = new FinalizationRegistry(function ({ url, ref }) {
if (cache.get(url) === ref)
cache.delete(url);
});
function remember(url, image) {
const ref = new WeakRef(image);
cache.set(url, ref);
cleanup.register(image, { url, ref });
}
Удерживаемое значение здесь ссылается на WeakRef, а не на саму картинку, так что утечки из предыдущего примера не случится. Другой вариант — при замене записи отменять старую регистрацию через ключ отмены.
Вызвать callback прямо из сборщика нельзя. Пользовательский код может выделить память (и запустить вложенную сборку посреди текущей), выбросить исключение, зарегистрировать новые объекты или отменить старые — всё это прямо посреди процесса, наводящего порядок в памяти. Поэтому GC делает минимум: обнуляет target и ставит реестр в очередь.
Даже эта постановка в очередь не выделяет новую память: поле для связи со следующим реестром заранее предусмотрено внутри самого реестра. Это важно потому, что очередь заполняется во время работы GC, когда его внутренние структуры находятся в промежуточном состоянии, а выделение памяти может вызвать рекурсивный запуск GC внутри незавершённого цикла сборки. OOM — лучшее, на что можно рассчитывать, но вернуть исключение обычным путём тут просто некуда. Фактически, этот код обязан не иметь точки отказа.
Дальше вступает планировщик. Вот что происходит с зарегистрированным объектом по шагам:
| Момент | Что происходит |
|---|---|
| скрипт | registry.register(wrapper, 42), затем wrapper = null |
| сборка мусора, между mark и sweep | wrapper не помечен: target обнуляется, реестр встаёт в очередь |
| sweep | память wrapper освобождена |
| текущее задание и цепочка Promise | дорабатывают как ни в чём не бывало |
| отдельная задача планировщика | служебная функция находит пустую ячейку и вызывает callback(42) |
Служебная функция действует осторожно. Она находит ячейку с пустым target, забирает из неё значение, закрепляет его, удаляет ячейку из списка — и только потом вызывает пользовательский callback. После возврата список читается заново. Так callback может сколько угодно регистрировать, отменять и даже вызывать сборку мусора: обход не останется с указателем на уже удалённую ячейку.
Если callback выбросил исключение, его значение потеряно — ячейка удалена ещё до вызова. Но остальные ячейки остаются на месте, и после завершения задачи реестр снова встанет в очередь. Один упавший callback не хоронит остальные.
Порядок вызовов стандарт не задаёт. Carakan обходит список от головы, но полагаться на это нельзя — ни на порядок регистрации, ни на порядок смерти объектов, ни на то, сколько значений обработается за одну задачу.
Web Workers появились в браузерах давно, и в Opera 12 они были. Но общались они с главной страницей только сообщениями: данные либо копировались, либо передавались целиком — отправитель при этом свой буфер терял. Для обмена парой строк это не проблема, но стоит захотеть обрабатывать кадры видео, считать физику или запускать скомпилированную в JavaScript игру в нескольких потоках, как постоянное копирование становится узким местом.
SharedArrayBuffer (ES2017) решает это радикально: несколько агентов получают один и тот же блок байтов и могут читать и писать его одновременно. «Агентом» стандарт называет независимо исполняемый контекст со своей кучей и очередью заданий — главную страницу или воркер. А объект Atomics даёт операции, без которых такой совместный доступ превращается в лотерею.
Прелесть тут в том, что «наружу» не отдаётся никакой специализированной структуры, жёстко задающей паттерны работы. Каждый агент получает собственный объект SharedArrayBuffer, собственные Int32Array, прототипы, глобальный объект и сборщик мусора, а внутри все они смотрят в один блок памяти:
агент A, куча A агент B, куча B
SharedArrayBuffer A SharedArrayBuffer B
│ │
Int32Array A Int32Array B
│ │
└──────── ES_ArrayBufferBackingStore ───┘
│
общие байты процесса
A и B для потребителей являются разными объектами с разной идентичностью: при пересылке получатель создаёт собственную обёртку. Сравнить их напрямую нельзя — объект из кучи агента A агенту B просто недоступен, — но если переслать буфер из A в B и обратно, в агенте A окажутся две обёртки одного блока, и === между ними даст false. При этом запись через Int32Array A сразу видна через Int32Array B.
Иначе в Carakan и нельзя. Сборщик мусора одной кучи ничего не знает о корнях и фазах сборки другой, и стоит передать в соседний поток указатель на обычный объект движка, как оба сборщика начнут наступать друг другу на ноги. Поэтому через границу агента не проходят ни объекты, ни строки, ни исключения, ни задания Promise — только ссылка на блок байтов и скопированные числа. Зато не пришлось делать потокобезопасным весь движок: классы объектов, строки, сборщик мусора остались однопоточными, как и были.
ArrayBuffer к backing storeВ старом Carakan байты ArrayBuffer просто принадлежали самому объекту: жив объект — живы байты. Для общей памяти это не годится, поэтому слой делится надвое. JavaScript-обёртка по-прежнему живёт в куче сборщика, а байты уехали в отдельный управляющий блок — backing store. В нём лежат:
Каждая обёртка держит одну ссылку на backing store, а байты освобождаются, только когда уйдёт последняя обёртка, последний временный пользователь или последний спящий агент. Счётчик атомарный, так что разные потоки могут отпускать ссылки одновременно, не мешая друг другу.
Была и тонкость: JIT обращался к полям старой структуры по жёстко зашитым смещениям. Поэтому порядок старых полей сохранён, а указатель на новый управляющий блок дописан после них. Обычный ArrayBuffer остался таким же быстрым, но каждый теперь платит за управляющий блок: в браузерной сборке это 80 байт в 64-битной версии и 44 байта в 32-битной. В jsshell блок получился больше — туда добавляются поля очереди ожидающих, и на x64 выходит 112 байт.
С общим буфером нельзя делать то, что можно с обычным. Его нельзя «передать» с отсоединением, как обычный ArrayBuffer: у остальных агентов на те же байты ровно такие же права. При пересылке в другой агент создаётся новая обёртка, а счётчик ссылок увеличивается. Сохранить его в файл или отправить в другой процесс нельзя: адрес блока имеет смысл только внутри текущего процесса. А slice() создаёт новый, независимый буфер с копией байтов.
Не получит сырого указателя на общие байты и код самого браузера. DOM, кодеки и WebGL умеют «одалживать» память обычного ArrayBuffer, но для общего буфера это запрещено, иначе сторонний код читал бы и писал эту память в обход атомарных операций.
Немножко археологии: в коде Presto есть OpSharedMemory — интерфейс именованной разделяемой памяти для обмена между процессами, с реализациями для POSIX и Windows. Код его абсолютно мёртв и нигде не используется; был ли это очередной недоделанный прототип или забытый атавизм, мы достоверно уже не узнаем. Я склоняюсь к первому. Для SharedArrayBuffer он бы всё равно не пригодился: интерфейс не умеет резервировать адреса и расти, да и потребовал бы отдельного объекта времени жизни на стороне GC.
Вспомним OpPseudoThread: внутри одного системного потока создаются «виртуальные», работающие по очереди.
Это кооперативная многозадачность, и теоретически она годится для реализации общей памяти. В каком-то смысле, так было бы даже проще: никаких гонок, никаких проблем с видимостью данных. Пользовательские программы не увидят разницы, хотя работать будут медленнее.
Только такая реализация исказила бы суть, и потому не годится. Да и не так уж она проста — пришлось бы сильно дорабатывать планировщик, для того, чтобы алгоритмы, рассчитанные на настоящий параллелизм, могли отрабатывать в «поочерёдном» режиме.
Другой вариант — использовать настоящие системные потоки и реализовать всё правильно. Но вот беда — Opera однопоточна, и воркеры в ней всё равно живут в главном потоке под внутренним планировщиком. Текущие потребители ничего не выиграют от настоящего многопоточного механизма. Чтобы общая память заработала в браузере, воркеры должны стать настоящими агентами в собственных системных потоках, с полным протоколом завершения: закрытие воркера или документа должно разбудить спящих агентов, отменить асинхронные ожидания, дождаться потоков и освободить память.
Это — большая и сложная задача сама по себе, её лучше отложить до того момента, когда поддержка многопоточности будет добавляться во весь браузер.
Но есть и другая причина не торопиться: общий буфер плюс воркер, который в цикле увеличивает счётчик, — это самодельный таймер очень высокой точности, а именно такой таймер нужен для атак по сторонним каналам вроде Spectre.
В январе 2018 года все крупные браузеры разом отключили SharedArrayBuffer, а вернули в итоге только для страниц в «изоляции от чужих источников» (cross-origin isolation), которую сайт включает заголовками COOP и COEP. Opera 12 о такой изоляции ничего не знает, и делать её придётся с нуля: политики, признак crossOriginIsolated, правила пересылки между источниками.
Но мы зашли слишком далеко, чтобы просто остановиться. Очередной компромисс: реализовать и оттестировать полноценный многопоточный SharedArrayBuffer в jsshell, но не делать фичу доступной в браузере.
В jsshell каждый агент запускается в собственном потоке ОС и создаёт там полноценный экземпляр Carakan: свой runtime, глобальный объект, кучу, очереди заданий и JIT. Глобальное состояние jsshell пришлось разобрать по владельцам, сделав корень g_opera и стек локальными для потока. Неизменяемые таблицы и параметры оболочки, зафиксированные до запуска агентов, остаются общими, а куча исполняемой памяти для JIT под Windows получила синхронизацию.
Итого — фича работает только для тестов. Интерфейс Test262 $262.agent может запустить агентов, выдать им общий буфер, собрать от них отчёты, а при завершении разбудить спящих агентов, дождаться всех потоков и освободить память. Полноценные Web Workers потребуют доработки в самом браузере.
AtomicsВозьмём простейший счётчик в общей памяти и два агента, которые одновременно выполняют:
counter[0] = counter[0] + 1;
Это три отдельных действия: прочитать, прибавить и записать. Оба агента могут прочитать одно и то же старое значение, оба прибавят единицу и оба запишут одинаковый результат. Одно увеличение потеряется. Это классическая гонка: результат зависит от того, как в реальном времени перемежались операции разных потоков.
Обычное средство против таких гонок — блокировка, она же мьютекс. В Python критический участок закрывают threading.Lock, в C++ — std::mutex, на уровне операционной системы используются мьютексы и другие примитивы ожидания. Пока один поток владеет блокировкой, остальные не входят в защищённый участок. Это универсальный механизм: под одной блокировкой можно согласованно изменить несколько полей и проверить произвольные условия. Платить приходится расходами на ожидание, усыпление и пробуждение потоков, а при ошибке — взаимной блокировкой, когда два потока вечно ждут друг друга.
Для одного целого числа такая защита избыточна. Процессор умеет прочитать ячейку, изменить и записать обратно одной неделимой операцией, и именно такие операции собраны в объекте Atomics:
Atomics.add(counter, 0, 1);
Вклиниться между чтением и записью теперь не может никто, а на x86 этот вызов превращается в одну инструкцию lock xadd. Для счётчиков, флагов и прочих состояний, умещающихся в одну ячейку, это проще и дешевле мьютекса. Для чего-то посложнее — прочитать два поля и записать третье — блокировка всё равно нужна, только построенная поверх атомиков.
Carakan реализует весь набор: арифметику и логику (add, sub, and, or, xor), exchange, compareExchange, load, store, isLockFree, а также wait, waitAsync и notify. Главная из них — compareExchange: она записывает новое значение, только если в ячейке всё ещё лежит ожидаемое старое, и на ней строятся и блокировки, и структуры данных вовсе без блокировок. Работают атомики только с целочисленными typed array, включая 64-битные на BigInt.
Правила видимости взяты из модели памяти C++11. Все операции Atomics выполняются в самом строгом режиме, sequentially consistent: все агенты видят их в одном и том же порядке. Именно это позволяет передавать данные между потоками:
// производитель
data[0] = 42;
Atomics.store(flag, 0, 1);
Atomics.notify(flag, 0);
// потребитель
Atomics.wait(flag, 0, 0); // спим, пока флаг равен 0
print(data[0]); // гарантированно 42
wait и notify сделаны по образцу Linux futex: агент засыпает, только если ячейка всё ещё содержит ожидаемое значение, а другой агент, поменяв её, будит спящих на этом адресе. Сам futex Carakan не вызывает — движок должен одинаково работать на Windows и POSIX, — так что очередь ожидающих у него своя.
Внутри всё это стоит на небольшом слое op_atomics.h — по сути, собственном маленьком <atomic> поверх встроенных функций компиляторов: __atomic_* у GCC и Clang, _Interlocked* у MSVC.
Через этот слой идут не только Atomics, но и самые обычные view[0] = 1. Если один поток пишет адрес простым присваиванием C++, а другой в это же время его читает, гонка случится уже в C++, и неопределённым станет поведение всего движка. Поэтому для общего буфера и обычные обращения атомарны, только в самом слабом режиме, relaxed: прочитанное значение всегда будет целиком либо старым, либо новым, без смеси байтов из обоих, но порядок между соседними ячейками не гарантирован. По той же причине set, copyWithin, slice и конструкторы typed array не могут просто вызвать memcpy для общей памяти.
Отдельная ловушка — пользовательский код посреди операции:
Atomics.store(view, index, { valueOf() { buffer.grow(8192); return 1; } });
Пока движок превращает аргумент в число, valueOf() успевает вырастить буфер, запустить сборку мусора или выбросить исключение. Поэтому после всех преобразований границы проверяются заново: указатель и длина, полученные до вызова, могли уже устареть.
JIT пока не ускоряет ни обращения к общим typed array, ни вызовы Atomics — всё уходит на медленный, но безопасный путь интерпретатора. Чтобы это исправить, генератор нужно научить отличать общую память от обычной, выбирать ширину и порядок операции и следить за длиной растущего буфера. Как и прочие доработки JIT, эта отложена в дальний ящик.
После финального этапа работы результаты стали видны сразу же. Многие сайты, просто не работавшие до, стали — медленно, со скрипом, с косяками в дизайне — но работать. Не все, и не сразу: первые же живые тесты нашли много мелких ошибок. Фреймворки Vue и React ещё до того, как выполнить что-то, проверяли наличие некоторых API, отсутствующих в Opera, — пришлось попутно добавить и их. Многое не работает до сих пор, но всё же Carakan перешёл к этапу, когда каждое очередное исправление открывает браузеру огромную часть до того неработающего веба.
Прежде чем оставить эту главу, стоит оглянуться: что ещё пришлось сделать, во что превратился движок и что его ожидает дальше.
Вот ещё несколько существенных изменений, каждое из которых заслуживает собственной главы. Сейчас у меня есть время только на краткий обзор.
Классы получили поля, приватные поля и методы (#name), статические блоки инициализации и проверку #name in object. Приватное имя — это неподделываемый ключ, который существует только внутри тела класса. Хранить значение можно в том же хранилище свойств, что и обычные поля, но ни перечисление свойств, ни Proxy, ни рефлексия его видеть не должны. А инициализация полей привязана к точному моменту конструирования: в классе-наследнике this не существует до вызова super(), и поля появляются ровно после него.
В Carakan приватное имя — отдельный вид ключа в том же хранилище свойств, а не параллельная таблица приватных полей. Каждое вычисление класса создаёт свежий ключ: внутри он похож на символ, но никакой пользовательский Symbol получить его не может. Обычный поиск, перечисление, дескрипторы, клонирование и Proxy такие ключи просто отфильтровывают. Отдельного «бренда» тоже нет: бренд экземпляра — само наличие у него собственного элемента с этим ключом. Поэтому #name in object смотрит только на собственные свойства, без прототипов и ловушек Proxy, а «свежесть» ключа хорошо видна на фабрике классов:
function make() {
return class {
#x = 1;
static has(o) { return #x in o; }
};
}
const A = make(), B = make();
A.has(new A()); // true
A.has(new B()); // false: у каждого вычисления класса свой #x
Асинхронность выросла из Promise до асинхронных итераторов и генераторов, for await...of, Array.fromAsync, вспомогательных методов итераторов и новых комбинаторов Promise. Главная сложность здесь не в количестве методов, а в порядке: лишняя или пропущенная микрозадача меняет последовательность вызовов, и Test262 это проверяет. Стандарт в этом месте менялся и сам: в ES2019, например, await перестал тратить лишние микрозадачи на обёртку уже готового Promise. Так очередь заданий, её владелец и точка обработки микрозадач в браузере стали частью семантики языка, а не просто планировочной инфраструктурой.
Модули получили динамический import(), import.meta, атрибуты импорта, JSON-модули и await на верхнем уровне. Последний превращает граф модулей в асинхронный автомат: родитель может ждать зависимость, циклическая группа модулей должна завершаться согласованно, а отказ одного модуля — дойти до всех, кто его импортирует. Возобновляемое выполнение тела модуля в Carakan оказалось подходящей основой, но загрузчик браузера, CSP, сеть и жизненный цикл документа всё равно пришлось связывать с состоянием языка.
Двоичные данные получили не только SharedArrayBuffer: изменяемые по размеру ArrayBuffer, передача с отсоединением (transfer), typed array с автоматически отслеживаемой длиной, BigInt64Array, Float16Array, новые методы DataView, кодирование в base64 и hex прямо у Uint8Array. Backing store, о котором шла речь выше, — одна общая переделка, которая позволила не реализовывать каждую из этих возможностей отдельным исключением.
Остальное многочисленно, но архитектурно проще: ?. и ??, ... в объектах, новые методы массивов и Set, стабильная сортировка, Object.groupBy и Map.groupBy, Promise.any, Promise.withResolvers и Promise.try, Error.cause, точное сохранение исходного текста функций для toString(), Math.sumPrecise, корректные Unicode-строки. Часть этого синтаксиса компилятор сводит к уже существующему байт-коду, а библиотечные методы написаны как встроенные функции на C++ и пользуются старой объектной моделью.
Впрочем, «проще» не значит «просто». Взять Math.sumPrecise: кажется, это цикл sum += value. Но стандарт требует сначала получить точную математическую сумму и округлить её только один раз — иначе Math.sumPrecise([1e30, 0.1, -1e30]) вернёт ноль вместо 0.1, а результат начнёт зависеть от порядка слагаемых. Реализация пользуется тем, что любое конечное число double кратно 2⁻¹⁰⁷⁴: положительные и отрицательные слагаемые накапливаются отдельно, в двух огромных целых по 34 64-битные части (2176 бит каждое), затем вычитаются и один раз округляются. Плюс NaN, обе бесконечности, знак нуля, предел длины и правильное закрытие итератора при ошибке. Снаружи — один метод Math, внутри — собственная длинная арифметика, почти как у BigInt.
Поэтому «поддержка ES2026» затрагивает лексер, области видимости, байт-код, интерпретатор, границы JIT, сборщик мусора, строки, массивы, typed array, планировщик, загрузчик модулей, структурное клонирование и браузерный host — почти всё, что есть в движке. Это, без преувеличения, самое большое изменение с момента начала «воскрешения» Opera.
Работа такого масштаба часто заканчивается непредсказуемо, даже если распланировать всё заранее. Мне же ещё хотелось соблюсти дух старой школы, спроектировав всё в соответствии с заложенными в архитектуре принципами.
Благо, эти принципы работали на проект, не раз и не два подсказывая решения. То там, то тут проявлялся шов, идеально подходящий для того, чтоб приживить к нему новую фичу. Это целиком заслуга оригинальных проектировщиков — опытный инженер всегда видит примерные пути развития системы и оставляет возможность для её расширения.
Переписывать целиком не пришлось ни компилятор, ни сборщик мусора, ни JIT. Ближе всего к переписыванию подошло владение памятью ArrayBuffer: раньше объект сам владел своими байтами, теперь это обёртка над отдельным backing store со счётчиком ссылок, видами хранилищ, резервированием адресов и очередью ожидающих. Старая модель времени жизни фактически заменена новой, но внешние классы typed array и зашитые в JIT смещения полей сохранены — так что и это переписывание одного внутреннего механизма, а не всей подсистемы.
Однако апгрейд выявил несколько мест, которые просят переделки. Метки типов в заголовках GC исчерпаны, и новые типы приходится маскировать под существующие. Состояние FinalizationRegistry — массив фиксированных слотов, а не самодокументируемый тип. Опять же — однопоточность. Запас расширяемости тут исчерпан: где-то нужно раздвигать границы, где-то — пересматривать сами принципы. Но и после этих изменений, уверен, движок останется узнаваемым Carakan.
Ну и самая большая текущая проблема — чудовищный разрыв между полнотой и скоростью: семантически движок догнал стандарт, а JIT — нет. Но тут я точно не стану торопиться: сначала нужно как следует оттестировать и оптимизировать нативное исполнение. Это работа не на одну неделю, и не для одного человека, но я собираюсь что-нибудь придумать.