Глубокий JS. Области тьмы или где живут переменные

20d0befa5477f5d688bd561161faf4be

Уровень:  Senior,  Senior+

В статье Глубокий JS. В память и типах и данных мы говорили о том, как выглядит структура переменной каждого конкретного типа в памяти движка V8. В этой статье предлагаю теперь рассмотреть, где именно эти переменные хранятся и каким образом попадают в память.

Как обычно, исследовать будем последнюю, на момент написания статьи, версию движка (12.2.136).

Абстрактное синтаксическое дерево (АСД)

Прежде чем мы перейдем непосредственно к переменным, стоит пару слов сказать о том, откуда вообще V8 их берет. Ведь код JavaScript, как и любой другой программный код — всего лишь, удобный для человеческого восприятия текст. Который парсится и преобразуется в машинный код (понятный уже непосредственно исполняемой среде, а не человеку).

Традиционно, языки программирования парсят текст программного кода и раскладывают в структуру под название Абстрактное синтаксическое дерево или АСД (AST в привычном английском варианте). Разработчики V8 не стали здесь изобретать велосипед и пошли по тому же проверенному пути.

Получив на вход файл или строку, движок разбирает текст и раскладывает инструкции в дерево АСД.

Например, код для алгоритма Евклида

while (b !== 0)
  if (a > b) a = a - b
  else b = b - a;

В распарсенном виде будет выглядеть вот так

%> v8-debug --print-ast test.js
[generating bytecode for function: ]
--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. BLOCK at -1
. . EXPRESSION STATEMENT at -1
. . . ASSIGN at -1
. . . . VAR PROXY local[0] (0x7fe71480ba60) (mode = TEMPORARY, assigned = true) ".result"
. . . . LITERAL undefined
. . WHILE at 0
. . . COND at 9
. . . . NOT at 9
. . . . . EQ_STRICT at 9
. . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b"
. . . . . . LITERAL 0
. . . BODY at 18
. . . . IF at 18
. . . . . CONDITION at 24
. . . . . . GT at 24
. . . . . . . VAR PROXY unallocated (0x7fe71480bc00) (mode = DYNAMIC_GLOBAL, assigned = true) "a"
. . . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b"
. . . . . THEN at 29
. . . . . . EXPRESSION STATEMENT at 29
. . . . . . . ASSIGN at -1
. . . . . . . . VAR PROXY local[0] (0x7fe71480ba60) (mode = TEMPORARY, assigned = true) ".result"
. . . . . . . . ASSIGN at 31
. . . . . . . . . VAR PROXY unallocated (0x7fe71480bc00) (mode = DYNAMIC_GLOBAL, assigned = true) "a"
. . . . . . . . . SUB at 35
. . . . . . . . . . VAR PROXY unallocated (0x7fe71480bc00) (mode = DYNAMIC_GLOBAL, assigned = true) "a"
. . . . . . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b"
. . . . . ELSE at 46
. . . . . . EXPRESSION STATEMENT at 46
. . . . . . . ASSIGN at -1
. . . . . . . . VAR PROXY local[0] (0x7fe71480ba60) (mode = TEMPORARY, assigned = true) ".result"
. . . . . . . . ASSIGN at 48
. . . . . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b"
. . . . . . . . . SUB at 52
. . . . . . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b"
. . . . . . . . . . VAR PROXY unallocated (0x7fe71480bc00) (mode = DYNAMIC_GLOBAL, assigned = true) "a"
. RETURN at -1
. . VAR PROXY local[0] (0x7fe71480ba60) (mode = TEMPORARY, assigned = true) ".result"

Здесь мы видим родительские узлы (вершины дерева), которые представляют операторы, и концевые узлы (листья дерева), которые представляют переменные.

Уже на этом этапе можно заметить, что переменные задекларированы, но память под них еще не выделена. Для каждой такое переменной в АСД создается некий VariableProxy узел, который и будет представлять конкретную переменную в памяти. При чем, таких VariableProxy на одну переменную может ссылаться сразу несколько. Дело в том, что процесс выделения памяти будет происходить позже и в другом месте, в Scope (об этом чуть ниже), а VariableProxy — своего рода ссылка-плейсхолдер. Напрямую АСД никогда к переменным не обращается, только через VariableProxy.

VariableMode

Теперь давайте разберемся с тем, каких типов бывают переменные в V8. Условно, все переменные можно разделить на три группы

Пользовотельские переменные

Переменные, которые пользователь может объявить явным (или неявным) образом. Таких всего три

  • kLet — объявляется лексемой «let»

  • kConst — объявляется лексемой «const»

  • kVar — объявляется лексемами «var» и «function»

Переменные компилятора

К ним относят внутренние временные переменные и динамические — переменные, не объявленные явным образом

  • kTemporary — не видна пользователю, живет в стэке

  • kDynamic — объявление/декларация переменной не известна, всегда требует поиска

  • kDynamicGlobal — объявление/декларация переменной не известна, требует поиска, но известно, что переменная глобальная

  • kDynamicLocal — объявление/декларация переменной не известна, требует поиска, но известно, что переменная локальная

a = "a"; // создаст переменную DYNAMIC_GLOBAL a; 

Классовые приватные переменные

Переменные для приватных классовых методов и аксессоров. Требуют проверки прав и живут в контексте класса.

  • kPrivateMethod — не может существовать в одном Scope с другой переменной с таким же именем

  • kPrivateSetterOnly — не может существовать в одном Scope с другой переменной с таким же именем, кроме kPrivateGetterOnly

  • kPrivateGetterOnly — не может существовать в одном Scope с другой переменной с таким же именем, кроме kPrivateSetterOnly

  • kPrivateGetterAndSetter — если существуют две переменные kPrivateSetterOnly и kPrivateGetterOnly с одинаковым именем, они преобразуются в одну переменную с этим типом

src/common/globals.h#1718

// The order of this enum has to be kept in sync with the predicates below.
enum class VariableMode : uint8_t {
  // User declared variables:
  kLet,  // declared via 'let' declarations (first lexical)
  
  kConst,  // declared via 'const' declarations (last lexical)
  
  kVar,  // declared via 'var', and 'function' declarations
  
  // Variables introduced by the compiler:
  kTemporary,  // temporary variables (not user-visible), stack-allocated
               // unless the scope as a whole has forced context allocation
  
  kDynamic,  // always require dynamic lookup (we don't know
             // the declaration)
  
  kDynamicGlobal,  // requires dynamic lookup, but we know that the
                   // variable is global unless it has been shadowed
                   // by an eval-introduced variable
  
  kDynamicLocal,  // requires dynamic lookup, but we know that the
                  // variable is local and where it is unless it
                  // has been shadowed by an eval-introduced
                  // variable

  // Variables for private methods or accessors whose access require
  // brand check. Declared only in class scopes by the compiler
  // and allocated only in class contexts:
  kPrivateMethod,  // Does not coexist with any other variable with the same
                   // name in the same scope.

  kPrivateSetterOnly,  // Incompatible with variables with the same name but
                       // any mode other than kPrivateGetterOnly. Transition to
                       // kPrivateGetterAndSetter if a later declaration for the
                       // same name with kPrivateGetterOnly is made.

  kPrivateGetterOnly,  // Incompatible with variables with the same name but
                       // any mode other than kPrivateSetterOnly. Transition to
                       // kPrivateGetterAndSetter if a later declaration for the
                       // same name with kPrivateSetterOnly is made.

  kPrivateGetterAndSetter,  // Does not coexist with any other variable with the
                            // same name in the same scope.

  kLastLexicalVariableMode = kConst,
};

Isolate

Еще один важный аспект V8 — IsolateIsolate — это абстракция, которая представляет изолированный экземпляр движка. Именно здесь и будет храниться состояние движка. Все, что находится внутри конкретного Isolate, не может использоваться в другом Isolate. Сам Isolate не является потоко-безопасным. Т.е. к нему может обращаться одновременно только один поток. Для организации многопоточности на стороне «встраивателя» (Embedder), например браузера, команда V8 предлагает использовать Locker/Unlocker API. В качестве примера Isolate можно взять, допустим, таб браузера или Worker.

Scope

В спецификации ECMAScript понятие области видимости несколько размыто, но мы знаем, что переменные всегда аллоцируются в одной из таких областей. В V8 эта область называется Scope. Всего, на данный момент, их предложено 9

  • CLASS_SCOPE — область класса

  • EVAL_SCOPE — верхнеуровневая область для eval

  • FUNCTION_SCOPE — верхнеуровневая область фукнции

  • MODULE_SCOPE — область модуля

  • SCRIPT_SCOPE — верхнеуровневая область скрипта (

    Парсинг тэгов лежит за пределами V8 (этим занимается браузер до построения DOMTree), поэтому говорить о начале и конце области скрипта — не совсем правильно. Браузер передает движку тело скрипта в виде строки, которая и будет, в свою очередь, помещена в область SCRIPT_SCOPE.

    В примере выше переменная a будет задекларирована в Global Scope (по правилам вcплытия VAR), а b останется видна только в рамках этого скрипта.

    CATCH_SCOPE

    Специально для конструкции try ... catch был выделен отдельный тип Scope. Точнее, для блока catch(e) {}.

    try { stms } catch /* start position -> */(e)/* <- end position */ { stmts }

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

    try {
      var a = "a";
    } catch (e) {
      var b = "b";
    }
    %> v8-debug --print-scopes test.js
    Global scope:
    global { // (0x7f8207010830) (0, 1412)
      // will be compiled
      // NormalFunction
      // 1 stack slots
      // temporary vars:
      TEMPORARY .result;  // (0x7f8207011200) local[0]
      // local vars:
      VAR a;  // (0x7f8207010bc0)
      VAR b;  // (0x7f82070110b8)
      
      catch { // (0x7f8207010c58) (29, 51)
        // 3 heap slots
        // local vars:
        VAR e;  // (0x7f8207010ee8) context[2], never assigned
      }
    }

    В данном примере мы видим, что переменные a и b попали в Global Scope, в то время как в CATCH_SCOPE нет ничего, кроме e. Поскольку структуры try {} и catch {} являются ничем иным, как блоками, а значит, к ним применяется правило блочной видимости.

    BLOCK_SCOPE

    Именно с блочной областью часто путают другие типы Scope. Согласно спецификации, к блочной области, как я уже сказал, применяется правило видимости.

    /* start postion -> */{ stmts }/* <- end position */

    Область начинается открывающей фигурной скобкой и заканчивается зкарывающей.

    {
      var a = "a";
      let b = "b";
    }
    %> v8-debug --print-scopes test.js
    Global scope:
    global { // (0x7fb799835e30) (0, 1411)
      // will be compiled
      // NormalFunction
      // 3 stack slots
      // temporary vars:
      TEMPORARY .result;  // (0x7fb799836448) local[0]
      // local vars:
      VAR a;  // (0x7fb7998361c0)
      
      block { // (0x7fb799836020) (0, 50)
        // local vars:
        CONST c;  // (0x7fb799836340) local[2], never assigned, hole initialization elided
        LET b;  // (0x7fb799836280) local[1], never assigned, hole initialization elided
      }
    }

    В данном примере, переменная a всплыла в Global Scope, так как задекларирована с типом VAR, а переменные b и c остались внутри BLOCK_SCOPE.

    К блочным так же, относится и структура for (let x ...) stmt

    for /* start position -> */(let x ...) stmt/* <- end position */

    Началом такой области будет первая открывающая круглая скобка, концом — последний токен stmt

    Пример:

    for (let i = 0; i < 2; i++) {
      var a = "a";
      let b = "b";
    }
    %> v8-debug --print-scopes test.js
    Global scope:
    global { // (0x7fcdfd010430) (0, 1510)
      // will be compiled
      // NormalFunction
      // 3 stack slots
      // temporary vars:
      TEMPORARY .result;  // (0x7fcdfd010ef0) local[0]
      // local vars:
      VAR a;  // (0x7fcdfd010d00)
      
      block { // (0x7fcdfd010770) (4, 61)
        // local vars:
        LET i;  // (0x7fcdfd0108e8) local[1], hole initialization elided
        
        block { // (0x7fcdfd010b60) (28, 61)
          // local vars:
          LET b;  // (0x7fcdfd010dc0) local[2], never assigned, hole initialization elided
        }
      }
    }

    Здесь мы видим два BLOCK_SCOPE, первая область хранит переменную цикла i, а вложенная область обеспечивает блочную видимость тела цикла.

    И еще одна блочная структура switch (tag) { cases }

    switch (tag) /* start position -> */{ cases }/* <- end postion */

    Начало области — первая открывающая фигурная скобка, конец — последняя закрывающая фигурная скобка.

    Пример:

    var a = "";
    
    switch (a) {
      default:
        let b = "b";
        break;
    }
    %> v8-debug --print-scopes test.js
    Global scope:
    global { // (0x7fd4a1033230) (0, 1590)
      // will be compiled
      // NormalFunction
      // 3 stack slots
      // temporary vars:
      TEMPORARY .switch_tag;  // (0x7fd4a10337a8) local[0]
      TEMPORARY .result;  // (0x7fd4a10338e8) local[1]
      // local vars:
      VAR a;  // (0x7fd4a1033450)
      
      block { // (0x7fd4a1033538) (13, 66)
        // local vars:
        LET b;  // (0x7fd4a10336b0) local[2], never assigned, hole initialization elided
      }
    }

    Здесь переменная b находится внутри операторных скобок блока switch, поэтому она задекларирована внутри этой области.

    WITH_SCOPE

    На практике, структура with (obj) stmt встречается не часто, но я не могу о ней не сказать, так как для неё тоже выделен свой тип Scope.

    with (obj) stmt

    Началом области является первый токен stmt, концом — последний токен stmt.

    var obj = {
      prop1: "prop1"
    };
    
    with (obj)
      prop1 = "prop2";
      
    console.log(obj.prop1); // <- "prop2"
    %> v8-debug --print-scopes test.js
    Global scope:
    global { // (0x7fea4480ee30) (0, 1447)
      // will be compiled
      // NormalFunction
      // 1 stack slots
      // temporary vars:
      TEMPORARY .result;  // (0x7fea4480f650) local[0]
      // local vars:
      VAR obj;  // (0x7fea4480f050)
      // dynamic vars:
      DYNAMIC_GLOBAL console;  // (0x7fea4480f730) never assigned
      
      with { // (0x7fea4480f370) (46, 62)
        // 3 heap slots
        // dynamic vars:
        DYNAMIC prop1;  // (0x7fea4480f790) lookup
      }
    }

    Здесь мы видимо, что переменная prop1 (которая, на самом деле, является свойством объекта obj) задекларировалась в WITH_SCOPE как динамическая (динамическая, так как её объявление осуществлено без ключевого слова var,  let или const).

    SHADOW_REALM_SCOPE

    Область так называемого ShadowRealm. Фича была предложена в 2022 году и пока находится в статусе эксперементальной.

    Основная мотивация — иметь возможность создавать несколько, полностью независимых изолированных глобальных объектов. Другими словами, иметь возможность динамически создавать Realms (миры). Ранее такая возможность имелась только у «встраивателей» (embedders), например, у производителей браузеров, через API движка. Сейчас предлагается дать такую возможность и JS-разработчикам.

    // test.mjs
    import { myRealmFunction } from "./realm.mjs";
    
    var realm = new ShadowRealm();
    
    realm.importValue("realm.mjs", "myRealmFunction").then((myRealmFunction) => {});
    // realm.mjs
    export function myRealmFunction() {}

    Для активации фичи требуется флаг --harmony-shadow-realm

    %> v8-debug --print-scopes --harmony-shadow-realm test.mjs
    V8 is running with experimental features enabled. Stability and security will suffer.
    Global scope:
    module { // (0x7faddd810c20) (0, 1231)
      // strict mode scope
      // will be compiled
      // Module
      // 3 stack slots
      // 3 heap slots
      // temporary vars:
      TEMPORARY .generator_object;  // (0x7faddd810eb8) local[0], never assigned
      TEMPORARY .result;  // (0x7faddd811558) local[2]
      // local vars:
      CONST myRealmFunction;  // (0x7faddd810f60) module, never assigned
      VAR realm;  // (0x7faddd811090) local[1]
      
      arrow (myRealmFunction) { // (0x7faddd811218) (135, 158)
        // strict mode scope
        // ArrowFunction
        // local vars:
        VAR myRealmFunction;  // (0x7faddd8113f0) parameter[0], never assigned
      }
    }
    Inner function scope:
    function myRealmFunction () { // (0x7faddd811f38) (31, 36)
      // strict mode scope
      // NormalFunction
      // 2 heap slots
    }
    Global scope:
    module { // (0x7faddd811c20) (0, 37)
      // strict mode scope
      // will be compiled
      // Module
      // 2 stack slots
      // 3 heap slots
      // temporary vars:
      TEMPORARY .generator_object;  // (0x7faddd811eb8) local[0], never assigned
      TEMPORARY .result;  // (0x7faddd812210) local[1]
      // local vars:
      LET myRealmFunction;  // (0x7faddd8120f8) module
      
      function myRealmFunction () { // (0x7faddd811f38) (31, 36)
        // strict mode scope
        // lazily parsed
        // NormalFunction
        // 2 heap slots
      }
    }

    Scope для ShadowRealm пока выглядит, как обычный MODULE_SCOPE, что логично, так как фича работает только с модулями. А потому, говорить о том, как будет выглядеть область этой Realm-а в итоговом варианте — пока преждевременно.

    Allocate

    После декларирования переменных в Scope наступает стадия выделения памяти. Происходит это в тот момент, когда мы присваиваем переменной значение. Из спецификации мы знаем, что существуют два неких абстрактных хранилища значений. Stack и Heap (куча).

    Heap, фактически ассоциируется с конкретным контекстом исполнения. Сюда попадают:

    • переменные, к которым есть обращения из внутреннего Scope

    • есть возможность, что к переменной будет обращение из текущего или внутреннего Scope (через eval или runtime c поиском)

    К ним относятся:

    • переменные в CATCH_SCOPE

    • в областях SCRIPT_SCOPE и EVAL_SCOPE все переменные типов kLet и kConst

    • не аллоцированные переменные

    • переменные, требующие поиска (все динамические типы)

    • переменные внутри модуля

    В Stack попадают:

    • все переменные типа kTemporary (скрытые)

    • всё, что не попадает в Heap

    В статье мы рассмотрели принципиальную структуры данных в движке V8. Статья получилось объемной, но, надеюсь, полезной.

    Эту и другие мои статьи, так же, читайте в моем канале

    RU:  https://t.me/frontend_almanac_ru
    EN:  https://t.me/frontend_almanac

    © Habrahabr.ru