No início de abril, o artigo "JavaScript: Call stack e a magia de seu tamanho" foi publicado no Habré - seu autor chegou à conclusão de que cada stack frame ocupa (72 + 8 * número de_variáveis_locais) bytes: "Acontece que nós contamos tudo corretamente e podemos afirmar que o tamanho de um ExecutionStack vazio no Chrome é de 72 bytes, e o tamanho da pilha de chamadas é pouco menos de um megabyte. Bom trabalho! "
Para propagação - vamos mudar um pouco o código usado AxemaFr para experimentos:
{let i = 0;
const func = () => {
i += 1.00000000000001;
func();
};
try {
func();
} catch (e) {
console.log(i);
}}
Em vez de 1, agora a cada etapa adicionamos um pouco mais e, como resultado, em vez de 13951, obtemos 12556,000000000002 - como se uma variável local fosse adicionada à função!
Vamos repetir as perguntas feitas pelo Desenvolvedor Frontend Sênior AxemaFr: “Por que é assim? O que mudou? Como entender, olhando para a função, quantas vezes ela pode ser executada recursivamente?! "
Utensílios de cozinha
Na linha de comando do Chrome, você pode passar argumentos para o mecanismo JS; em particular, você pode alterar o tamanho da pilha de 984 KB para qualquer outra chave
--js-flags=--stack-size=
.
Para descobrir quanta pilha cada função requer, a chave
--print-bytecode
já mencionada nos ajudará . Não foi mencionado que a saída de depuração é enviada para stdout, que o Chrome no Windows estupidamente não tem, porque é compilado como um aplicativo GUI. É fácil de corrigir: faça uma cópia de chrome.exe e em seu editor hexadecimal corrija o byte
0xD4
do valor
0x02
para
0x03
(para aqueles que não são amigos do editor hexadecimal, este byte ajudará a corrigir o script Python) Mas se você estiver lendo este artigo agora no Chrome e apenas executar o arquivo corrigido - digamos que você o nomeou cui_chrome.exe - uma nova janela será aberta em uma instância existente do navegador e o argumento
--js-flags
será ignorado. Para iniciar uma nova instância do Chrome, você precisa passar uma nova
--user-data-dir
:
cui_chrome.exe --no-sandbox --js-flags="--print-bytecode --print-bytecode-filter=func" --user-data-dir=\Windows\Temp
Sem ele,
--print-bytecode-filter
você se afogará em despejos de bytecode de quilômetros de funções integradas ao Chrome.
Depois de iniciar o navegador, abra o console do desenvolvedor e digite o código usado AxemaFr:
{let i = 0;
const func = () => {
i++;
func();
};
func()}
Antes de pressionar Enter, um dump aparecerá na janela do console atrás do Chrome:
[bytecode gerado para a função: func (0x44db08635355 <função SharedFunctionInfo>)]
Contagem de parâmetro 1
Register count 1
Frame 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 Retorno
Pool constante (tamanho = 2)
Tabela Handler (tamanho = 0)
Tabela de posição da fonte (tamanho = 12)
Como o dump mudará se a linha for
i++;
substituída por
i += 1.00000000000001;
?
[bytecode gerado para a função: func (0x44db0892d495 <função SharedFunctionInfo>)]
Contagem de parâmetro 1
Registrar contagem 2
Tamanho da moldura 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 Retorno
Pool constante (tamanho = 3)
Tabela Handler (tamanho = 0)
Tabela de posição da fonte (tamanho = 12)
Agora vamos descobrir o que mudou e por quê.
Explorando exemplos
Todos os opcodes V8 são descritos em github.com/v8/v8/blob/master/src/interpreter/interpreter-generator.cc
O primeiro dump é decodificado assim:
LdaCurrentContextSlot [2]; a: = contexto [2]
ThrowReferenceErrorIfHole [0]; if (a === indefinido)
; throw ("ReferenceError:% s não está definido", const [0])
Inc [0]; a ++
Star r0; r0: = a
LdaCurrentContextSlot [2]; a: = contexto [2]
ThrowReferenceErrorIfHole [0]; if (a === indefinido)
; throw ("ReferenceError:% s não está definido", const [0])
Ldar r0; a: = r0
StaCurrentContextSlot [2] ; context[2] := a
LdaImmutableCurrentContextSlot [3] ; a := context[3]
ThrowReferenceErrorIfHole [1] ; if (a === undefined)
; throw("ReferenceError: %s is not defined", const[1])
Star r0 ; r0 := a
CallUndefinedReceiver0 r0, [1] ; r0()
LdaUndefined ; a := undefined
Return
O último argumento opta
Inc
e
CallUndefinedReceiver0
define o slot de feedback, no qual o otimizador coleta estatísticas sobre os tipos usados. Isso não afeta a semântica do bytecode, então hoje não estamos interessados.
Sob o dump, há um postscript: "Constant pool (size = 2)" - e de fato vemos que o bytecode usa duas linhas -
"i"
e
"func"
- para substituição na mensagem de exceção quando os símbolos com tais nomes são indefinidos. Há um postscript acima do dump: "Frame size 8" - de acordo com o fato de que a função usa um registrador de intérprete (
r0
).
O quadro de pilha da nossa função consiste em:
- único argumento
this
; - endereços de retorno;
- o número de argumentos passados (
arguments.length
); - referências a pool constante com strings usadas;
- links para contexto com variáveis locais;
- mais três ponteiros necessários para o motor; e finalmente
- espaço para um registro.
Total 9 * 8 = 72 bytes, como signor AxemaFre descobri.
Dos sete termos listados, teoricamente, três podem mudar - o número de argumentos, a presença de um pool constante e o número de registros. O que obtivemos na variante com 1.00000000000001?
LdaCurrentContextSlot [2]; a: = contexto [2]
ThrowReferenceErrorIfHole [0]; if (a === indefinido)
; throw ("ReferenceError:% s não está definido", const [0])
Star r0; r0: = a
LdaConstant [1]; a: = const [1]
Adicione r0, [0]; a + = r0
Star r1; r1: = a
; ... mais como antes
Primeiro, a constante adicionada assumiu o terceiro lugar no pool de constantes; em segundo lugar, mais um registro foi necessário para carregá-lo, de modo que o frame da pilha da função cresceu 8 bytes.
Se você não usar símbolos nomeados na função, poderá fazer sem o pool constante. Em github.com/v8/v8/blob/master/src/execution/frame-constants.h#L289 descreveu o formato de quadro de pilha V8 e afirmou que quando o pool constante não está em uso, o tamanho do quadro de pilha é reduzido em um ponteiro . Como você pode ter certeza disso? À primeira vista, parece que uma função que não usa símbolos nomeados não pode ser recursiva; mas dê uma olhada:
{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 1
Register count 1
Frame 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 Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 8)
O objetivo - "Pool constante (tamanho = 0)" - foi alcançado; mas o estouro da pilha, como antes, ocorre por meio de 13951 chamadas. Isso significa que, mesmo quando o pool constante não é usado, o quadro de pilha da função ainda contém um ponteiro para ele.
É possível obter um tamanho de frame de pilha menor do que o calculado AxemaFrvalor mínimo? - sim, se nenhum registro for usado dentro da função:
{function func() {
this();
};
let chain = ()=>null;
for(let i=0; i<15050; i++)
chain = func.bind(chain);
chain()}
[bytecode gerado para a função: func (0x44db08c34059 <função SharedFunctionInfo>)]
Contagem de parâmetro 1
Registrar contagem 0
Tamanho da moldura 0
25 S> 000044DB08C34322 @ 0: 5d 02 00 CallUndefinedReceiver0 <este>, [0]
000044DB08C34325 @ 3: 0d LdaUndefined
29 S> 000044DB08C34326 @ 4: ab Retorno
Pool constante (tamanho = 0)
Tabela Handler (tamanho = 0)
Tabela de posição da fonte (tamanho = 6)
(Neste caso, uma cadeia de chamadas de 15051 já leva a "RangeError: Tamanho máximo da pilha de chamadas excedido".)
Assim, a conclusão do signor AxemaFrque "o tamanho de um ExecutionStack vazio no Chrome é de 72 bytes" foi refutado com sucesso.
Refinando previsões
Podemos argumentar que o tamanho mínimo do frame da pilha para uma função JS no Chrome é de 64 bytes. Para isso, você precisa adicionar 8 bytes para cada parâmetro formal declarado, outros 8 bytes para cada parâmetro real em excesso do número de declarados e outros 8 bytes para cada registro usado. Um registro é alocado para cada variável local, para carregar constantes, para acessar variáveis de um contexto externo, para passar parâmetros reais durante chamadas, etc. Dificilmente é possível determinar o número exato de registros usados a partir do código-fonte em JS. É importante notar que o interpretador JS suporta um número ilimitado de registros - eles não estão relacionados aos registros do processador no qual o interpretador é executado.
Agora está claro o porquê:
- (
func = (x) => { i++; func(); };
) , ; - (
func = () => { i++; func(1); };
) , — :[generated bytecode for function: func (0x44db08e12da1 <SharedFunctionInfo func>)] Parameter count 1 Register count 2 Frame 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 Return Constant pool (size = 2) Handler Table (size = 0) Source Position Table (size = 12) - 1.00000000000001 —
r1
, .