Дочірні процеси Node.js: усе, що вам потрібно знати

Як використовувати spawn (), exec (), execFile () та fork ()

Оновлення: Ця стаття тепер є частиною моєї книги “Node.js поза межами основ”.

Прочитайте оновлену версію цього вмісту та більше про Node на jscomplete.com/node-beyond-basics .

Однопоточна, не блокуюча продуктивність у Node.js чудово працює для одного процесу. Але врешті-решт одного процесу в одному центральному процесорі буде недостатньо для обробки зростаючого навантаження вашого додатка.

Яким би потужним не був ваш сервер, один потік може підтримувати лише обмежене навантаження.

Той факт, що Node.js працює в одному потоці, не означає, що ми не можемо скористатися перевагами декількох процесів і, звичайно, кількох машин.

Використання декількох процесів є найкращим способом масштабування програми Node. Node.js призначений для побудови розподілених додатків з багатьма вузлами. Ось чому він називається Node . Масштабованість врахована на платформі, і це не те, про що ви починаєте думати пізніше протягом життя програми.

Ця стаття являє собою опис частини мого курсу Pluralsight про Node.js. Я охоплюю подібний вміст у відеоформаті.

Зверніть увагу, що перед тим, як прочитати цю статтю, вам потрібно буде добре розуміти події та потоки Node.js. Якщо ви цього ще не зробили, рекомендую прочитати ці дві інші статті, перш ніж прочитати цю:

Розуміння архітектури, керованої подіями Node.js

Більшість об'єктів Node - наприклад, HTTP-запити, відповіді та потоки - реалізують модуль EventEmitter, щоб вони могли ...

Потоки: все, що вам потрібно знати

Потоки Node.js мають репутацію важких для роботи та ще складніших для розуміння. Ну, у мене є хороші новини ...

Модуль дитячих процесів

Ми можемо легко обертати дочірній процес за допомогою child_processмодуля Node, і ці дочірні процеси можуть легко спілкуватися між собою за допомогою системи обміну повідомленнями.

child_processМодуль дозволяє нам отримати доступ до операційної системи функцій, запустивши будь-яку системну команду всередині, а, дочірнього процесу.

Ми можемо контролювати цей дочірній потік вхідного потоку та слухати його вихідний потік. Ми також можемо контролювати аргументи, що передаються базовій команді ОС, і ми можемо робити все, що завгодно, з результатами цієї команди. Наприклад, ми можемо передавати вихідні дані однієї команди як вхідні дані для іншої (як це робимо в Linux), оскільки всі входи та виходи цих команд можуть бути представлені нам за допомогою потоків Node.js.

Зверніть увагу, що всі приклади, які я буду використовувати в цій статті, основані на Linux. У Windows вам потрібно змінити команди, які я використовую, з їх альтернативами Windows.

Є чотири різні способи створення дочірнього процесу в вузлі: spawn(), fork(), exec(), і execFile().

Ми побачимо різницю між цими чотирма функціями та час використання кожної з них.

Породжені дитячі процеси

spawnФункція запускає команду в новому процесі , і ми можемо використовувати його , щоб передати цю команду аргументів. Наприклад, ось код для створення нового процесу, який буде виконувати pwdкоманду.

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

Ми просто деструктуруємо spawnфункцію з child_processмодуля і виконуємо її за допомогою команди ОС як першого аргументу.

Результатом виконання 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команди в потоці введення 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команди, щоб вона враховувала лише рядки. Після виконання, наведений вище код видасть кількість усіх файлів у всіх каталогах під поточним.

Синтаксис оболонки та функція 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.

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