Node.js Child Processes: Όλα όσα πρέπει να γνωρίζετε

Τρόπος χρήσης spawn (), exec (), execFile () και πιρούνι ()

Ενημέρωση: Αυτό το άρθρο είναι πλέον μέρος του βιβλίου μου "Node.js Beyond The Basics".

Διαβάστε την ενημερωμένη έκδοση αυτού του περιεχομένου και περισσότερα σχετικά με τον Node στη διεύθυνση jscomplete.com/node-beyond-basics .

Μονόκλωνη, μη αποκλεισμένη απόδοση στο Node.js λειτουργεί τέλεια για μία μόνο διαδικασία. Αλλά τελικά, μια διαδικασία σε μία CPU δεν θα είναι αρκετή για να αντιμετωπίσει τον αυξανόμενο φόρτο εργασίας της εφαρμογής σας.

Ανεξάρτητα από το πόσο ισχυρός μπορεί να είναι ο διακομιστής σας, ένα μόνο νήμα μπορεί να υποστηρίξει μόνο ένα περιορισμένο φορτίο.

Το γεγονός ότι το Node.js εκτελείται σε ένα νήμα δεν σημαίνει ότι δεν μπορούμε να επωφεληθούμε από πολλές διαδικασίες και, φυσικά, από πολλές μηχανές επίσης.

Η χρήση πολλαπλών διαδικασιών είναι ο καλύτερος τρόπος για να κλιμακώσετε μια εφαρμογή Node. Το Node.js έχει σχεδιαστεί για τη δημιουργία κατανεμημένων εφαρμογών με πολλούς κόμβους. Γι 'αυτό ονομάζεται Κόμβος . Η δυνατότητα κλιμάκωσης ενσωματώνεται στην πλατφόρμα και δεν είναι κάτι που αρχίζετε να σκέφτεστε αργότερα στη διάρκεια μιας εφαρμογής.

Αυτό το άρθρο αποτελεί σύνταξη μέρους του μαθήματος Pluralsight σχετικά με το Node.js. Καλύπτω παρόμοιο περιεχόμενο σε μορφή βίντεο εκεί.

Λάβετε υπόψη ότι θα πρέπει να κατανοήσετε καλά τα συμβάντα και τις ροές του Node.js προτού διαβάσετε αυτό το άρθρο. Εάν δεν το έχετε κάνει ήδη, σας συνιστούμε να διαβάσετε αυτά τα δύο άλλα άρθρα προτού το διαβάσετε:

Κατανόηση της αρχιτεκτονικής βάσει συμβάντων Node.js

Τα περισσότερα από τα αντικείμενα του Node - όπως αιτήματα HTTP, απαντήσεις και ροές - εφαρμόζουν τη λειτουργική μονάδα EventEmitter ώστε να μπορούν…

Ροές: Όλα όσα πρέπει να γνωρίζετε

Οι ροές Node.js έχουν τη φήμη ότι είναι δύσκολο να εργαστούν και ακόμη πιο δύσκολο να κατανοηθούν. Λοιπόν έχω καλά νέα…

Η ενότητα «Παιδικές διεργασίες»

Μπορούμε εύκολα να περιστρέψουμε μια παιδική διαδικασία χρησιμοποιώντας την child_processενότητα του Node και αυτές οι θυγατρικές διαδικασίες μπορούν εύκολα να επικοινωνούν μεταξύ τους με ένα σύστημα ανταλλαγής μηνυμάτων.

Η child_processενότητα μας επιτρέπει να έχουμε πρόσβαση στις λειτουργίες του Λειτουργικού Συστήματος εκτελώντας οποιαδήποτε εντολή συστήματος μέσα σε μια, καλά, παιδική διαδικασία.

Μπορούμε να ελέγξουμε τη ροή εισόδου της θυγατρικής διαδικασίας και να ακούσουμε τη ροή εξόδου της. Μπορούμε επίσης να ελέγξουμε τα επιχειρήματα που πρέπει να περάσουν στην υποκείμενη εντολή OS και μπορούμε να κάνουμε ό, τι θέλουμε με την έξοδο αυτής της εντολής. Μπορούμε, για παράδειγμα, να διοχετεύσουμε την έξοδο μιας εντολής ως την είσοδο σε μια άλλη (όπως κάνουμε στο Linux), καθώς όλες οι είσοδοι και οι έξοδοι αυτών των εντολών μπορούν να μας παρουσιαστούν χρησιμοποιώντας ροές Node.js.

Σημειώστε ότι τα παραδείγματα που θα χρησιμοποιώ σε αυτό το άρθρο βασίζονται σε Linux. Στα Windows, πρέπει να αλλάξετε τις εντολές που χρησιμοποιώ με τις εναλλακτικές λύσεις των Windows.

Υπάρχουν τέσσερις διαφορετικοί τρόποι για να δημιουργήσετε μια διαδικασία παιδί στον κόμβο: spawn(), fork(), exec(), και execFile().

Θα δούμε τις διαφορές μεταξύ αυτών των τεσσάρων λειτουργιών και πότε θα χρησιμοποιήσουμε καθεμία.

Διαδικασίες αναπαραγωγής παιδιών

Η spawnσυνάρτηση ξεκινά μια εντολή σε μια νέα διαδικασία και μπορούμε να την χρησιμοποιήσουμε για να περάσουμε αυτήν την εντολή σε ορίσματα. Για παράδειγμα, εδώ είναι ο κώδικας για τη δημιουργία μιας νέας διαδικασίας που θα εκτελέσει την pwdεντολή.

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

Απλώς καταστρέφουμε τη spawnλειτουργία από τη child_processμονάδα και την εκτελούμε με την εντολή OS ως το πρώτο όρισμα.

Το αποτέλεσμα της εκτέλεσης της spawnσυνάρτησης (το childπαραπάνω αντικείμενο) είναι μια ChildProcessπαρουσία, η οποία εφαρμόζει το EventEmitter API. Αυτό σημαίνει ότι μπορούμε να εγγράψουμε χειριστές για συμβάντα σε αυτό το θυγατρικό αντικείμενο απευθείας. Για παράδειγμα, μπορούμε να κάνουμε κάτι όταν η παιδική διαδικασία τερματίζεται με την εγγραφή ενός χειριστή για την exitεκδήλωση:

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

Ο χειριστής παραπάνω μας δίνει την έξοδο codeγια την παιδική διαδικασία και signal, εάν υπάρχει, που χρησιμοποιήθηκε για τον τερματισμό της παιδικής διαδικασίας. Αυτή η signalμεταβλητή είναι μηδενική όταν η διαδικασία του παιδιού τερματίζεται κανονικά.

Τα άλλα γεγονότα που μπορούμε να εγγραφούν χειριστές για με τις ChildProcessπεριπτώσεις είναι disconnect, error, close, και message.

  • Το disconnectσυμβάν εκπέμπεται όταν η γονική διαδικασία καλεί χειροκίνητα τη child.disconnectσυνάρτηση.
  • Το errorσυμβάν εκπέμπεται εάν η διαδικασία δεν μπορούσε να αναπαραχθεί ή να σκοτωθεί.
  • Το closeσυμβάν εκπέμπεται όταν stdioκλείσουν οι ροές μιας παιδικής διαδικασίας.
  • Η messageεκδήλωση είναι η πιο σημαντική. Εκπέμπεται όταν η παιδική διαδικασία χρησιμοποιεί τη process.send()λειτουργία για την αποστολή μηνυμάτων. Έτσι οι διαδικασίες γονέα / παιδιού μπορούν να επικοινωνούν μεταξύ τους. Θα δούμε ένα παράδειγμα παρακάτω.

Κάθε διαδικασία παιδί παίρνει επίσης τις τρεις τυπικές stdioροές, οι οποίες θα μπορούν να έχουν πρόσβαση με τη χρήση child.stdin, child.stdoutκαι child.stderr.

Όταν αυτές οι ροές κλείσουν, η θυγατρική διαδικασία που τις χρησιμοποιούσε θα εκπέμψει το closeσυμβάν. Αυτό το closeσυμβάν είναι διαφορετικό από το exitσυμβάν, επειδή πολλές θυγατρικές διεργασίες ενδέχεται να μοιράζονται τις ίδιες stdioροές και επομένως μία θυγατρική διαδικασία εξόδου δεν σημαίνει ότι οι ροές έκλεισαν.

Δεδομένου ότι όλες οι ροές είναι εκπομπές συμβάντων, μπορούμε να ακούσουμε διαφορετικά συμβάντα σε αυτές τις stdioροές που είναι συνδεδεμένες σε κάθε θυγατρική διαδικασία. Σε αντίθεση με μια κανονική διαδικασία, ωστόσο, σε μια θυγατρική διαδικασία, οι stdout/ stderrροές είναι αναγνώσιμες ροές, ενώ η stdinροή είναι εγγράψιμη. Αυτό είναι βασικά το αντίστροφο αυτών των τύπων όπως βρίσκεται σε μια κύρια διαδικασία. Τα συμβάντα που μπορούμε να χρησιμοποιήσουμε για αυτές τις ροές είναι τα τυπικά. Το πιο σημαντικό, στις αναγνώσιμες ροές, μπορούμε να ακούσουμε το dataσυμβάν, το οποίο θα έχει την έξοδο της εντολής ή οποιοδήποτε σφάλμα παρουσιάζεται κατά την εκτέλεση της εντολής:

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

Οι δύο παραπάνω διαχειριστές θα καταγράψουν και τις δύο περιπτώσεις στην κύρια διαδικασία stdoutκαι stderr. Όταν εκτελούμε την spawnπαραπάνω λειτουργία, pwdεκτυπώνεται η έξοδος της εντολής και η θυγατρική διαδικασία εξέρχεται με κωδικό 0, πράγμα που σημαίνει ότι δεν προέκυψε σφάλμα.

Μπορούμε να μεταφέρουμε ορίσματα στην εντολή που εκτελείται από τη spawnσυνάρτηση χρησιμοποιώντας το δεύτερο όρισμα της spawnσυνάρτησης, η οποία είναι ένας πίνακας με όλα τα ορίσματα που θα μεταβιβαστούν στην εντολή. Για παράδειγμα, για να εκτελέσετε την findεντολή στον τρέχοντα κατάλογο με ένα -type fόρισμα (μόνο για τη λίστα αρχείων), μπορούμε να κάνουμε:

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

Εάν προκύψει σφάλμα κατά την εκτέλεση της εντολής, για παράδειγμα, αν βρούμε έναν άκυρο προορισμό παραπάνω, ο child.stderrdataχειριστής συμβάντων θα ενεργοποιηθεί και ο exitχειριστής συμβάντων θα αναφέρει έναν κωδικό εξόδου 1, ο οποίος σημαίνει ότι έχει προκύψει σφάλμα. Οι τιμές σφάλματος εξαρτώνται πραγματικά από το κεντρικό λειτουργικό σύστημα και τον τύπο σφάλματος.

Η παιδική διαδικασία stdinείναι μια εγγράψιμη ροή. Μπορούμε να το χρησιμοποιήσουμε για να στείλουμε μια εντολή κάποια είσοδο. Ακριβώς όπως κάθε εγγράψιμη ροή, ο ευκολότερος τρόπος να το καταναλώσετε είναι να χρησιμοποιήσετε τη pipeλειτουργία. Απλώς διοχετεύουμε μια αναγνώσιμη ροή σε μια εγγράψιμη ροή. Δεδομένου ότι η κύρια διαδικασία stdinείναι μια αναγνώσιμη ροή, μπορούμε να την διοχετεύσουμε σε μια stdinροή διεργασίας για παιδιά . Για παράδειγμα:

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}`); });

Στο παραπάνω παράδειγμα, η θυγατρική διαδικασία επικαλείται την wcεντολή, η οποία μετράει γραμμές, λέξεις και χαρακτήρες στο Linux. Στη συνέχεια διοχετεύουμε την κύρια διαδικασία stdin(η οποία είναι μια αναγνώσιμη ροή) στη θυγατρική διαδικασία stdin(η οποία είναι μια εγγράψιμη ροή). Το αποτέλεσμα αυτού του συνδυασμού είναι ότι έχουμε μια τυπική λειτουργία εισαγωγής όπου μπορούμε να πληκτρολογήσουμε κάτι και όταν χτυπήσουμε Ctrl+D, αυτό που πληκτρολογήσαμε θα χρησιμοποιηθεί ως είσοδος της wcεντολής.

Μπορούμε επίσης να διοχετεύσουμε την τυπική είσοδο / έξοδο πολλαπλών διεργασιών μεταξύ τους, όπως μπορούμε να κάνουμε με εντολές Linux. Για παράδειγμα, μπορούμε σωλήνα η stdoutτης findεντολής στο stdin του wcεντολή για να μετρήσει όλα τα αρχεία στον τρέχοντα κατάλογο:

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}`); });

Πρόσθεσα το -lόρισμα στην wcεντολή για να το κάνω να μετρά μόνο τις γραμμές. Όταν εκτελεστεί, ο παραπάνω κώδικας θα εμφανίσει μια μέτρηση όλων των αρχείων σε όλους τους καταλόγους κάτω από τον τρέχοντα.

Shell Syntax και η συνάρτηση exec

Από προεπιλογή, η spawnσυνάρτηση δεν δημιουργεί ένα κέλυφος για την εκτέλεση της εντολής που περνάμε σε αυτό. Αυτό το καθιστά λίγο πιο αποτελεσματικό από τη execλειτουργία, η οποία δημιουργεί ένα κέλυφος. Η execσυνάρτηση έχει μια άλλη σημαντική διαφορά. Είναι αποθηκεύει προσωρινά δημιουργείται εξόδου της εντολής και περνά ολόκληρη την τιμή εξόδου σε μια λειτουργία επανάκλησης (αντί για τη χρήση ρευμάτων, το οποίο είναι αυτό spawnκάνει).

Εδώ είναι το προηγούμενο find | wc παράδειγμα που εφαρμόστηκε με μια execσυνάρτηση.

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.

Στη διαδικασία των γονέων, ακούμε το ίδιο το messageγεγονός σχετικά με τη διαδικασία του διχαλωτού παιδιού. Όταν λάβουμε αυτό το συμβάν, θα έχουμε μια sumτιμή έτοιμη για αποστολή στον αιτούντα χρήστη μέσω http.

Ο παραπάνω κωδικός, φυσικά, περιορίζεται από τον αριθμό των διεργασιών που μπορούμε να διαχωρίσουμε, αλλά όταν τον εκτελέσουμε και ζητήσουμε το μεγάλο τελικό σημείο υπολογισμού μέσω http, ο κύριος διακομιστής δεν αποκλείεται καθόλου και μπορεί να λάβει περαιτέρω αιτήματα.

Η clusterενότητα του κόμβου , που είναι το θέμα του επόμενου άρθρου μου, βασίζεται σε αυτήν την ιδέα της διαμόρφωσης παιδικών διεργασιών και εξισορρόπησης φορτίων στα αιτήματα μεταξύ των πολλών πιρούνων που μπορούμε να δημιουργήσουμε σε οποιοδήποτε σύστημα.

Αυτό έχω μόνο για αυτό το θέμα. Ευχαριστώ για την ανάγνωση! Μέχρι την επόμενη φορά!

Εκμάθηση αντίδρασης ή κόμβος; Δείτε τα βιβλία μου:

  • Μάθετε το React.js δημιουργώντας παιχνίδια
  • Node.js Πέρα από τα βασικά