区分 Node.js 子进程的 exit 和 close 事件

2,218 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情

在 Node.js 中,通过 spawn 可以创建子进程,返回一个 child_process 对象,该对象上面提供了很多的事件,例如:

  • close
  • error
  • exit
  • message
  • disconnect

这里比较容易搞混的就是 closeexit 的区别,从字面上理解一个叫做「关闭」,一个叫做「退出」,那你可能会问:

  • 进程退出了就意味着关闭了吗?
  • 进程是先关闭还是先退出呢?

今天笔者就遇到了一个案例:一个通过 spawn 创建的子进程,只监听了 close 事件,没有监听 exit 事件,发现进程已经退出了,但 close 事件迟迟没有触发。

于是想就刨根问底把这个事情搞明白,就先去查阅了官方文档,发现官方文档里面有这么一句话,差点把我给误导了:

The closeevent will always emit after exitwas already emitted or errorif the child failed to spawn

可能是我英文不好,第一次读的时候以为:exiterror事件触发之后一定会触发 exit事件。

其实并不是这样,它这句话主要想表达这三个事件发生的次序:即 close永远在 exiterror之后触发。但是并不意味着发生了 exiterror事件就必然有 close事件!

听着感觉有点绕,没关系,我们用案例来研究一下:

const cp = require('child_process').spawn('sleep', ['100'])
cp.on('exit', console.log.bind(console, 'exited'))
cp.on('close', console.log.bind(console, 'closed'))
setTimeout(function () {
  console.log('Going to kill')
  cp.kill()
}, 500)

得到的结果是:

Going to kill
exited null SIGTERM
closed null SIGTERM

可以看到,在 kill 之后,先触发了 exit 事件,紧接着触发了 close 事件,一切正常。我们把代码改一下,将子进程的 stdout 重定向到另外一个子进程的 stdin 里面:

const cp = require('child_process').spawn('yes')
cp.on('exit', console.log.bind(console, 'exited'))
cp.on('close', console.log.bind(console, 'closed'))

const cpNext = require('child_process').spawn('cat')
cp.stdout.pipe(cpNext.stdin)

setTimeout(function () {
  console.log('Going to kill')
  cp.kill()
}, 500)

此时得到的结果是:

Going to kill
exited null SIGTERM

有没有觉得很奇怪,此时进程已经被 kill 掉了,但是并没有触发 close 事件,这是为什么呢?其实官方文档中也给出了明确的解释:

The closeevent is emitted after a process has ended and the stdio steams of child process have been closed.

即只有当进程退出,且输入输出流关闭了之后才会触发 close事件。我们把上面的代码改造一下:

const cp = require('child_process').spawn('yes')
cp.on('exit', console.log.bind(console, 'exited'))
cp.on('close', console.log.bind(console, 'closed'))

const cpNext = require('child_process').spawn('cat')
cp.stdout.pipe(cpNext.stdin)

setTimeout(function () {
  console.log('Going to kill')
  cp.kill()

  setTimeout(function () {
    cp.stdout.destroy() // 关闭了 stdout 之后,才会触发 close 事件
  }, 500)
}, 500)

过了 500 毫秒之后,触发了 close 事件:

Going to kill
exited null SIGTERM
closed null SIGTERM

你可能会问,如果不 destroy 掉子进程的 stdout,而是 kill 另外一个接收输入的子进程,会是什么结果呢?

const cp = require('child_process').spawn('yes')
cp.on('exit', console.log.bind(console, 'exited'))
cp.on('close', console.log.bind(console, 'closed'))

const cpNext = require('child_process').spawn('cat')
cp.stdout.pipe(cpNext.stdin)

setTimeout(function () {
  console.log('Going to kill')
  cp.kill()

  setTimeout(function () {
    cpNext.kill()
  }, 500)
}, 500)

此时会报错:

Going to kill
exited null SIGTERM
node:events:368
      throw er; // Unhandled 'error' event
      ^

Error: write EPIPE
    at WriteWrap.onWriteComplete [as oncomplete] (node:internal/stream_base_commons:98:16)
Emitted 'error' event on Socket instance at:
    at Socket.onerror (node:internal/streams/readable:773:14)
    at Socket.emit (node:events:390:28)
    at emitErrorNT (node:internal/streams/destroy:157:8)
    at emitErrorCloseNT (node:internal/streams/destroy:122:3)
    at processTicksAndRejections (node:internal/process/task_queues:83:21) {
  errno: -32,
  code: 'EPIPE',
  syscall: 'write'
}

write EPIPE 的意思是:当你尝试写入一个读端已经被关闭的管道时,就会出现这个错误。虽然进程已经退出,但是管道还在,因此正确的方式为在 kill 之前,销毁输入输出流:

const cp = require('child_process').spawn('yes')
cp.on('exit', console.log.bind(console, 'exited'))
cp.on('close', console.log.bind(console, 'closed'))

const cpNext = require('child_process').spawn('cat')
cp.stdout.pipe(cpNext.stdin)

setTimeout(() => {
  console.log('Going to kill')
  cp.stdout.destroy()
  cp.kill()
  setTimeout(() => {
    cpNext.stdin.destroy()
    cpNext.kill()
  }, 500)
}, 500)

到这里,相信大家已经看明白了,触发了 exit 事件并不意味着一定会触发 close 事件。所以在选择监听事件的时候,如果只是关心进程是否存在,只需要监听 exit 事件即可,如果不关心进程是否存在,而是关心输入输出,则需要监听 close 事件。