JavaScript é uma linguagem single-threaded e, ao mesmo tempo, também non-blocking, assíncrona e concorrente. Este artigo explica-lhe como isso acontece.
Tempo de execução
JavaScript é uma linguagem interpretada e não compilada. Isto significa que precisa de um intérprete que converta a linguagem JS código para um código de máquina. Existem vários tipos de interpretadores (conhecidos como motores). Os motores de browser mais populares são o V8 (Chrome), o Quantum (Firefox) e o WebKit (Safari). A propósito, o V8 também é utilizado num runtime popular que não é de um navegador, Node.js.
Cada motor contém uma pilha de memória, uma pilha de chamadas, um ciclo de eventos, uma fila de retorno de chamada e uma WebAPI com pedidos HTTP, temporizadores, eventos, etc., tudo implementado à sua maneira para uma interpretação mais rápida e segura do código JS.
Arquitetura básica de tempo de execução JS. Autor: Alex Zlatkov
Linha única
Uma linguagem single-thread é uma linguagem com uma única pilha de chamadas e uma única pilha de memória. Isto significa que só executa uma coisa de cada vez.
A pilha é uma região contínua de memória, que atribui um contexto local a cada função executada.
A pilha é uma região muito maior, que armazena tudo o que é atribuído dinamicamente.
A pilha de chamadas é uma estrutura de dados que basicamente regista o ponto em que nos encontramos no programa.
Pilha de chamadas
Vamos escrever um código simples e acompanhar o que está a acontecer na pilha de chamadas.
Como pode ver, as funções são adicionadas à pilha, executadas e posteriormente eliminadas. É a chamada forma LIFO - Last In, First Out (último a entrar, primeiro a sair). Cada entrada na pilha de chamadas é chamada de estrutura da pilha.
O conhecimento da pilha de chamadas é útil para ler os traços da pilha de erros. Geralmente, a razão exacta do erro está no topo da primeira linha, embora a ordem de execução do código seja de baixo para cima.
Por vezes, é possível lidar com um erro popular, notificado por Tamanho máximo da pilha de chamadas excedido. É fácil obter isto utilizando a recursão:
função foo() {
foo()
}
foo()
e o nosso browser ou terminal bloqueia. Cada navegador, mesmo nas suas diferentes versões, tem um limite de tamanho de pilha de chamadas diferente. Na grande maioria dos casos, são suficientes e o problema deve ser procurado noutro local.
Pilha de chamadas bloqueada
Eis um exemplo de bloqueio da thread JS. Vamos tentar ler um bobo e um ficheiro bar utilizando o NóFunção síncrona .js readFileSync.
Este é um GIF em loop. Como pode ver, o motor JS espera até à primeira chamada em readFileSync está concluído. Mas isso não acontecerá porque não há bobo por isso a segunda função nunca será chamada.
Comportamento assíncrono
No entanto, o JS também pode ser não-bloqueante e comportar-se como se fosse multithread. Isto significa que não espera pela resposta de uma chamada à API, eventos de E/S, etc., e pode continuar a execução do código. Isto é possível graças aos motores JS que utilizam (sob o capô) linguagens multithreading reais, como C++ (Chrome) ou Rust (Firefox). Estas linguagens fornecem nós com a Web API sob as capas dos browsers ou ex. API de E/S no Node.js.
No GIF acima, podemos ver que a primeira função é empurrada para a pilha de chamadas e Hi é imediatamente executado na consola.
De seguida, chamamos o setTimeout função fornecida pela WebAPI do navegador. Vai para a pilha de chamadas e a sua chamada de retorno assíncrona bobo vai para a fila de espera do WebApi, onde aguarda a chamada, definida para acontecer após 3 segundos.
Entretanto, o programa continua o código e vemos Olá. Não estou bloqueado na consola.
Depois de ser invocada, cada função na fila da WebAPI vai para o Fila de retorno de chamada. É onde as funções esperam até que a pilha de chamadas esteja vazia. Quando isso acontece, elas são movidas para lá uma a uma.
Assim, quando o nosso setTimeout o temporizador termina a contagem decrescente, o nosso bobo vai para a fila de callbacks, espera até que a pilha de chamadas fique disponível, vai para lá, é executada e vemos Olá de uma chamada de retorno assíncrona na consola.
Ciclo de eventos
A questão é: como é que o tempo de execução sabe que a pilha de chamadas está vazia e como é que o evento na fila de retorno de chamada é invocado? Conheça o loop de eventos. Ele faz parte do mecanismo JS. Este processo verifica constantemente se a pilha de chamadas está vazia e, se estiver, monitoriza se existe um evento na fila de resposta à chamada à espera de ser invocado.
É toda a magia dos bastidores!
Conclusão da teoria
Concorrência e paralelismo
Concorrência significa a execução de várias tarefas ao mesmo tempo, mas não em simultâneo. Por exemplo, duas tarefas funcionam em períodos de tempo sobrepostos.
Paralelismo significa executar duas ou mais tarefas em simultâneo, por exemplo, efetuar vários cálculos ao mesmo tempo.
Tópicos e processos
Fios são uma sequência de execução de código que pode ser executada independentemente uma da outra.
Processo é uma instância de um programa em execução. Um programa pode ter vários processos.
Síncrono e assíncrono
Em síncrono Na programação, as tarefas são executadas uma após a outra. Cada tarefa aguarda que a tarefa anterior esteja concluída e só depois é executada.
Em assíncrono programação, quando uma tarefa é executada, é possível mudar para uma tarefa diferente sem esperar que a anterior seja concluída.
Síncrono e assíncrono num ambiente único e multi-threaded
Síncrono com um único segmento: As tarefas são executadas uma após a outra. Cada tarefa espera que a tarefa anterior seja executada.
Síncrono com vários threads: As tarefas são executadas em diferentes threads mas esperam por outras tarefas em execução em qualquer outra thread.
Assíncrono com um único thread: As tarefas começam a ser executadas sem esperar que uma tarefa diferente termine. Num determinado momento, apenas uma única tarefa pode ser executada.
Assíncrono com vários threads: As tarefas são executadas em diferentes threads sem esperar que outras tarefas sejam concluídas e terminam as suas execuções de forma independente.
Classificação JavaScript
Se considerarmos o funcionamento dos motores JS, podemos classificar o JS como uma linguagem interpretada assíncrona e single-threaded. A palavra "interpretada" é muito importante porque significa que a linguagem será sempre dependente do tempo de execução e nunca será tão rápida como as linguagens compiladas com multi-threading incorporado.
Vale ressaltar que o Node.js pode realizar multi-threading real, desde que cada thread seja iniciada como um processo separado. Existem bibliotecas para isso, mas o Node.js tem um recurso embutido chamado Fios de trabalho.
Todos os GIFs de loop de evento vêm do Lupas criada por Philip Roberts, onde pode testar os seus cenários assíncronos.