Skip to content

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

@EasonYou

Description

@EasonYou
Owner

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个值

  • ignore,不需要对数组做处理,直接返回一个空数组。在后续处理会处理成['ignore', 'ignore', 'ignore']
  • pipe,相当于 ['pipe', 'pipe', 'pipe']
  • inherit - 相当于 ['inherit', 'inherit', 'inherit'][0, 1, 2]
// 序列化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做了这么几件事情

  • 生成了一个包裹函数,用于生成子进程
  • 生成子进程,如果错误,返回其结果
  • 如果有错,直接退出子进程,并关闭所有的流通道以及回收实例内存
  • 如果没出错且ipc不为空的情况下,建立父子进程的通讯通道

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

总结

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @EasonYou

        Issue actions

          【node源码】child_process源码阅读 · Issue #13 · EasonYou/my-blog