EasonYou / my-blog

It's my blog recording front-end
2 stars 0 forks source link

【node源码】child_process源码阅读 #13

Open EasonYou opened 4 years ago

EasonYou commented 4 years ago

childe_process 子进程

child_process 模块提供了衍生子进程的能力,主要由child_process.spawn()函数提供

child_process.spawn()方法异步衍生子进程,不阻塞Node.js事件循环。

child_process.spawnSync()提供一样的功能,但是会阻塞时间循环知道进程退出或终止

其他的方法(同步与异步),都是基于以上两个方法实现的,只是方便用户使用。

创建异步进程

在 Windows 上衍生 .bat 和 .cmd 文件(了解)

在Unix 类型的操作系统,child_process.execFile()会比child_process.exec()性能更好,因为默认情况下不会衍生shell

但在windows下,.bat和.cmd文件没有终端情况下不能自行执行,因此无法使用child_process.execFile()启动。

执行.bat和.cmd可以使用设置了shell选项的child_process.spawn()child_process.exec()

child_process.exec(command[, options][, callback])

衍生一个shell然后在该shell中执行command,并缓冲任何产生的输出

可提供callback,调用时传入参数(error, stdout, stderr)errorError的实例,error.code是子进程的退出码,error.signal被设为终止进程的信号,除0以外的退出码都被视为错误。

child_process.exec()不会替换现有的进程,且使用 shell 来执行命令

exec('"/path/to/test file/test.sh" arg1 arg2');
// 使用双引号,以便路径中的空格不被解析为多个参数的分隔符。

exec('echo "The \\$HOME variable is $HOME"');
// 第一个 $HOME 变量会被转义,第二个则不会。

child_process.execFile(file[, args][, options][, callback])

child_process.execFile() 函数类似于 child_process.exec(),但默认情况下不会衍生 shell。 相反,指定的可执行文件 file 会作为新进程直接地衍生,使其比 child_process.exec() 稍微更高效。

execFileexec的一层封装,底层调用的就是exec

child_process.fork(modulePath[, args][, options])

child_process.fork()专门用于衍生新的Node.js进程,一样返回ChildProcess对象,其将会内置一个额外的通信通道,允许信息在父子进程间来回传递。(subprocess.send()

除了衍生进程外,内容和V8实例等,也需要额外分配资源

不设置的情况下,会使用父进程的process.execPath来衍生新的Node.js实例,可通过设置,允许使用其他的执行路径。

使用自定义的 execPath 启动的 Node.js 进程将会使用文件描述符(在子进程上使用环境变量 NODE_CHANNEL_FD 标识)与父进程通信

child_process.fork() 不会克隆当前的进程,且不支持shell选项

child_process.spawn(command[, args][, options])

child_process.spawn() 方法使用给定的 command 衍生一个新进程,并带上 args 中的命令行参数。

总结

进程模块,总的来说在使用上比较简单,没有很复杂的地方。还有几个同步创建子进程的方法,与上面的异步方法雷同,不再赘述。

但是需要深入了解各个方法的核心调用,理解操作系统层面的一些问题。后续需要深入node的这块的源码。

源码阅读

child_process在JavaScript的核心模块上,还是比较简单的,派生进程的实现都交给了内建模块的c++实现了

从源码上,可以很好地阐述,node是如何基于spwan去派生出其他方法的

fork/exec/execfile的实现

从文档中可得知,fork/exec/execFile三个方法都是基于spwan封装的,其中exec基于execFile封装

// exec中,将file options做了一层封装,直接调用的execFile
function exec(command, options, callback) {
  const opts = normalizeExecArgs(command, options, callback);
  return module.exports.execFile(opts.file,
                                 opts.options,
                                 opts.callback);
}
// execFile主要还是通过spawn来进行子进程派生
// 后面更多的是对退出和错误事件的监听以及stdout和stderr的输出处理
function execFile(file /* , args, options, callback */) {
  //..
  // 派生子进程
  const child = spawn(file, args, {
    cwd: options.cwd,
    env: options.env,
    gid: options.gid,
    uid: options.uid,
    shell: options.shell,
    windowsHide: !!options.windowsHide,
    windowsVerbatimArguments: !!options.windowsVerbatimArguments
  });

  //..
  // 退出事件
  function exithandler(code, signal) { }
  // 错误事件
  function errorhandler(e) { }
  // 信息传递
  function kill() { }
  // ..
  // 缓存stdout和stderr
  if (child.stdout) {
    if (encoding)
      child.stdout.setEncoding(encoding);
    // 监听data事件
    child.stdout.on('data', function onChildStdout(chunk) {
      const encoding = child.stdout.readableEncoding;
      // 如果是buffer类型,则加上收到的字节数,否则,加上收到的字符串
      const length = encoding ?
        Buffer.byteLength(chunk, encoding) :
        chunk.length;
      stdoutLen += length;
      if (stdoutLen > options.maxBuffer) { // 判断是否超出内部的buffer
        const truncatedLen = options.maxBuffer - (stdoutLen - length);
        _stdout.push(chunk.slice(0, truncatedLen)); // 缓存字符串
        kill();
      } else {
        _stdout.push(chunk); // 缓存buffer
      }
    });
  }

  if (child.stderr) {
    // .. 忽略,处理方式跟data差不多
  }
  // 监听事件
  child.addListener('close', exithandler);
  child.addListener('error', errorhandler);

  return child;
}
// fork,主要还是调用的spawn方法
// 主要是针对node的调用路径,做了参数上的序列化
function fork(modulePath /* , args, options */) {
  // ...
  // 如果没有execPath,则派生当前进程的路径
  options.execPath = options.execPath || process.execPath;
  // 不以shell的形式启动
  options.shell = false;

  return spawn(options.execPath, args, options);
}

从上面的代码可以看出spawn方法才是最关键的方法,基本都是基于spawn去做参数的封装

子进程派生,spawn

首先来看下spawn方法里面做了什么

// lib/child_process.js
// 在内建模块上,去引入child_process
const child_process = require('internal/child_process');
// 这里,我们只关注ChildProcess这个构造方法
const { ChildProcess } = child_process;
// spawn方法,由于派生子进程
function spawn(file, args, options) {
  // 序列化参数
  const opts = normalizeSpawnArguments(file, args, options);
  // 生成一个childprocess实例
  const child = new ChildProcess();
  // 通过spwan方法进行子进程的派生
  child.spawn({ /** ... */ });
  return child;
}

从上面可以看出,其实spawn方法只是new了一个ChildProcess的实例,然后调用了实例的spawn方法

接下来看下ChildProcess内部是怎么运作的

// lib/internal/child_process.js
function ChildProcess() {
  // 继承了EventEmitter
  EventEmitter.call(this);
  //..
  // 生成一个进程的派生句柄,并没有在这里直接派生进程
  this._handle = new Process();
  this._handle[owner_symbol] = this;
  // 绑定onexit时间
  this._handle.onexit = (exitCode, signalCode) => {
    // ..这里做了一些进程退出的事件操作,忽略
  };
}

// 这里是真正派生进程的方法
ChildProcess.prototype.spawn = function(options) {
  let i = 0;
  // ..
  // 默认为pipe的标准释出
  let stdio = options.stdio || 'pipe';
  stdio = getValidStdio(stdio, false);
  // ..

  if (ipc !== undefined) {
    // 让子进程知道IPC通道
    options.envPairs.push(`NODE_CHANNEL_FD=${ipcFd}`);
    options.envPairs.push(`NODE_CHANNEL_SERIALIZATION_MODE=${serialization}`);
  }
  // 获取file等参数
  this.spawnfile = options.file;
  if (ArrayIsArray(options.args))
    this.spawnargs = options.args;
  else if (options.args === undefined)
    this.spawnargs = [];

  const err = this._handle.spawn(options);

  // 进程生成错误,在nextTick的时候抛出
  if (/** .. */) {
    process.nextTick(onErrorNT, this, err);
  } else if (err) {
    // 关闭所有的pipe管道
    for (i = 0; i < stdio.length; i++) {
      const stream = stdio[i];
      if (stream.type === 'pipe') {
        stream.handle.close();
      }
    }
    this._handle.close(); // 关闭进程
    this._handle = null; // 回收内存
    throw errnoException(err, 'spawn'); // 抛出错误
  }
  // 忽略...
  // 建立父子进程的通讯通道
  if (ipc !== undefined) setupChannel(this, ipc, serialization);

  return err;
};

在主进程执行spawn前,会创建一个管道作为ipc通道,这个创建就在getValidStdio方法里

先看下stdio的序列化

原始的stdio可能为3个值

// 序列化stdio
function stdioStringToArray(stdio, channel) {
  const options = [];

  switch (stdio) {
    case 'ignore':
    case 'pipe': options.push(stdio, stdio, stdio); break;
    case 'inherit': options.push(0, 1, 2); break;
    default:
      throw new ERR_INVALID_OPT_VALUE('stdio', stdio);
  }
  // ...
  return options;
}

先序列化后,再对对应的stdio创建ipc通道

function getValidStdio(stdio, sync) {
  // ...stdio可能为 ignore/
  if (typeof stdio === 'string') {
    stdio = stdioStringToArray(stdio);
  }
  // 创建ipc通道
  stdio = stdio.reduce((acc, stdio, i) => {
    // ...
    if (stdio === 'ignore') {
      acc.push({ type: 'ignore' });
    } else if (stdio === 'pipe' || (typeof stdio === 'number' && stdio < 0)) {
      // ...
    } else if (stdio === 'ipc') {
      // ...
      // 创建ipc通道
      ipc = new Pipe(PipeConstants.IPC);
      ipcFd = i;

      acc.push({
        type: 'pipe',
        handle: ipc,
        ipc: true
      });
    } else if (stdio === 'inherit') {
      // ...
    } else if (typeof stdio === 'number' || typeof stdio.fd === 'number') {
      // ...
    } else if (getHandleWrapType(stdio) || getHandleWrapType(stdio.handle) ||
               getHandleWrapType(stdio._handle)) {
      // ...
    } else if (isArrayBufferView(stdio) || typeof stdio === 'string') { /** ... */} else { /** ... */}

    return acc;
  }, []);
  return { stdio, ipc, ipcFd };
}

Pipe内部构造了一个双工流,本质上这个管道跟stream是一样的模式。因此可以父子进程间互相通信 // todo.. pipe的内建模块代码阅读

在这里,node进行初始化的时候,就会判断NODE_CHANNEL_FD变量,然后建立ipc通道。这个代码在lib/internal/bootstrap/pre_execution.js

function setupChildProcessIpcChannel() {
  if (process.env.NODE_CHANNEL_FD) {
        // 获取fd
    const fd = parseInt(process.env.NODE_CHANNEL_FD, 10);
        // 建立IPC通道
    require('child_process')._forkChild(fd, serializationMode);
    assert(process.send);
  }
}

在进程初始化的时候,设置进程通道,在lib/child_process.js

function _forkChild(fd, serializationMode) {
    // 创建管道实例
  const p = new Pipe(PipeConstants.IPC);
  p.open(fd);
  p.unref();
  // 在这里设置IPC通道
  const control = setupChannel(process, p, serializationMode);
  process.on('newListener', function onNewListener(name) {
    if (name === 'message' || name === 'disconnect') control.ref();
  });
  process.on('removeListener', function onRemoveListener(name) {
    if (name === 'message' || name === 'disconnect') control.unref();
  });
}

// todo... /lib/internal/child_process.js setupChannel

在JavaScript层面,node做了这么几件事情

注意,子进程的ipcFdNODE_CHANNEL_FD标识,由child_process模块处理。cluster中,调用fork方法,不需要对这块做处理,所以ipc通道可以进行父子进程的建立。

总结

因为c++能力有限,无法继续深入探究c++层面的代码。后续把c++以及操作系统的知识补充回来,再继续深入探究