Explorando los aspectos internos de Node.js

Publicado: 2022-03-10
Resumen rápido ↬ Node.js es una herramienta interesante para desarrolladores web. Con su alto nivel de concurrencia, se ha convertido en un candidato líder para las personas que eligen herramientas para usar en el desarrollo web. En este artículo, aprenderemos sobre lo que constituye Node.js, le daremos una definición significativa, comprenderemos cómo interactúan los componentes internos de Node.js entre sí y exploraremos el repositorio del proyecto para Node.js en GitHub.

Desde la introducción de Node.js por parte de Ryan Dahl en la European JSConf el 8 de noviembre de 2009, ha tenido un amplio uso en la industria tecnológica. Empresas como Netflix, Uber y LinkedIn dan credibilidad a la afirmación de que Node.js puede soportar una gran cantidad de tráfico y concurrencia.

Armados con conocimientos básicos, los desarrolladores principiantes e intermedios de Node.js luchan con muchas cosas: "¡Es solo un tiempo de ejecución!" “¡Tiene bucles de eventos!” "¡Node.js es de un solo subproceso como JavaScript!"

Si bien algunas de estas afirmaciones son ciertas, profundizaremos en el tiempo de ejecución de Node.js, comprenderemos cómo ejecuta JavaScript, veremos si realmente es de un solo subproceso y, finalmente, comprenderemos mejor la interconexión entre sus dependencias principales, V8 y libuv. .

requisitos previos

  • Conocimientos básicos de JavaScript
  • Familiaridad con la semántica de Node.js ( require , fs )

¿Qué es Node.js?

Puede ser tentador suponer lo que muchas personas han creído acerca de Node.js, la definición más común es que es un tiempo de ejecución para el lenguaje JavaScript . Para considerar esto, debemos entender lo que llevó a esta conclusión.

Node.js a menudo se describe como una combinación de C++ y JavaScript. La parte de C++ consiste en enlaces que ejecutan código de bajo nivel que hacen posible acceder al hardware conectado a la computadora. La parte de JavaScript toma JavaScript como su código fuente y lo ejecuta en un intérprete popular del lenguaje, llamado motor V8.

Con este entendimiento, podríamos describir Node.js como una herramienta única que combina JavaScript y C++ para ejecutar programas fuera del entorno del navegador.

Pero, ¿podríamos realmente llamarlo un tiempo de ejecución? Para determinar eso, definamos qué es un tiempo de ejecución.

En una de sus respuestas sobre StackOverflow, DJNA define un entorno de tiempo de ejecución como “todo lo que necesitas para ejecutar un programa, pero no herramientas para cambiarlo”. De acuerdo con esta definición, podemos decir con confianza que todo lo que sucede mientras ejecutamos nuestro código (en cualquier idioma que sea) se ejecuta en un entorno de tiempo de ejecución.

Otros lenguajes tienen su propio entorno de tiempo de ejecución. Para Java, es Java Runtime Environment (JRE). Para .NET, es Common Language Runtime (CLR). Para Erlang, es BEAM.

Sin embargo, algunos de estos tiempos de ejecución tienen otros lenguajes que dependen de ellos. Por ejemplo, Java tiene Kotlin, un lenguaje de programación que compila un código que un JRE puede entender. Erlang tiene Elixir. Y sabemos que hay muchas variantes para el desarrollo de .NET, que se ejecutan en CLR, conocido como .NET Framework.

Ahora entendemos que un tiempo de ejecución es un entorno proporcionado para que un programa pueda ejecutarse con éxito, y sabemos que V8 y una gran cantidad de bibliotecas de C++ hacen posible que se ejecute una aplicación Node.js. Node.js en sí mismo es el tiempo de ejecución real que une todo para hacer de esas bibliotecas una entidad, y solo comprende un idioma, JavaScript, independientemente de con qué se haya creado Node.js.

¡Más después del salto! Continúe leyendo a continuación ↓

Estructura interna de Node.js

Cuando intentamos ejecutar un programa Node.js (como index.js ) desde nuestra línea de comando usando el comando node index.js , estamos llamando al tiempo de ejecución de Node.js. Este tiempo de ejecución, como se mencionó, consta de dos dependencias independientes, V8 y libuv.

Dependencias básicas de Node.js
Dependencias principales de Node.js (vista previa grande)

V8 es un proyecto creado y mantenido por Google. Toma el código fuente de JavaScript y lo ejecuta fuera del entorno del navegador. Cuando ejecutamos un programa a través de un comando de node , el tiempo de ejecución de Node.js pasa el código fuente a V8 para su ejecución.

La biblioteca libuv contiene código C++ que permite el acceso de bajo nivel al sistema operativo. Las funcionalidades como la creación de redes, la escritura en el sistema de archivos y la simultaneidad no se envían de forma predeterminada en V8, que es la parte de Node.js que ejecuta nuestro código JavaScript. Con su conjunto de bibliotecas, libuv proporciona estas utilidades y más en un entorno Node.js.

Node.js es el pegamento que mantiene unidas las dos bibliotecas, convirtiéndose así en una solución única. A lo largo de la ejecución de un script, Node.js entiende a qué proyecto pasar el control y cuándo.

API interesantes para programas del lado del servidor

Si estudiamos un poco la historia de JavaScript, sabríamos que está destinado a agregar alguna funcionalidad e interacción a una página en el navegador. Y en el navegador interactuaríamos con los elementos del modelo de objeto de documento (DOM) que componen la página. Para esto, existe un conjunto de API, denominadas colectivamente API DOM.

El DOM existe solo en el navegador; es lo que se analiza para representar una página, y básicamente está escrito en el lenguaje de marcado conocido como HTML. Además, el navegador existe en una ventana, por lo tanto, el objeto de la window , que actúa como raíz para todos los objetos de la página en un contexto de JavaScript. Este entorno se denomina entorno de navegador y es un entorno de tiempo de ejecución para JavaScript.

Las API de Node.js llaman a libuv para algunas funciones
Las API de Node.js interactúan con libuv (vista previa grande)

En un entorno Node.js, no tenemos nada como una página ni un navegador; esto anula nuestro conocimiento del objeto de ventana global. Lo que sí tenemos es un conjunto de API que interactúan con el sistema operativo para brindar funcionalidad adicional a un programa de JavaScript. Estas API para Node.js ( fs , path , buffer , events , HTTP , etc.), tal como las tenemos, existen solo para Node.js y las proporciona Node.js (en sí mismo un tiempo de ejecución) para que podamos puede ejecutar programas escritos para Node.js.

Experimento: cómo fs.writeFile crea un nuevo archivo

Si V8 se creó para ejecutar JavaScript fuera del navegador, y si un entorno Node.js no tiene el mismo contexto o entorno que un navegador, ¿cómo haríamos algo como acceder al sistema de archivos o crear un servidor HTTP?

Como ejemplo, tomemos una aplicación Node.js simple que escribe un archivo en el sistema de archivos en el directorio actual:

 const fs = require("fs") fs.writeFile("./test.txt", "text");

Como se muestra, estamos tratando de escribir un nuevo archivo en el sistema de archivos. Esta función no está disponible en el lenguaje JavaScript; está disponible solo en un entorno Node.js. ¿Cómo se ejecuta esto?

Para entender esto, hagamos un recorrido por la base de código de Node.js.

Dirigiéndose al repositorio de GitHub para Node.js, vemos dos carpetas principales, src y lib . La carpeta lib tiene el código JavaScript que proporciona el buen conjunto de módulos que se incluyen de forma predeterminada con cada instalación de Node.js. La carpeta src contiene las bibliotecas de C++ para libuv.

Si buscamos en la carpeta lib y revisamos el archivo fs.js , veremos que está lleno de código JavaScript impresionante. En la línea 1880, notaremos una declaración de exports . Esta declaración exporta todo lo que podemos acceder importando el módulo fs , y podemos ver que exporta una función llamada writeFile .

La búsqueda de la function writeFile( (donde se define la función) nos lleva a la línea 1303, donde vemos que la función se define con cuatro parámetros:

 function writeFile(path, data, options, callback) { callback = maybeCallback(callback || options); options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); const flag = options.flag || 'w'; if (!isArrayBufferView(data)) { validateStringAfterArrayBufferView(data, 'data'); data = Buffer.from(data, options.encoding || 'utf8'); } if (isFd(path)) { const isUserFd = true; writeAll(path, isUserFd, data, 0, data.byteLength, callback); return; } fs.open(path, flag, options.mode, (openErr, fd) => { if (openErr) { callback(openErr); } else { const isUserFd = false; writeAll(fd, isUserFd, data, 0, data.byteLength, callback); } }); }

En las líneas 1315 y 1324, vemos que se llama a una sola función, writeAll , después de algunas comprobaciones de validación. Encontramos esta función en la línea 1278 en el mismo archivo fs.js

 function writeAll(fd, isUserFd, buffer, offset, length, callback) { // write(fd, buffer, offset, length, position, callback) fs.write(fd, buffer, offset, length, null, (writeErr, written) => { if (writeErr) { if (isUserFd) { callback(writeErr); } else { fs.close(fd, function close() { callback(writeErr); }); } } else if (written === length) { if (isUserFd) { callback(null); } else { fs.close(fd, callback); } } else { offset += written; length -= written; writeAll(fd, isUserFd, buffer, offset, length, callback); } }); }

También es interesante notar que este módulo intenta llamarse a sí mismo. Vemos esto en la línea 1280, donde llama a fs.write . Buscando la función de write , descubriremos un poco de información.

La función de write comienza en la línea 571 y se ejecuta alrededor de 42 líneas. Vemos un patrón recurrente en esta función: la forma en que llama a una función en el módulo de binding , como se ve en las líneas 594 y 612. Una función en el módulo de binding se llama no solo en esta función, sino en prácticamente cualquier función que se exporte. en el archivo fs.js Algo debe ser muy especial al respecto.

La variable binding se declara en la línea 58, en la parte superior del archivo, y un clic en esa llamada de función revela cierta información, con la ayuda de GitHub.

Declaración de la variable vinculante
Declaración de la variable vinculante (vista previa grande)

Esta función internalBinding se encuentra en el módulo denominado cargadores. La función principal del módulo de cargadores es cargar todas las bibliotecas libuv y conectarlas a través del proyecto V8 con Node.js. La forma en que lo hace es bastante mágica, pero para obtener más información, podemos observar detenidamente la función writeBuffer que llama el módulo fs .

Deberíamos ver dónde se conecta esto con libuv y dónde entra V8. En la parte superior del módulo de cargadores, hay una buena documentación que dice esto:

 // This file is compiled and run by node.cc before bootstrap/node.js // was called, therefore the loaders are bootstraped before we start to // actually bootstrap Node.js. It creates the following objects: // // C++ binding loaders: // - process.binding(): the legacy C++ binding loader, accessible from user land // because it is an object attached to the global process object. // These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE() // and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees // about the stability of these bindings, but still have to take care of // compatibility issues caused by them from time to time. // - process._linkedBinding(): intended to be used by embedders to add // additional C++ bindings in their applications. These C++ bindings // can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag // NM_F_LINKED. // - internalBinding(): the private internal C++ binding loader, inaccessible // from user land unless through `require('internal/test/binding')`. // These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL() // and have their nm_flags set to NM_F_INTERNAL. // // Internal JavaScript module loader: // - NativeModule: a minimal module system used to load the JavaScript core // modules found in lib/**/*.js and deps/**/*.js. All core modules are // compiled into the node binary via node_javascript.cc generated by js2c.py, // so they can be loaded faster without the cost of I/O. This class makes the // lib/internal/*, deps/internal/* modules and internalBinding() available by // default to core modules, and lets the core modules require itself via // require('internal/bootstrap/loaders') even when this file is not written in // CommonJS style.

Lo que aprendemos aquí es que para cada módulo llamado desde el objeto binding en la sección JavaScript del proyecto Node.js, hay un equivalente en la sección C++, en la carpeta src .

En nuestro recorrido por fs , vemos que el módulo que hace esto se encuentra en node_file.cc . Cada función a la que se puede acceder a través del módulo se define en el archivo; por ejemplo, tenemos writeBuffer en la línea 2258. La definición real de ese método en el archivo C++ está en la línea 1785. Además, la llamada a la parte de libuv que realmente escribe en el archivo se puede encontrar en las líneas 1809 y 1815, donde la función uv_fs_write se llama de forma asíncrona.

¿Qué ganamos con este entendimiento?

Al igual que muchos otros tiempos de ejecución de lenguajes interpretados, el tiempo de ejecución de Node.js se puede piratear. Con una mayor comprensión, podríamos hacer cosas que son imposibles con la distribución estándar simplemente mirando a través de la fuente. Podríamos agregar bibliotecas para realizar cambios en la forma en que se llaman algunas funciones. Pero, sobre todo, esta comprensión es una base para una mayor exploración.

¿Es Node.js de subproceso único?

Sentado en libuv y V8, Node.js tiene acceso a algunas funcionalidades adicionales que un motor de JavaScript típico que se ejecuta en el navegador no tiene.

Cualquier JavaScript que se ejecute en un navegador se ejecutará en un solo hilo. Un subproceso en la ejecución de un programa es como una caja negra colocada encima de la CPU en la que se ejecuta el programa. En un contexto de Node.js, algún código podría ejecutarse en tantos subprocesos como nuestras máquinas puedan transportar.

Para verificar esta afirmación en particular, exploremos un fragmento de código simple.

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test.txt", "test", (err) => { If (error) { console.log(err) } console.log("1 Done: ", Date.now() — startTime) });

En el fragmento anterior, estamos tratando de crear un nuevo archivo en el disco en el directorio actual. Para ver cuánto tiempo podría tomar esto, agregamos un pequeño punto de referencia para monitorear la hora de inicio del script, que nos da la duración en milisegundos del script que está creando el archivo.

Si ejecutamos el código anterior, obtendremos un resultado como este:

Resultado del tiempo que se tarda en crear un solo archivo en Node.js
Tiempo necesario para crear un solo archivo en Node.js (vista previa grande)
 $ node ./test.js -> 1 Done: 0.003s

Esto es muy impresionante: sólo 0,003 segundos.

Pero hagamos algo realmente interesante. Primero, dupliquemos el código que genera el nuevo archivo y actualicemos el número en la instrucción de registro para reflejar sus posiciones:

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test1.txt", "test", function (err) { if (err) { console.log(err) } console.log("1 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test2.txt", "test", function (err) { if (err) { console.log(err) } console.log("2 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test3.txt", "test", function (err) { if (err) { console.log(err) } console.log("3 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test4.txt", "test", function (err) { if (err) { console.log(err) } console.log("4 Done: %ss", (Date.now() — startTime) / 1000) });

Si intentamos ejecutar este código, obtendremos algo que nos dejará boquiabiertos. Aquí está mi resultado:

Resultado del tiempo que se tarda en crear varios archivos
Creación de muchos archivos a la vez (Vista previa grande)

Primero, notaremos que los resultados no son consistentes. En segundo lugar, vemos que el tiempo ha aumentado. ¿Lo que está sucediendo?

Las tareas de bajo nivel se delegan

Node.js es de subproceso único, como sabemos ahora. Partes de Node.js están escritas en JavaScript y otras en C++. Node.js utiliza los mismos conceptos del bucle de eventos y la pila de llamadas con los que estamos familiarizados en el entorno del navegador, lo que significa que las partes de JavaScript de Node.js son de un solo subproceso. Pero la tarea de bajo nivel que requiere hablar con un sistema operativo no es de un solo subproceso.

Las tareas de bajo nivel se delegan al sistema operativo a través de libuv
Delegación de tareas de bajo nivel de Node.js (vista previa grande)

Cuando Node.js reconoce una llamada como destinada a libuv, delega esta tarea a libuv. En su funcionamiento, libuv requiere subprocesos para algunas de sus bibliotecas, de ahí el uso del grupo de subprocesos para ejecutar programas de Node.js cuando se necesitan.

De forma predeterminada, el grupo de subprocesos de Node.js proporcionado por libuv tiene cuatro subprocesos. Podríamos aumentar o reducir este grupo de subprocesos llamando a process.env.UV_THREADPOOL_SIZE en la parte superior de nuestro script.

 // script.js process.env.UV_THREADPOOL_SIZE = 6; // … // …

Qué sucede con nuestro programa de creación de archivos

Parece que una vez que invocamos el código para crear nuestro archivo, Node.js toca la parte libuv de su código, que dedica un hilo para esta tarea. Esta sección en libuv obtiene información estadística sobre el disco antes de trabajar en el archivo.

Esta comprobación estadística puede tardar un tiempo en completarse; por lo tanto, el subproceso se libera para algunas otras tareas hasta que se complete la verificación estadística. Cuando se completa la verificación, la sección libuv ocupa cualquier subproceso disponible o espera hasta que un subproceso esté disponible para ella.

Solo tenemos cuatro llamadas y cuatro subprocesos, por lo que hay suficientes subprocesos para todos. La única pregunta es qué tan rápido cada subproceso procesará su tarea. Notaremos que el primer código que ingresa al grupo de subprocesos devolverá su resultado primero y bloqueará todos los demás subprocesos mientras ejecuta su código.

Conclusión

Ahora entendemos qué es Node.js. Sabemos que es un tiempo de ejecución. Hemos definido qué es un tiempo de ejecución. Y hemos profundizado en lo que constituye el tiempo de ejecución proporcionado por Node.js.

Hemos recorrido un largo camino. Y desde nuestro pequeño recorrido por el repositorio de Node.js en GitHub, podemos explorar cualquier API que nos interese, siguiendo el mismo proceso que hicimos aquí. Node.js es de código abierto, por lo que seguramente podemos sumergirnos en el código fuente, ¿no?

Aunque hemos tocado varios de los niveles bajos de lo que sucede en el tiempo de ejecución de Node.js, no debemos asumir que lo sabemos todo. Los recursos a continuación apuntan a alguna información sobre la cual podemos construir nuestro conocimiento:

  • Introducción a Node.js
    Al ser un sitio web oficial, Node.dev explica qué es Node.js, así como sus administradores de paquetes, y enumera los marcos web construidos sobre él.
  • "JavaScript y Node.js", el libro para principiantes de nodos
    Este libro de Manuel Kiessling hace un trabajo fantástico al explicar Node.js, después de advertir que el JavaScript del navegador no es el mismo que el de Node.js, aunque ambos están escritos en el mismo lenguaje.
  • Principios de Node.js
    Este libro para principiantes va más allá de una explicación del tiempo de ejecución. Enseña sobre paquetes y flujos y la creación de un servidor web con el marco Express.
  • librería
    Esta es la documentación oficial del código C++ de soporte del tiempo de ejecución de Node.js.
  • V8
    Esta es la documentación oficial del motor de JavaScript que hace posible escribir Node.js con JavaScript.