Como funciona Javascript – Parte 1

Como funciona Javascript

Javascript es un lenguaje de programación interpretado con un único hilo de ejecución y que se apoya en uno de los tantos motores (engines) que existen actualmente.

En esta entrada y las restantes siembre nos apoyaremos en el engine V8 ya que actualmente es el que mejores resultado tiene.

No obstante puedes encontrar en el siguiente enlace un amplio listado con múltiples motores.

Sabiendo esto, ahora veremos como es el proceso para convertir este código.

function f(param) {
  return param.name;
}

En este otro código.

;; full compiled call site
 ldr   r0, [fp, #+8]     ; load parameter "param" from stack
 ldr   r2, [pc, #+84]    ; load string "name" from constant pool
 ldr   ip, [pc, #+84]    ; load uninitialized stub from constant pool
 blx   ip                ; call the stub
 ...
 dd    0xabcdef01        ; address of stub loaded above
                         ; this gets replaced when the stub misses

Como funciona el V8

Lo primero que debemos saber es que el engine V8 esta escrito en C++, motivo por el cual lo hace sumamente rápido.

En el interior del engine V8 existen diferentes partes y cada una se encarga de una tarea en particular, todo esto para que desde un archivo de Javascript (texto plano), nuestro ordenador, movil, tablet, tv, etc… pueda ejecutarlo.

  1. El archivo JS es procesado usando un PARSER que realiza un análisis léxico, el cual hace pequeños trozos de código llamados tokens e intenta identificar el significado de cada token y que debe hacer ese código.
  2. Ahora con esos tokens generados se formara un arbol de sintaxis abstracta (AST – Abstract Sintax Tree), y que por cierto, si queréis ver como se forma ese árbol dentro del engine V8 podéis visitar la siguiente web.
  3. A continuación pasaríamos a la fase de interpretación (INTERPRETER), que es la encargada de pasar toda esa estructura a un código que comprenda cualquier máquina (bytecode). Esta última fase tiene algunos matices que veremos a continuación.

Diferentes caminos: intérprete y compilación

En este punto ya entendemos el motivo por el cual Javascript es un lenguaje interpretado.

Podemos observar que la última fase (casi la última) del engine V8, su finalidad es interpretar ese árbol y convertirlo a un código comprensible por la máquina.

Bien…, es necesario hacer un pequeño paréntesis para comprender las siguientes fases.

Debemos saber que no solo existen los lenguajes interpretados, también existen los lenguajes compilados como C, C++, C#, Go, Java (bytecode), Delphi, etc..

En estos y otros muchos lenguajes de programación compilados no se produce la «interpretación» al vuelo como sucede en Javascript, PHP o Python.

En el proceso de compilación se revisa el código he intenta comprender que hace ese código para compilarlo a un nuevo lenguaje que entienda la máquina (código máquina).

A continuación un pequeño ejemplo creado usando C.

Creamos nuestro código.

TruboC1

Compilamos nuestro código.

TurboC2

Finalmente construimos nuestro fichero ejecutable (.exe).

TurboC3

Ahora con el fichero .exe generado, vamos a obtener el código máquina usando la siguiente web.

asm1

Y finalmente obtenemos el código fuente que la maquina es capaz de entender (HELLO EXEes un fichero de texto plano).

Con este pequeño ejemplo, hemos aprendido la diferencia entre un lenguaje de programación interpretado como Javascript y otro compilado como C.

Ahora que ya sabemos lo que significa y que ventajas ofrece el proceso de compilación, podemos continuar.

Continuando con V8

Con lo aprendido hasta ahora mismo podríamos pensar que un lenguaje compilado es mejor que uno interpretado, pero vamos a ver que cada uno tiene puntos fuertes y débiles.

INTERPRETADO COMPILADO
Pros Contras Pros Contras
  • Se inicia muy rápido, ya que no es necesario compilar el código.
  • El engine recibe el fichero, lo interpreta y lo ejecuta.
  • Como es interpretado, según aumente la cantidad de código, este puede volverse muy lento es su ejecución.
  • En una compilación se ha optimizado para evitar esa comprobación constante.
  • El código generado esta muy optimizado, evitando la repetición de código con mismo resultados, ya que previamente lo analiza y optimiza.
  • Tiene que realizar un proceso previo, haciendo que su arranque e inicio sea mucho mas lento.

En este punto lo interesante sería tener lo mejor de ambos «mundos», pudiendo tener un arranque rápido con un código optimizado.

Bien, para ello la empresa Google en el año 2008 combino ambos mundos creando un compilador en tiempo de ejecución, JIT Compiler (Just In Time Compiler).

En la sección «como funciona V8»  en el paso 3 (INTERPRETER) dentro del engine V8 se esta generando nuestro bytecode, que todavía no es un código de bajo nivel como lo es el código máquina.

Aunque ese bytecode ya es comprensible y se puede ejecutar, no esta optimizado.

El siguiente paso es la revisión de el código generado (bytecode) en el paso 3 (INTERPRETER), por un nuevo elemento llamado PROFILER.

El PROFILER se encarga de revisar nuestro código mientras se ejecuta a través del INTERPRETER, y va tomando nota sobre las mejoras que se pueden realizar y como optimizar el código.

Si el PROFILER encuentra código que se puede mejorar entonces lo enviará al COMPILER (compilador).

A continuación un pequeño diagrama del proceso.

v8

Como esta compilación se produce en tiempo de ejecución, tomara ese código no optimizado y lo optimizara, para luego remplazar las partes que se pueden mejorar en el código final.

Debemos tener en cuenta que este proceso se esta realizando constantemente, de tal forma que siempre se debería tener la mejor versión del código máquina generado.

Un poco mas de V8

En este punto ya entendemos que es un lenguaje interpretado y compilado, y como Google con su engine V8 introdujo el JIT Compiler, mezclando ambos mundos para obtener la mejor versión del código.

Dentro del engine V8 nos encontramos otros elementos que son necesarios para que el engine funcione como debe, y los vamos a detallar a continuación:

  • Call stack
  • Callback queue
  • Memory Heap
  • Event loop
  • Web APIs

Call Stack y Memory Heap

Como habíamos explicado al principio, Javascript solo tienen un único hilo de ejecución y además tiene un contexto de ejecución global.

Esto significa que Javascript solo nos permite tener una pila de llamadas (call stack) y una pila de memoria (memory heap) donde almacenamos la información.

En la pila de memoria (memory heap) cada vez que definimos una variable esta se almacena, ya sea del tipo string, number, boolean, object, etc…

const text = 'Hello world';
const status = false;
const age = 100;
const data = {
   x: 100,
   y: 'test',
};

Todos los datos que se están almacenando en la memoria, cuando ya no son necesarios Javascript se encarga de eliminarlos por nosotros.

Aunque el proceso de eliminación es automático como hemos dicho antes, debemos tener en cuenta que Javascript bloquea las zonas de memoria que están en uso para evitar fugas de memoria (memory leaks).

Esta gestión automática que realiza el recolector de basura (garbage collector) por parte de Javascript es muy cómoda, pero puedes imaginar lo que implica esa gestión automática, y es que si no tenemos cuidado podemos aumentar la memoria en uso y que suceda un desbordamiento de la pila (stack overflow).

Si necesitáis información detallada sobre la gestión de memoria en Javascript, desde este enlace podéis acceder a toda la información sobre el flujo, técnica y algoritmos que usan para realizar esa gestión.

En la pila de llamadas (call stack) debemos saber que Javascript usa para la gestión de llamadas del call stack y su procesamiento, la técnica LIFO (Last Input – First Output).

A continuación un pequeño video para poner en practica la teoría.

Ahora que sabemos como funciona, debemos estar muy atentos con la pila de llamadas (call stack), ya que debemos controlar correctamente la ejecución de nuestro código para evitar un desbordamiento de la pila (stack overflow).

A continuación mediante recursividad vamos a reproducir el fallo, que aunque en este caso es muy evidente, nos sirve como ejemplo para cuando nos tengamos que enfrentar a ello.

 

Event Loop y Callback Queue

Otros dos elementos que nos encontramos dentro del engine V8, son el Event Loop y el Callback Queue.

El Callback Queue es una pila/cola donde se van añadiendo los procesos que requieren de un mayor tiempo de procesamiento o simplemente quedan a la espera de una respuesta, como puede ocurrir en la llamada a un servicio externo.

En este punto entra el Event Loop, que no es mas que un observador que controla todo lo que esta pendiente en el Callback Queue, y lo añade al Call Stack cuando este quede vacío.

A continuación un pequeño video explicando como funciona usando la siguiente herramienta.

Web APIs

Las Web APIs no son mas que interfaces y una serie de objetos que nos ofrece nuestro navegador para desarrollar aplicaciones.

Con ellas podemos acceder a decenas de interfaces que nos permiten desde el propio navegador por ejemplo, usar el audio de nuestro equipo, acceso a la cámara, nuestra posición, etc…

En el siguiente enlace podéis acceder a todas las APIs que existen actualmente, aunque algunas estén en fase experimental.

Optimizando nuestro Javascript

Ahora que conocemos las tripas del engine V8, lo interesante sería escribir código Javascript que sea mas óptimo y que nuestro engine pueda optimizar mucho mejor.

A continuación vamos a definir algunas pautas que parecen evidentes, pero que según crece el proyecto y pasa el tiempo suelen obviarse:

  • Tener el código actualizado es importante, ya que muchas veces se continua creando código que con el tiempo y diferentes mejoras queda desactualizado o en el peor de los casos cae en el olvido.
  • Las comparaciones se realizan de izquierda a derecha, esto nos permite hacer evaluaciones mucho mas óptimas ya que si no se cumpliera la primera condición el resto no se evaluaría.
    • const a = false;
      const b = true;
      const c = true;
      
      // Primera condición no se cumple 
      a && b && c && alert('To do');
      
  • Aprovechar la interface performance, para evaluar el rendimiento de nuestro código y saber que podemos mejorar.
    • (() => {
          // Rellenando un array con valores para comprobar el performance
          let arr = [];
          for (let cont = 0; cont < 1000000; cont++) {
              arr.push(cont);
          }
          const len = arr.length;
          
          const ta1 = performance.now();
          for (let cont = 0; cont < arr.length; cont++) {}
          const ta2 = performance.now();
      
          const tb1 = performance.now();
          for (let cont = 0; cont < len; cont++) {}    
          const tb2 = performance.now();
          
          function Resuts(t1, t2) {
              this.CALCULATED = t1;
              this.STORED = t2;
          }
          const result = new Resuts((ta2 - ta1), (tb2 - tb1));
          console.table(result);
      
      })();
  • Almacenar un valor que no va a cambiar, ya que evita la consulta y su correspondiente tiempo en obtener y calcular el valor. En el ejemplo anterior podemos ver un claro ejemplo de su uso en el ciclo for, calculando cada iteración frente a su asignación
  • El acceso a las propiedades de un objeto usando ‘.’ a usar ‘[]’, es un tema que me llamo la atención bastante, pero es cierto que los tiempos son menores cuando usamos ‘[]’ que cuando usamos ‘.’ para acceder a las propiedades.
    • (() => {
          const obj = {
              a: 100,
              b: true,
          };
          const ta1 = performance.now();
          for (let cont = 0; cont < 1000; cont++) {
              const a = obj.a;
              const b = obj.b;
          }
          const ta2 = performance.now();
      
          const tb1 = performance.now();
          for (let cont = 0; cont < 1000; cont++) {
              const a = obj['a'];
              const b = obj['b'];
          }    
          const tb2 = performance.now();
          
          function Resuts(t1, t2) {
              this.DOT = t1;
              this.SQUARE_BRACKET = t2;
          }
          const result = new Resuts((ta2 - ta1), (tb2 - tb1));
          console.table(result);
      })();
  • El proceso de iteración sobre algunos elementos, aunque se puede iterar de múltiples formas existen algunas que son mas óptimas que otras. También es cierto que en ocasiones, muchas de estas pruebas se hacen con grandes cantidades de información y quizás el código que ahorramos frente a el código mas óptimo no compense.
    • (() => {
          const newArray = (len = 100) => {
              let arr = [];
              for (let cont = 0; cont < len; cont++) {
                  arr.push(cont);
              }
              return arr;
          };
          const speedTest = cb => {
              const t1 = performance.now();
              cb();
              const t2 = performance.now();
              return t2 - t1;
          };
          
          const arr = newArray(10000);
      
          const t1 = speedTest(function() {
              const len = arr.length;
              for (let cont = 0; cont < len; cont++) {
                  arr[cont]
              }   
          });
          const t2 = speedTest(function() {
              arr.forEach(item => item);
          })
          const t3 = speedTest(function() {
              arr.map(item => item);
          })
          const t4 = speedTest(function() {
              let cont = 0;
              let len = arr.length;
              while (cont < len) {
                  arr[cont];
                  cont++;
              }
          });
          const t5 = speedTest(function() {
              for (let data of arr) {
                  data;
              }
          })
          console.table([['FOR',t1], ['FOREACH', t2], ['MAP', t3], ['WHILE', t4], ['FOR_OF', t5]]);
      })();

Aquí solo he puesto algunas pruebas de código para que entendáis que siempre se puede mejorar siguiendo buenas practicas, buscando información, investigando y practicando mucho.

Además existen muchas formas de optimizar nuestro código, ya no solamente a nivel de algoritmia y una buena codificación, también existen diferentes herramientas que nos permiten generar un código mas reducido y optimizado.

Bien con esto finalizamos la primera de una serie de entradas enfocadas exclusivamente a Javascript.