Untergeordnete Node.js-Prozesse: Alles, was Sie wissen müssen

Verwendung von spawn (), exec (), execFile () und fork ()

Update: Dieser Artikel ist jetzt Teil meines Buches "Node.js Beyond The Basics".

Lesen Sie die aktualisierte Version dieses Inhalts und mehr über Node unter jscomplete.com/node-beyond-basics .

Die nicht blockierende Leistung mit einem Thread in Node.js eignet sich hervorragend für einen einzelnen Prozess. Aber irgendwann wird ein Prozess in einer CPU nicht ausreichen, um die zunehmende Arbeitslast Ihrer Anwendung zu bewältigen.

Unabhängig davon, wie leistungsfähig Ihr Server sein mag, kann ein einzelner Thread nur eine begrenzte Last unterstützen.

Die Tatsache, dass Node.js in einem einzigen Thread ausgeführt wird, bedeutet nicht, dass wir nicht mehrere Prozesse und natürlich auch mehrere Computer nutzen können.

Die Verwendung mehrerer Prozesse ist der beste Weg, um eine Knotenanwendung zu skalieren. Node.js wurde zum Erstellen verteilter Anwendungen mit vielen Knoten entwickelt. Aus diesem Grund heißt es Node . Die Skalierbarkeit ist in die Plattform integriert und Sie werden später in der Lebensdauer einer Anwendung nicht mehr darüber nachdenken.

Dieser Artikel ist eine Zusammenfassung eines Teils meines Pluralsight-Kurses über Node.js. Ich beschreibe dort ähnliche Inhalte im Videoformat.

Bitte beachten Sie, dass Sie ein gutes Verständnis der Ereignisse und Streams von Node.j benötigen, bevor Sie diesen Artikel lesen. Wenn Sie es noch nicht getan haben, empfehle ich Ihnen, diese beiden anderen Artikel zu lesen, bevor Sie diesen lesen:

Grundlegendes zur ereignisgesteuerten Architektur von Node.j.

Die meisten Objekte von Node - wie HTTP-Anforderungen, -Antworten und -Streams - implementieren das EventEmitter-Modul, damit sie…

Streams: Alles was Sie wissen müssen

Node.js Streams haben den Ruf, schwer zu bearbeiten und noch schwerer zu verstehen zu sein. Nun, ich habe gute Nachrichten ...

Das Modul für untergeordnete Prozesse

Mit dem Node- child_processModul können wir einen untergeordneten Prozess problemlos drehen, und diese untergeordneten Prozesse können problemlos mit einem Messagingsystem miteinander kommunizieren.

Das child_processModul ermöglicht uns den Zugriff auf Betriebssystemfunktionen, indem wir einen beliebigen Systembefehl in einem untergeordneten Prozess ausführen.

Wir können diesen untergeordneten Prozess-Eingabestream steuern und seinen Ausgabestream abhören. Wir können auch die Argumente steuern, die an den zugrunde liegenden Betriebssystembefehl übergeben werden sollen, und mit der Ausgabe dieses Befehls können wir tun, was wir wollen. Wir können zum Beispiel die Ausgabe eines Befehls als Eingabe an einen anderen weiterleiten (genau wie unter Linux), da uns alle Ein- und Ausgaben dieser Befehle mithilfe von Node.js-Streams präsentiert werden können.

Beachten Sie, dass die Beispiele, die ich in diesem Artikel verwenden werde, alle auf Linux basieren. Unter Windows müssen Sie die Befehle, die ich verwende, mit ihren Windows-Alternativen umschalten.

Es gibt vier verschiedene Möglichkeiten , einen Kind - Prozess in Knoten zu erstellen: spawn(), fork(), exec(), und execFile().

Wir werden die Unterschiede zwischen diesen vier Funktionen und den jeweiligen Verwendungszwecken erkennen.

Spawned Child-Prozesse

Die spawnFunktion startet einen Befehl in einem neuen Prozess und wir können ihn verwenden, um diesem Befehl beliebige Argumente zu übergeben. Hier ist zum Beispiel Code, um einen neuen Prozess zu erzeugen, der den pwdBefehl ausführt .

const { spawn } = require('child_process'); const child = spawn('pwd');

Wir zerstören einfach die spawnFunktion aus dem child_processModul heraus und führen sie mit dem OS-Befehl als erstem Argument aus.

Das Ergebnis der Ausführung der spawnFunktion (das childobige Objekt) ist eine ChildProcessInstanz, die die EventEmitter-API implementiert. Dies bedeutet, dass wir Handler für Ereignisse in diesem untergeordneten Objekt direkt registrieren können. Zum Beispiel können wir etwas tun, wenn der untergeordnete Prozess beendet wird, indem wir einen Handler für das exitEreignis registrieren :

child.on('exit', function (code, signal) { console.log('child process exited with ' + `code ${code} and signal ${signal}`); });

Der obige Handler gibt uns den Exit codefür den untergeordneten Prozess und signal, falls vorhanden, den, der zum Beenden des untergeordneten Prozesses verwendet wurde. Diese signalVariable ist null, wenn der untergeordnete Prozess normal beendet wird.

Die anderen Veranstaltungen , die wir Handler für mit den registrieren können ChildProcessInstanzen sind disconnect, error, close, und message.

  • Das disconnectEreignis wird ausgegeben, wenn der übergeordnete Prozess die child.disconnectFunktion manuell aufruft .
  • Das errorEreignis wird ausgegeben, wenn der Prozess nicht erzeugt oder beendet werden konnte.
  • Das closeEreignis wird ausgegeben, wenn die stdioStreams eines untergeordneten Prozesses geschlossen werden.
  • Die messageVeranstaltung ist die wichtigste. Es wird ausgegeben, wenn der untergeordnete Prozess die process.send()Funktion zum Senden von Nachrichten verwendet. Auf diese Weise können Eltern / Kind-Prozesse miteinander kommunizieren. Wir werden unten ein Beispiel dafür sehen.

Jedes Kind Prozess wird auch die drei Standard - stdioStreams, die wir zugreifen können child.stdin, child.stdoutund child.stderr.

Wenn diese Streams geschlossen werden, gibt der untergeordnete Prozess, der sie verwendet hat, das closeEreignis aus. Dieses closeEreignis unterscheidet sich vom exitEreignis, da mehrere untergeordnete Prozesse möglicherweise dieselben stdioStreams gemeinsam nutzen. Das Beenden eines untergeordneten Prozesses bedeutet also nicht, dass die Streams geschlossen wurden.

Da alle Streams Ereignisemitter sind, können wir verschiedene Ereignisse in den stdioStreams abhören , die an jeden untergeordneten Prozess angehängt sind. Im Gegensatz zu einem normalen Prozess sind die stdout/ stderrStreams in einem untergeordneten Prozess lesbare Streams, während der stdinStream beschreibbar ist. Dies ist im Grunde die Umkehrung dieser Typen, wie sie in einem Hauptprozess zu finden sind. Die Ereignisse, die wir für diese Streams verwenden können, sind die Standardereignisse. Am wichtigsten ist, dass wir in den lesbaren Streams das dataEreignis abhören können, bei dem der Befehl ausgegeben wird oder bei der Ausführung des Befehls ein Fehler auftritt:

child.stdout.on('data', (data) => { console.log(`child stdout:\n${data}`); }); child.stderr.on('data', (data) => { console.error(`child stderr:\n${data}`); });

Die beiden oben genannten Handler protokollieren beide Fälle im Hauptprozess stdoutund stderr. Wenn wir die spawnobige Funktion ausführen , wird die Ausgabe des pwdBefehls gedruckt und der untergeordnete Prozess wird mit Code beendet 0, was bedeutet, dass kein Fehler aufgetreten ist.

Wir können Argumente an den Befehl übergeben, der von der spawnFunktion ausgeführt wird, indem wir das zweite Argument der spawnFunktion verwenden, bei dem es sich um ein Array aller Argumente handelt, die an den Befehl übergeben werden sollen. Um beispielsweise den findBefehl im aktuellen Verzeichnis mit einem -type fArgument auszuführen (nur um Dateien aufzulisten), können wir Folgendes tun:

const child = spawn('find', ['.', '-type', 'f']);

Wenn während der Ausführung des Befehls ein Fehler auftritt, z. B. wenn wir oben ein ungültiges Ziel suchen, wird der child.stderrdataEreignishandler ausgelöst und der exitEreignishandler meldet einen Exit-Code von 1, der anzeigt, dass ein Fehler aufgetreten ist. Die Fehlerwerte hängen tatsächlich vom Host-Betriebssystem und der Art des Fehlers ab.

Ein untergeordneter Prozess stdinist ein beschreibbarer Stream. Wir können es verwenden, um einem Befehl eine Eingabe zu senden. Wie bei jedem beschreibbaren Stream ist die Verwendung der pipeFunktion der einfachste Weg, ihn zu konsumieren . Wir leiten einfach einen lesbaren Stream in einen beschreibbaren Stream. Da der Hauptprozess stdinein lesbarer Stream ist, können wir diesen in einen untergeordneten Prozessstrom stdinleiten. Zum Beispiel:

const { spawn } = require('child_process'); const child = spawn('wc'); process.stdin.pipe(child.stdin) child.stdout.on('data', (data) => { console.log(`child stdout:\n${data}`); });

Im obigen Beispiel ruft der untergeordnete Prozess den wcBefehl auf, der unter Linux Zeilen, Wörter und Zeichen zählt. Wir leiten dann den Hauptprozess stdin(der ein lesbarer Stream ist) in den untergeordneten Prozess stdin(der ein beschreibbarer Stream ist) weiter. Das Ergebnis dieser Kombination ist, dass wir einen Standardeingabemodus erhalten, in dem wir etwas eingeben können. Wenn wir drücken Ctrl+D, wird das, was wir eingegeben haben, als Eingabe des wcBefehls verwendet.

Wir können auch die Standardeingabe / -ausgabe mehrerer Prozesse aufeinander leiten, genau wie wir es mit Linux-Befehlen tun können. Zum Beispiel können wir stdoutden findBefehl an den Standard des wcBefehls weiterleiten, um alle Dateien im aktuellen Verzeichnis zu zählen:

const { spawn } = require('child_process'); const find = spawn('find', ['.', '-type', 'f']); const wc = spawn('wc', ['-l']); find.stdout.pipe(wc.stdin); wc.stdout.on('data', (data) => { console.log(`Number of files ${data}`); });

Ich -lhabe dem wcBefehl das Argument hinzugefügt , damit nur die Zeilen gezählt werden. Bei der Ausführung gibt der obige Code eine Anzahl aller Dateien in allen Verzeichnissen unter dem aktuellen aus.

Shell-Syntax und die exec-Funktion

Standardmäßig erstellt die spawnFunktion keine Shell , um den Befehl auszuführen, den wir an sie übergeben. Dies macht es etwas effizienter als die execFunktion, die eine Shell erstellt. Die execFunktion hat einen weiteren großen Unterschied. Es puffert die generierte Ausgabe des Befehls und übergibt den gesamten Ausgabewert an eine Rückruffunktion (anstatt Streams zu verwenden, was der spawnFall ist).

Hier ist das vorherige find | wc Beispiel, das mit einer execFunktion implementiert wurde .

const { exec } = require('child_process'); exec('find . -type f | wc -l', (err, stdout, stderr) => { if (err) { console.error(`exec error: ${err}`); return; } console.log(`Number of files ${stdout}`); });

Since the exec function uses a shell to execute the command, we can use the shell syntax directly here making use of the shell pipe feature.

Note that using the shell syntax comes at a security risk if you’re executing any kind of dynamic input provided externally. A user can simply do a command injection attack using shell syntax characters like ; and $ (for example, command + ’; rm -rf ~’ )

The exec function buffers the output and passes it to the callback function (the second argument to exec) as the stdout argument there. This stdout argument is the command’s output that we want to print out.

The exec function is a good choice if you need to use the shell syntax and if the size of the data expected from the command is small. (Remember, exec will buffer the whole data in memory before returning it.)

The spawn function is a much better choice when the size of the data expected from the command is large, because that data will be streamed with the standard IO objects.

We can make the spawned child process inherit the standard IO objects of its parents if we want to, but also, more importantly, we can make the spawn function use the shell syntax as well. Here’s the same find | wc command implemented with the spawn function:

const child = spawn('find . -type f | wc -l', { stdio: 'inherit', shell: true });

Because of the stdio: 'inherit' option above, when we execute the code, the child process inherits the main process stdin, stdout, and stderr. This causes the child process data events handlers to be triggered on the main process.stdout stream, making the script output the result right away.

Because of the shell: true option above, we were able to use the shell syntax in the passed command, just like we did with exec. But with this code, we still get the advantage of the streaming of data that the spawn function gives us. This is really the best of both worlds.

There are a few other good options we can use in the last argument to the child_process functions besides shell and stdio. We can, for example, use the cwd option to change the working directory of the script. For example, here’s the same count-all-files example done with a spawn function using a shell and with a working directory set to my Downloads folder. The cwd option here will make the script count all files I have in ~/Downloads:

const child = spawn('find . -type f | wc -l', { stdio: 'inherit', shell: true, cwd: '/Users/samer/Downloads' });

Another option we can use is the env option to specify the environment variables that will be visible to the new child process. The default for this option is process.env which gives any command access to the current process environment. If we want to override that behavior, we can simply pass an empty object as the env option or new values there to be considered as the only environment variables:

const child = spawn('echo $ANSWER', { stdio: 'inherit', shell: true, env: { ANSWER: 42 }, });

The echo command above does not have access to the parent process’s environment variables. It can’t, for example, access $HOME, but it can access $ANSWER because it was passed as a custom environment variable through the env option.

One last important child process option to explain here is the detached option, which makes the child process run independently of its parent process.

Assuming we have a file timer.js that keeps the event loop busy:

setTimeout(() => { // keep the event loop busy }, 20000);

We can execute it in the background using the detached option:

const { spawn } = require('child_process'); const child = spawn('node', ['timer.js'], { detached: true, stdio: 'ignore' }); child.unref();

The exact behavior of detached child processes depends on the OS. On Windows, the detached child process will have its own console window while on Linux the detached child process will be made the leader of a new process group and session.

If the unref function is called on the detached process, the parent process can exit independently of the child. This can be useful if the child is executing a long-running process, but to keep it running in the background the child’s stdio configurations also have to be independent of the parent.

The example above will run a node script (timer.js) in the background by detaching and also ignoring its parent stdio file descriptors so that the parent can terminate while the child keeps running in the background.

The execFile function

If you need to execute a file without using a shell, the execFile function is what you need. It behaves exactly like the exec function, but does not use a shell, which makes it a bit more efficient. On Windows, some files cannot be executed on their own, like .bat or .cmd files. Those files cannot be executed with execFile and either exec or spawn with shell set to true is required to execute them.

The *Sync function

The functions spawn, exec, and execFile from the child_process module also have synchronous blocking versions that will wait until the child process exits.

const { spawnSync, execSync, execFileSync, } = require('child_process');

Those synchronous versions are potentially useful when trying to simplify scripting tasks or any startup processing tasks, but they should be avoided otherwise.

The fork() function

The fork function is a variation of the spawn function for spawning node processes. The biggest difference between spawn and fork is that a communication channel is established to the child process when using fork, so we can use the send function on the forked process along with the global process object itself to exchange messages between the parent and forked processes. We do this through the EventEmitter module interface. Here’s an example:

The parent file, parent.js:

const { fork } = require('child_process'); const forked = fork('child.js'); forked.on('message', (msg) => { console.log('Message from child', msg); }); forked.send({ hello: 'world' });

The child file, child.js:

process.on('message', (msg) => { console.log('Message from parent:', msg); }); let counter = 0; setInterval(() => { process.send({ counter: counter++ }); }, 1000);

In the parent file above, we fork child.js (which will execute the file with the node command) and then we listen for the message event. The message event will be emitted whenever the child uses process.send, which we’re doing every second.

To pass down messages from the parent to the child, we can execute the send function on the forked object itself, and then, in the child script, we can listen to the message event on the global process object.

When executing the parent.js file above, it’ll first send down the { hello: 'world' } object to be printed by the forked child process and then the forked child process will send an incremented counter value every second to be printed by the parent process.

Let’s do a more practical example about the fork function.

Let’s say we have an http server that handles two endpoints. One of these endpoints (/compute below) is computationally expensive and will take a few seconds to complete. We can use a long for loop to simulate that:

const http = require('http'); const longComputation = () => { let sum = 0; for (let i = 0; i  { if (req.url === '/compute') { const sum = longComputation(); return res.end(`Sum is ${sum}`); } else { res.end('Ok') } }); server.listen(3000);

This program has a big problem; when the the /compute endpoint is requested, the server will not be able to handle any other requests because the event loop is busy with the long for loop operation.

There are a few ways with which we can solve this problem depending on the nature of the long operation but one solution that works for all operations is to just move the computational operation into another process using fork.

We first move the whole longComputation function into its own file and make it invoke that function when instructed via a message from the main process:

In a new compute.js file:

const longComputation = () => { let sum = 0; for (let i = 0; i  { const sum = longComputation(); process.send(sum); });

Now, instead of doing the long operation in the main process event loop, we can fork the compute.js file and use the messages interface to communicate messages between the server and the forked process.

const http = require('http'); const { fork } = require('child_process'); const server = http.createServer(); server.on('request', (req, res) => { if (req.url === '/compute') { const compute = fork('compute.js'); compute.send('start'); compute.on('message', sum => { res.end(`Sum is ${sum}`); }); } else { res.end('Ok') } }); server.listen(3000);

When a request to /compute happens now with the above code, we simply send a message to the forked process to start executing the long operation. The main process’s event loop will not be blocked.

Once the forked process is done with that long operation, it can send its result back to the parent process using process.send.

In the parent process, we listen to the message event on the forked child process itself. When we get that event, we’ll have a sum value ready for us to send to the requesting user over http.

The code above is, of course, limited by the number of processes we can fork, but when we execute it and request the long computation endpoint over http, the main server is not blocked at all and can take further requests.

Node’s cluster module, which is the topic of my next article, is based on this idea of child process forking and load balancing the requests among the many forks that we can create on any system.

That’s all I have for this topic. Thanks for reading! Until next time!

Learning React or Node? Checkout my books:

  • Learn React.js by Building Games
  • Node.js Beyond the Basics