Где-то месяц назад Carakan получил поддержку ECMAScript 2015. Рой AI-агентов проапгрейдил движок за неделю, что не может не впечатлять, ведь разница между ES5 и ES6 революционна! Новый синтаксис, объектная модель, стандартизация поведения, нелинейное исполнение…
Апгрейд ES6 → ES2026 (сюда я включаю все промежуточные спецификации) обещал быть колоссальным по другим причинам. Не перерабатывая сам язык, стандарты добавили огромное количество новых API, изменили поведение RegExp, ввели новые типы, описали модель многопоточности и чёрт знает что ещё. В конце концов, между ES5 и ES6 прошло шесть лет, а между ES6 и ES2026 — почти вдвое больше.
Работа над ECMAScript 2026 началась сразу после ES2015. Где-то неделю назад процесс перешёл от стадии промежуточных тестов отдельных участков кода к тестированию всего движка в сборке. Это требует чертовски много времени и сил, и я не хочу отвлекаться посреди процесса, поэтому сегодня поделюсь только одной историей. Она покажет всего одну из сотен возникших проблем; решение было не самым долгим, но, пожалуй, одним из самых технически изощрённых.
Множество раз я упоминал, что Opera работает с DOM и JavaScript в одном потоке. Тому есть и исторические причины, и практические; сейчас останавливаться на них не буду. Однако JS-движку как-то нужно обрабатывать несколько задач, хотя бы выглядящих одновременными — скажем, выполнять разные программы для каждой открытой вкладки.
Для этого в Carakan существует класс OpPseudoThread. Смысл дословно соответствует названию: это реализация псевдопотока, умеющего останавливаться и продолжаться, не будучи при этом потоком операционной системы. По современным меркам это родственник корутины, написанный ещё тогда, когда подобные механизмы редко встречались в обычном прикладном C++.
Каждому псевдопотоку выделяется собственный участок памяти, используемый как стек. На нём Carakan может глубоко проваливаться в выполнение JavaScript, приостанавливать его и затем возвращаться ровно в то же место. Если движку требуется вызвать код браузера на обычном системном стеке, псевдопоток временно откладывает свой стек, переключает процессор на исходный и выполняет вызов там. Если имеющейся памяти не хватает, псевдопоток может выделить ещё один сегмент и продолжить уже на нём.
С точки зрения архитектуры — красиво и эффективно. Само переключение выполняют крошечные фрагменты машинного кода. Они сохраняют регистры, подменяют указатель стека и возвращаются уже в другую цепочку вызовов. С точки зрения вызывающего кода работа выглядит полностью однопоточной, и ни о каких переключениях этот код не подозревает.
А вот с точки зрения Windows такие фокусы выглядят подозрительно.
Если ваш код падает, в большинстве случаев можно прикинуть, что пошло не так. Разыменовал нулевой указатель, вышел за границу массива, попытался исполнить мусор — через отладчик можно посмотреть адрес, стек вызовов и понять хотя бы примерный вектор исследования. Существуют исключения — например, когда проблема отказывается воспроизводиться под отладчиком или когда приходится отлаживать многопоточный код. Хуже всего, когда эти условия сочетаются.
Однажды набор тестов Carakan под Windows оборвался посреди проверки нехватки памяти. Ни ошибки, ни результата — только вылет программы со статусом STATUS_BAD_STACK. Эксперименты с другими вариантами сборки выдавали STATUS_BAD_FUNCTION_TABLE. Либо плохой стек, либо плохая таблица функций в памяти; совершенно недостаточно, чтобы понять причину (хотя сейчас, обладая послезнанием, вы можете о чём-то догадываться). Под отладчиком всё это выглядело… неоднозначно — и, прочтя дальше, вы можете понять, почему.
В MSDN-описаниях обоих кодов есть нечто общее: слова during an unwind. Windows определяет STATUS_BAD_STACK как неверный или невыровненный стек, встреченный при размотке, а STATUS_BAD_FUNCTION_TABLE — как некорректную таблицу функций, встреченную там же. Значит, «проверка нехватки памяти» запускает механизм размотки Windows.
Всё ещё непонятно!
В очередной раз вспомним, как в Opera реализованы исключения: это механизм TRAP/LEAVE. Его удобно представлять как «попробовать выполнить операцию» и «немедленно вернуться к ближайшему обработчику с ошибкой». Заодно Opera ведёт список временных объектов, которые надо удалить при таком возврате: память должна освобождаться и при успешном выполнении, и при аварийном выходе.
В основе этого механизма лежат макросы op_setjmp/op_longjmp. На Windows-пути они отображаются на вызовы setjmp/longjmp, на UNIX-пути — на _setjmp/_longjmp. Первая пара есть в стандарте ISO C, вторая — только в POSIX. POSIX-версия не разматывает кадры, просто восстанавливая сохранённые регистры и перенося управление в нужную точку. Linux не особенно интересно, через какие приключения программа добралась оттуда сюда. Отказываясь от размотки, эти функции работают быстрее, что, видимо, и определило их использование.
На 64-битной Windows всё иначе. Перед прыжком среда исполнения разматывает цепочку вызовов: проходит по кадрам, проверяет их и восстанавливает состояние по служебным таблицам. Обычный скомпилированный код содержит описание того, как это делать. А у переключателей OpPseudoThread, скопированных в исполняемую память как массивы байтов, таких таблиц нет. Даже хуже: цепочка внезапно перескакивает из одного физического стека в другой. Это совершенно не то, чего ожидает Windows!
Поэтому, когда внутренний LEAVE пытался добраться до обработчика по другую сторону переключения, операционная система начинала разматывать кадры, находила разрыв и останавливала процесс. STATUS_BAD_STACK означал, что очередной кадр не принадлежит ожидаемому стеку. STATUS_BAD_FUNCTION_TABLE появлялся, когда размотка доходила до сгенерированного кода, для которого не существовало подходящего описания.
Эту гипотезу удалось подтвердить минимальным тестом. Попался, который кусался!
Хотя OpPseudoThread существовал и раньше, проблема никогда не проявляла себя именно так, как в этом случае. Почему?
Во-первых, мы внесли исправления в подсистему переключения стеков. До этого Windows всё время считала активным системный стек и при падении возвращала более загадочные результаты. Когда границы стали корректными, операционная система смогла уверенно упасть с точной ошибкой. То есть Opera когда-то вполне могла умирать по той же причине, но с другими симптомами.
Во-вторых, такая ошибка проскользнула под радарами тестов. Как уже сказано, на Linux эта проблема не воспроизводится, да и в Windows она проявилась только в x64-окружении, то есть начиная с Opera 12.00.
В нашем случае спусковым крючком стал тест async generators, намеренно создающий нехватку памяти. Carakan в этот момент выполняет работу на своём стеке, временно переходит на системный, обнаруживает, что продолжать невозможно, и выбрасывает LEAVE. В однопоточной логике обычная внутренняя ошибка должна была добраться до верхнего обработчика и превратиться в аккуратный результат теста.
В логике псевдопотоков LEAVE пытается пройти обратно через разрыв между стеками, что приводит к моментальному закрытию процесса операционной системой.
Технически проблема может проявляться не только при OOM. Через ту же границу проходят обращения Carakan к браузерному окружению, операции с резервированием дополнительного стека, глубокая рекурсия и множество внутренних вызовов. Нехватка памяти просто оказалась удобным и воспроизводимым способом наткнуться на архитектурную болячку.
Я не склонен считать, что это проблема Windows. Оба варианта вызовов нарушают архитектурный контракт. То, что Linux годами спокойно перепрыгивал через границы стеков, лишь позволяло нарушению оставаться незаметным.
Итак, у вас возникла ошибка, но сообщить о ней «наверх» вы не можете. Есть над чем поразмыслить, не так ли?
Подсказка нашлась там же, где и источник проблемы. Проектировщики механизма предчувствовали — я не могу подобрать здесь лучшего определения — проблему и оставили предупреждение в заголовочном файле OpPseudoThread.h:
A pseudo thread's implementation should take be care about using certain
mechanisms. TRAP/LEAVE "inside" a pseudo thread probably works, whereas
leaving out of the thread (across a call to Start() or Resume()) might not
be a good idea. But really, the safest is probably to use Yield() in
exceptional situations, and translate it to LEAVE afterwards. Also, relying
on stack unwinding obviously has the same issues as with TRAP/LEAVE; if
there is any chance that a yielded thread is destroyed rather than resumed
(which seems inevitable) all data referenced from the stack must be possible
to clean up externally. But since available stack space is also fixed (at
thread creation time) conservative usage of the stack is wise disregarding
the unwind issues.
Абсолютно точное предсказание проблемы, с которой они никогда не сталкивались, и столь же точное решение: не выходить из псевдопотока через LEAVE, а сделать Yield(), преобразовав ошибку уже после возвращения.
Именно так теперь и устроен весь путь. Код, выполняемый по другую сторону переключения, получает локальный обработчик на своём физическом стеке. Если возникает LEAVE, он ловится там же, где был вызван. Через границу переносится не управление, а обычное числовое значение ошибки. Затем штатный переключатель возвращает регистры и указатель стека предусмотренным способом. Когда исполнение снова находится на непрерывном системном стеке, Opera повторно выдаёт сохранённую ошибку внешнему обработчику.
Получился своеобразный шлюз. Исключение подходит к границе, превращается в данные, проезжает через неё и снова становится исключением только на безопасной стороне.
Для некоторых переходов этого всё ещё недостаточно. Внутри стека потока между текущей точкой и обработчиком может находиться машинный код, сгенерированный самим Carakan и также не имеющий данных для размотки Windows. Определить заранее, безопасен ли каждый конкретный путь, невозможно: ошибка в такой проверке приводит к немедленной смерти процесса. Поэтому при сомнительном пути текущая активация Carakan полностью прекращается, псевдопоток штатно возвращается на корневой системный стек, и ошибка поднимается уже оттуда.
Это консервативное решение. Мы не пытаемся продолжать внутренний вызов после серьёзной ошибки, зато не зависим от того, какие именно сгенерированные кадры оказались между двумя точками. Для OOM и других терминальных ошибок это именно то, что нужно.
Безопасный возврат управления — только половина дела. При LEAVE Opera должна пройти цепочку зарегистрированных объектов и освободить принадлежащие им ресурсы. Исторически эта цепочка общая для логического потока: в ней могут вперемешку находиться элементы с системного стека, основного стека Carakan и дополнительных сегментов, выделенных для глубокой рекурсии.
Пока longjmp без вопросов перепрыгивал, куда требовалось, конструкция выглядела работающей. После появления явных границ стало необходимо знать, к какому физическому стеку относится каждый участок цепочки — пришлось добавить списки очистки для каждого сегмента отдельно.
Однако обычный LEAVE очищает элементы списка только до первого CleanupCatcher: тот соответствует ближайшему TRAP, удаляет себя из цепочки и делает longjmp в ещё живой кадр. Но при полном отказе от активации ни один TRAP на её сегментах больше не является точкой назначения — все эти кадры будут выброшены штатным Yield() до самого корня. Если остановиться на первом catcher’е, объекты, зарегистрированные раньше него и лежащие дальше к хвосту списка, останутся без очистки.
Поэтому появился отдельный режим CleanupSegment(). Он идёт до конца цепочки покидаемого физического сегмента. У обычного элемента он вызывает виртуальную очистку, а CleanupCatcher распознаёт отдельно и лишь отсоединяет базовым CleanupItem::Cleanup(), не вызывая переопределение с longjmp. После разделения списков по сегментам конец такой цепочки действительно означает границу одного стека, поэтому обход не задевает живые объекты системного или соседнего стека. Затем та же операция повторяется для каждого припаркованного внешнего блока.
Фух! Редко бывает достаточно найти проблему и решение для неё. Само исправление может вскрывать проблемы, предвидеть которые невероятно сложно. Уверен, что с подобным я столкнусь ещё не один раз.
OpPseudoThread — великолепный механизм для своей задачи (который к тому же теперь и работает как надо). Но можно ли заменить его на настоящие системные потоки?
Да! Твёрдо и чётко!
Я в этом так уверен, потому что в коде есть готовое решение OPPSEUDOTHREAD_THREADED. Это имплементация OpPseudoThread, работающая поверх pthreads и Win32. Start() создаёт воркер в отдельном потоке, после чего вызывающая сторона и воркер передают друг другу право исполнения через mutex и condition variable. Yield() будит исходную сторону и усыпляет воркер; Resume() делает обратное; Suspend() просит исходный поток выполнить callback; если Reserve() обнаруживает нехватку места, он создаёт ещё один вложенный системный поток с новым стеком. Закончившийся поток даже держится в глобальном кэше для повторного использования. Чем-то это похоже на созданный нами OpWorkerThread, и я об этом ещё обязательно вспомню.
Такой вариант, конечно, не выполняет JavaScript параллельно. Он просто обеспечивает альтернативный механизм его исполнения. Комментарий к TWEAK_OPPSEUDOTHREAD_THREADED описывает его как средство разработки и отладки, удобное для профилировщиков, но слишком тяжёлое для продакшена: системный стек, объект ядра и переключение планировщика обходятся заметно дороже сохранения нескольких регистров.
Но можно ли использовать такой механизм (после каких-то доработок) для истинного параллелизма?
Нет, и дело не в выборе механизма.
Carakan, DOM, сборщик мусора и окружающий браузерный код рассчитаны на кооперативное владение состоянием. Для любого настоящего параллельного исполнения придётся пересмотреть владение heap’ами, доступ к DOM, thread-local состояние, остановку GC, передачу результатов между планировщиками и решить проблемы, по сравнению с которыми описанная выше покажется лёгкой разминкой.
На сегодня — всё.
Дальше — отладка нового Carakan, которая займёт, по моим прикидкам, бесконечное количество времени. И примерно столько же может занять рассказ о том, что и как реализовано. Допускаю, что следующая часть будет писаться дольше обычного, и, скорее всего, это будет отдельный подцикл «Мы пилили Carakan, много наших полегло».