Русский
Русский
English
Статистика
Реклама

Стек вызовов JavaScript и ещё большая магия



В начале апреля на хабре была опубликована статья JavaScript: Стек вызовов и магия его размера её автор пришёл к выводу, что каждый кадр стека занимает (72 + 8 * число_локальных_переменных) байтов: Получается, что мы посчитали все верно и можем утверждать, что размер пустого ExecutionStack в Chrome равен 72 байтам, а размер коллстэка чуть меньше одного мегабайта. Отличная работа!

Для затравки немного изменим код, использованный AxemaFr для экспериментов:

{let i = 0;const func = () => {  i += 1.00000000000001;  func();};try {  func();} catch (e) {  console.log(i);}}

Вместо 1 теперь на каждом шаге прибавляем чуточку больше, и в результате вместо 13951 получаем 12556.000000000002 как будто бы в функции добавилась локальная переменная!

Повторим вопросы, которыми задавался Senior Frontend Developer AxemaFr: Почему же так? Что изменилось? Как понять, посмотрев на функцию, сколько раз она может выполниться рекурсивно?!

Готовим инструменты


В командной строке Chrome можно передавать аргументы для JS-движка; в частности, можно поменять и размер стека с 984 КБ на любой другой ключом --js-flags=--stack-size=.

Разобраться в том, сколько стека требуется каждой функции, нам поможет ключ --print-bytecode, уже упоминавшийся ранее. Не упоминалось то, что отладочный вывод направляется в stdout, которого у Chrome под Windows тупо нет, потому что он скомпилирован как GUI-приложение. Исправить это несложно: сделайте копию chrome.exe, и в своём любимом hex-редакторе исправьте байт 0xD4 со значения 0x02 на 0x03 (тем, кто с hex-редактором не дружит, этот байт поможет исправить скрипт на Python). Но если вы прямо сейчас читаете эту статью в Chrome, и просто запустите исправленный файл предположим, что вы назвали его cui_chrome.exe то откроется новое окно в уже существующем экземпляре браузера, и аргумент --js-flags будет проигнорирован. Чтобы запустить новый экземпляр Chrome, нужно передать ему какую-нибудь новую --user-data-dir:
cui_chrome.exe --no-sandbox --js-flags="--print-bytecode --print-bytecode-filter=func" --user-data-dir=\Windows\Temp

Без --print-bytecode-filter вы утонете в километровых дампах байткода функций, встроенных в Chrome.

После запуска браузера откройте консоль разработчика и введите код, использованный AxemaFr:

{let i = 0;const func = () => {  i++;  func();};func()}

Ещё до того, как вы нажмёте на Enter, в консольном окне позади Chrome появится дамп:
[generated bytecode for function: func (0x44db08635355 <SharedFunctionInfo func>)]Parameter count 1Register count 1Frame size 8   36 S> 000044DB086355EE @    0 : 1a 02             LdaCurrentContextSlot [2]         000044DB086355F0 @    2 : ac 00             ThrowReferenceErrorIfHole [0]         000044DB086355F2 @    4 : 4d 00             Inc [0]         000044DB086355F4 @    6 : 26 fa             Star r0         000044DB086355F6 @    8 : 1a 02             LdaCurrentContextSlot [2]   37 E> 000044DB086355F8 @   10 : ac 00             ThrowReferenceErrorIfHole [0]         000044DB086355FA @   12 : 25 fa             Ldar r0         000044DB086355FC @   14 : 1d 02             StaCurrentContextSlot [2]   44 S> 000044DB086355FE @   16 : 1b 03             LdaImmutableCurrentContextSlot [3]         000044DB08635600 @   18 : ac 01             ThrowReferenceErrorIfHole [1]         000044DB08635602 @   20 : 26 fa             Star r0   44 E> 000044DB08635604 @   22 : 5d fa 01          CallUndefinedReceiver0 r0, [1]         000044DB08635607 @   25 : 0d                LdaUndefined   52 S> 000044DB08635608 @   26 : ab                ReturnConstant pool (size = 2)Handler Table (size = 0)Source Position Table (size = 12)

Как изменится дамп, если строчку i++; заменить на i += 1.00000000000001;?
[generated bytecode for function: func (0x44db0892d495 <SharedFunctionInfo func>)]Parameter count 1Register count 2Frame size 16   36 S> 000044DB0892D742 @    0 : 1a 02             LdaCurrentContextSlot [2]         000044DB0892D744 @    2 : ac 00             ThrowReferenceErrorIfHole [0]         000044DB0892D746 @    4 : 26 fa             Star r0         000044DB0892D748 @    6 : 12 01             LdaConstant [1]         000044DB0892D74A @    8 : 35 fa 00          Add r0, [0]         000044DB0892D74D @   11 : 26 f9             Star r1         000044DB0892D74F @   13 : 1a 02             LdaCurrentContextSlot [2]   37 E> 000044DB0892D751 @   15 : ac 00             ThrowReferenceErrorIfHole [0]         000044DB0892D753 @   17 : 25 f9             Ldar r1         000044DB0892D755 @   19 : 1d 02             StaCurrentContextSlot [2]   60 S> 000044DB0892D757 @   21 : 1b 03             LdaImmutableCurrentContextSlot [3]         000044DB0892D759 @   23 : ac 02             ThrowReferenceErrorIfHole [2]         000044DB0892D75B @   25 : 26 fa             Star r0   60 E> 000044DB0892D75D @   27 : 5d fa 01          CallUndefinedReceiver0 r0, [1]         000044DB0892D760 @   30 : 0d                LdaUndefined   68 S> 000044DB0892D761 @   31 : ab                ReturnConstant pool (size = 3)Handler Table (size = 0)Source Position Table (size = 12)

Теперь разберёмся, что поменялось и почему.

Исследуем примеры


Все опкоды V8 описаны в github.com/v8/v8/blob/master/src/interpreter/interpreter-generator.cc
Первый дамп расшифровывается так:
LdaCurrentContextSlot [2]           ; a := context[2]ThrowReferenceErrorIfHole [0]       ; if (a === undefined)                                    ;   throw("ReferenceError: %s is not defined", const[0])Inc [0]                             ; a++Star r0                             ; r0 := aLdaCurrentContextSlot [2]           ; a := context[2]ThrowReferenceErrorIfHole [0]       ; if (a === undefined)                                    ;   throw("ReferenceError: %s is not defined", const[0])Ldar r0                             ; a := r0StaCurrentContextSlot [2]           ; context[2] := aLdaImmutableCurrentContextSlot [3]  ; a := context[3]ThrowReferenceErrorIfHole [1]       ; if (a === undefined)                                    ;   throw("ReferenceError: %s is not defined", const[1])Star r0                             ; r0 := aCallUndefinedReceiver0 r0, [1]      ; r0()LdaUndefined                        ; a := undefinedReturn

Последний аргумент опкодов Inc и CallUndefinedReceiver0 задаёт feedback slot, в котором для оптимизатора накапливается статистика об использованных типах. На семантику байткода это не влияет, так что нас сегодня совсем не интересует.

Под дампом есть приписка: Constant pool (size = 2) и действительно видим, что в байткоде используются две строки "i" и "func" для подстановки в сообщение исключения, когда символы с такими именами undefined. Есть приписка и над дампом: Frame size 8 в соответствии с тем, что в функции используется один регистр интерпретатора (r0).

Стековый кадр нашей функции состоит из:

  • единственного аргумента this;
  • адреса возврата;
  • числа переданных аргументов (arguments.length);
  • ссылки на constant pool c используемыми строками;
  • ссылки на context с локальными переменными;
  • ещё трёх указателей, нужных движку; и наконец,
  • места для одного регистра.

Итого 9*8=72 байта, как синьор AxemaFr и вычислил.

Из семи перечисленных слагаемых, теоретически, меняться могут три число аргументов, наличие constant pool, и число регистров. Что у нас получилось в варианте с 1.00000000000001?

LdaCurrentContextSlot [2]      ; a := context[2]ThrowReferenceErrorIfHole [0]  ; if (a === undefined)                               ;   throw("ReferenceError: %s is not defined", const[0])Star r0                        ; r0 := aLdaConstant [1]                ; a := const[1]Add r0, [0]                    ; a += r0Star r1                        ; r1 := a                               ; ...дальше как раньше

Во-первых, прибавляемая константа заняла третье место в constant pool; во-вторых, для её загрузки понадобился ещё один регистр, так что стековый кадр функции вырос на 8 байтов.

Если не использовать в функции именованные символы, то можно обойтись без constant pool. На github.com/v8/v8/blob/master/src/execution/frame-constants.h#L289 описан формат стекового кадра V8 и указано, что когда constant pool не используется, то размер стекового кадра сокращается на один указатель. Как в этом удостовериться? На первый взгляд кажется, что функция, не использующая именованные символы, не может быть рекурсивной; но взгляните-ка:

{let i = 0;function func() {  this()();};const helper = () => (i++, func.bind(helper));try {  helper()();} catch (e) {  console.log(i);}}

[generated bytecode for function: func (0x44db0878e575 <SharedFunctionInfo func>)]Parameter count 1Register count 1Frame size 8   37 S> 000044DB0878E8DA @    0 : 5e 02 02 00       CallUndefinedReceiver1 <this>, <this>, [0]         000044DB0878E8DE @    4 : 26 fa             Star r0   43 E> 000044DB0878E8E0 @    6 : 5d fa 02          CallUndefinedReceiver0 r0, [2]         000044DB0878E8E3 @    9 : 0d                LdaUndefined   47 S> 000044DB0878E8E4 @   10 : ab                ReturnConstant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 8)

Цель Constant pool (size = 0) достигнута; но переполнение стека, как и раньше, происходит через 13951 вызов. Это значит, что даже когда constant pool не используется, стековый кадр функции всё равно содержит указатель на него.

А удастся ли добиться меньшего размера стекового кадра, чем вычисленное AxemaFr минимальное значение? да, если внутри функции не использовать ни один регистр:
{function func() {  this();};let chain = ()=>null;for(let i=0; i<15050; i++)  chain = func.bind(chain);chain()}

[generated bytecode for function: func (0x44db08c34059 <SharedFunctionInfo func>)]Parameter count 1Register count 0Frame size 0   25 S> 000044DB08C34322 @    0 : 5d 02 00          CallUndefinedReceiver0 <this>, [0]         000044DB08C34325 @    3 : 0d                LdaUndefined   29 S> 000044DB08C34326 @    4 : ab                ReturnConstant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 6)

(При этом цепочка из 15051 вызова уже приводит к RangeError: Maximum call stack size exceeded.)

Таким образом, вывод синьора AxemaFr о том, что размер пустого ExecutionStack в Chrome равен 72 байтам, успешно опровергнут.

Уточняем предсказания


Мы можем утверждать, что минимальный размер стекового кадра для JS-функции в Chrome равен 64 байтам. К этому нужно прибавить по 8 байтов за каждый объявленный формальный параметр, ещё по 8 байтов за каждый фактический параметр сверх числа объявленных, и ещё по 8 байтов за каждый использованный регистр. По регистру отводится для каждой локальной переменной, для загрузки констант, для обращения к переменным из внешнего контекста, для передачи фактических параметров при вызовах, и т.д. Точное число использованных регистров по исходному тексту на JS вряд ли возможно определить. Стоит отметить, что интерпретатор JS поддерживает неограниченное число регистров они не имеют отношения к регистрам процессора, на котором интерпретатор выполняется.

Теперь понятно, почему:
  • добавление неиспользуемого формального параметра (func = (x) => { i++; func(); };) потребляет столько же памяти, как дополнительная локальная переменная;
  • передача необъявленного фактического параметра (func = () => { i++; func(1); };) потребляет вдвое больше памяти, чем дополнительная локальная переменная потому что для передачи понадобился дополнительный регистр:
    [generated bytecode for function: func (0x44db08e12da1 <SharedFunctionInfo func>)]Parameter count 1Register count 2Frame size 16   34 S> 000044DB08E12FE2 @    0 : 1a 02             LdaCurrentContextSlot [2]         000044DB08E12FE4 @    2 : ac 00             ThrowReferenceErrorIfHole [0]         000044DB08E12FE6 @    4 : 4d 00             Inc [0]         000044DB08E12FE8 @    6 : 26 fa             Star r0         000044DB08E12FEA @    8 : 1a 02             LdaCurrentContextSlot [2]   35 E> 000044DB08E12FEC @   10 : ac 00             ThrowReferenceErrorIfHole [0]         000044DB08E12FEE @   12 : 25 fa             Ldar r0         000044DB08E12FF0 @   14 : 1d 02             StaCurrentContextSlot [2]   39 S> 000044DB08E12FF2 @   16 : 1b 03             LdaImmutableCurrentContextSlot [3]         000044DB08E12FF4 @   18 : ac 01             ThrowReferenceErrorIfHole [1]         000044DB08E12FF6 @   20 : 26 fa             Star r0         000044DB08E12FF8 @   22 : 0c 01             LdaSmi [1]         000044DB08E12FFA @   24 : 26 f9             Star r1   39 E> 000044DB08E12FFC @   26 : 5e fa f9 01       CallUndefinedReceiver1 r0, r1, [1]         000044DB08E13000 @   30 : 0d                LdaUndefined   48 S> 000044DB08E13001 @   31 : ab                ReturnConstant pool (size = 2)Handler Table (size = 0)Source Position Table (size = 12)
    
  • изменение в предыдущем примере добавляемого значения на 1.00000000000001 не влияет на размер стекового кадра потому что один и тот же r1 используется и для загрузки константы, и для передачи фактического параметра.


Источник: habr.com
К списку статей
Опубликовано: 12.04.2021 18:13:34
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании маклауд

Javascript

Программирование

Совершенный код

Магия

Расследования

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru