1. Node多进程child_process库exec方法源码执行流程分析

疑问和收获:

  • exec和execFile到底有什么区别?
  • 为什么exec/execFile/fork都是通过spawn实现的,spawn的作用到底是什么?
  • 为什么spawn调用后没有回调,而exec和execFile能够回调?
  • 为什么spawn调用后需要手动调用child.stdout.on(‘data’,callback),这里的child.stdout/child.stderr到底是什么?
  • 为什么有data/error/exit/close这么多种回调,他们的执行顺序到底是怎样的?

    exec 源码深入分析

image.png

在未学习exec源码之前,我们先对上面的拓扑图进行一个简单的学习,看到exec内部的执行流程 不难看到exec执行的是execlFile这个方法,且不同的地方就是传入的参数不同,而execFile执行的是spawn这个方法,且spawn这个方法调用的是node内部库的一个child_process方法。
我们在webstorm中打开一个项目

  1. // /bin/process/index.js
  2. const cp = require('child_process')
  3. const path = require('path')
  4. cp.exec('ls -al|grep node_modules ',function(err,stdout,stderr){
  5. console.log(err)
  6. console.log(stdout)
  7. console.log(stderr)
  8. })

1、通过断点调试可以知道,exec方法实际上只是调用了normalizeExecArgs方法,对参数进行了处理,然后调用了execFile方法

  1. function exec(command, options, callback) {
  2. const opts = normalizeExecArgs(command, options, callback);
  3. return module.exports.execFile(opts.file,
  4. opts.options,
  5. opts.callback);
  6. }

2、execFile 内部也是调用了spawn方法,把参数格式化成了这种形式:
指定了执行命令的终端/bin/sh

  1. args: ['/bin/sh', '-c', 'ls -al|grep node_modules ']

3、spawn内部也是调用new ChildProcess()方法,创建了子进程来执行实例child上的spawn方法。

  1. function spawn(file, args, options) {
  2. options = normalizeSpawnArguments(file, args, options);
  3. validateTimeout(options.timeout);
  4. validateAbortSignal(options.signal, 'options.signal');
  5. const killSignal = sanitizeKillSignal(options.killSignal);
  6. const child = new ChildProcess();
  7. debug('spawn', options);
  8. // 调用的是子进程child的spawn方法
  9. child.spawn(options);

ChildProcess调用的是C++的内置库,帮我们创建一个子进程。

  1. const { Process } = internalBinding('process_wrap');

在监视器中打印process.pid可以看出当前进程是80796,然后 ps -ef | grep 80796可以查看当前的子进程id 80797。

  1. wangjiayan@wangjiayandeMacBook-Pro-2 test-cli % ps -ef | grep 80796
  2. 501 80796 79802 0 5:28下午 ?? 0:00.21 /usr/local/bin/node ./bin/process.js
  3. 501 80797 80796 0 5:28下午 ?? 0:00.16 /usr/local/bin/node /private/var/folders/hl/7kpq3z6j59l5pkywt742tqnw0000gn/T/AppTranslocation/DFC0361D-C073-4F57-907F-256B6C4DAD07/d/Visual Studio Code.app/Contents/Resources/app/extensions/ms-vscode.js-debug/src/watchdog.bundle.js
  4. 501 80907 80796 0 5:37下午 ?? 0:00.00 (bash)

4、spawn方法,创建三个管道通信stdio,实现了消息订阅接口ee,并且给每个输入输出流实现了socket接口,

深度分析child_process库spawn底层实现

接着上一节代码块中走到了child.spawn

  • 第一步是通过getValidStdio去生成pipe,创建一个管道实例:第一个是输入,第二个是输出,第三个是error(只是生成了管道,但是还没创建socket的通信)
  • 第二步对spawn的一些参数进行处理:下面代码未贴
  • 第三步通过this._handle.spawn 子进程被创建出来
  • 第四步通过createSocket方法,将之前的pipe和子进程与socket绑定。

    1. ChildProcess.prototype.spawn = function(options) {
    2. ...
    3. // 创建三个管道通信
    4. stdio = getValidStdio(stdio, false);
    5. ...
    6. for (i = 0; i < stdio.length; i++) {
    7. const stream = stdio[i];
    8. ....
    9. if (stream.handle) {
    10. ....
    11. stream.socket = createSocket(this.pid !== 0 ?
    12. stream.handle : null, i > 0);
    13. if (i > 0 && this.pid !== 0) {
    14. this._closesNeeded++;
    15. stream.socket.on('close', () => {
    16. maybeClose(this);
    17. });
    18. }
    19. }
    20. }
    21. }

5、spawn方法回调原理,主要分2条线。方便在回调的时候将stdout一次性返回。

  • 子进程的执行线,由事件订阅实现,子进程退出后执行

    1. cp.on('exit',function(code){
    2. console.log('exit',exit)
    3. })
  • 流读取:由socket实现,这个是流关闭后去触发

    1. cp.stdout.on('data',function(chunk){
    2. console.log('stdout',chunk.toString())
    3. })

    截屏2022-09-07 22.49.14.png

    spawn 底层是调用了C++创建子进程并执行子进程的方法,

Node多进程源码总结

exec/execFile/spawn/fork的区别

  • exec: 原理是调用/bin/sh -c 执行我们传入的shell脚本,底层调用略execFile
  • execFile:原理是直接执行我们传入的file和args,底层调用spawn创建和执行子进程,并建立略回调,一次性将所有的stdout和stderr结果返回
  • spawn:原理是调用略internal/child_process,实例化略ChildProcess子进程对象,再调用child.spawn创建 子进程并执行命令,底层是调用了child.)handle.spawn执行process_wrap中的spwan方法,执行过程是异步的,执行完毕后再通过PIPE进行单向数据通信,通信结束后子进程发起onexit回调,同时Socket会执行close回调。
  • fork:原理是通过spawn创建子进程和执行命令,采用node执行命令,通过setupchannel创建IPC用于子进程和父进程之间的双向通信。

    data/error/exit/close回调的区别

  • data:用于主进程读取数据过程中通过onStreamRead发起的回调

  • error: 命令执行失败后发起的回调
  • exit: 子进程关闭完成后发起的回调
  • close:子进程所有Socket通信端口全部关闭后发起的回调
  • stdout close/stderr close:特定的PIPE读取完成后调用onReadableStreamEnd关闭Socket时发起的回调。

    要点1:对参数的处理

    child_process.exec(command[, options][, callback])这里如果传入两个参数,对参数做了处理成了三个参数。 ```javascript function normalizeExecArgs(command, options, callback) { if (typeof options === ‘function’) { callback = options; options = undefined; }

    // Make a shallow copy so we don’t clobber the user’s options object. // 这里有疑惑,浅拷贝改变了,源对象,也会跟着改变啊 options = { …options }; options.shell = typeof options.shell === ‘string’ ? options.shell : true;

    return { file: command, options: options, callback: callback }; }

  1. <a name="Q9L2m"></a>
  2. ## 要点2:终端执行命令的原理
  3. 执行命令`ls`时,相当于`/bin/sh -c "ls"`<br />`-c` :<command><br />`/bin/sh`后面既可以加命令,也可以加可执行文件。script-file
  4. <a name="KCwke"></a>
  5. ## 要点3查看当前进程及子进程
  6. 判断当前程序执行进程的方法,代码调用`process.pid`拿到当前进程id。然后再
  7. ```javascript
  8. ps -ef | grep [pid]