阅读 401

Node.js 异常捕获与退出机制:为什么我的程序不退出?

编写 Node.js 程序时,我们常常会遇到这样的问题:为什么我的程序就是不退出?

  • CLI 工具遇到了问题已经异常了,但仍在运行,没有返回值
  • Electron 程序明明已经关闭了窗口,但命令行一直不退出
  • 云函数 / Lambda 上的事件函数已经失败了,但就是不结束,一直执行定时任务等到超时

在 Node.js 中,这个问题很可能与产生的异常类型与对应的错误机制有关:

JavaScript 中,使用异步事件队列简化了并发事件的处理,但也增加了异常的情况和复杂度。Node.js 为了处理高并发场景和 CPU 密集型任务,引入了多线程的 Worker,进一步增加了异常的情况。另外,Node.js 中还有 uncaughtException, unhandledRejection 等 Event 来处理全局的异常事件。

总的来说,Node.js 中异常的可能性多:普通异常,Promise 内异常,定时器内异常,Worker 内异常。异常的处理方式也多种多样:try-catch 语句,全局事件,Worker 事件等。这些对应的异常都需要怎样去正确的捕获并处理,是一个有趣的问题。在实践中,这也是一个重要的问题:错误的异常捕获可能导致程序崩溃,程序崩溃后由于残留定时器不退出,程序崩溃后无法定位根因等问题。

下面,让我们分类的分析一下各个场景下的异常与它的捕获机制:

太长不看 如果你实在不希望去理解其中机制的细节,又希望你的程序能在异常时退出,那么,下面两步即可:

  1. 在所有线程的入口出加入以下代码,以处理普通异常,Promise 异常与定时器异常,并可在此集中处理:
process.on('unhandledRejection', (err) => {
	throw err;
})

process.on('uncaughtException', (err) => {
	throw err;
})
复制代码
  1. 在所有启动 Worker 处加入以下代码,以将 Worker 线程内的顶层异常在启动 Worker 的线程内抛出
const worker = new Worker(...)
worker.on('error', (err) => {
	throw err;
});
复制代码

这样做的具体原因与其中的详细机制,让我们在下面开始具体分解:

[TOC]

普通的异常情况

这里的情况都非常的普通而简单,和其他编程语言无异,但事情将在后面变得复杂,让我们在此做一些铺垫。

简单来说,就是我们直接抛出一个异常,这样的异常无疑会直接终止程序的运行,我们检测程序是否还在运行的 still running 也没有输出。

// simple.js
setInterval(() => {
    console.log('still running');
}, 500)

throw 'simple';

/*
throw 'simple';
^
simple
(Use `node --trace-uncaught ...` to show where the exception was thrown)
*/
复制代码

try-catch 与普通异常

这种情况也很普通,但还是让我们看看 try-catch 语句和简单异常组合的情况:可以看到,程序没有退出,正确运行,一切都和一般的编程语言类似。

setInterval(() => {
    console.log('still running');
}, 500)


try {
    throw 'simple';
} catch(err) {
    console.log('catch', err);
}


/*
catch simple
still running
still running
still running
still running
*/
复制代码

process.on("uncaughtException") 与普通异常

我们用 Node.js 特有的 uncaughtException 事件来试图捕获一下抛出的异常。在这种情况下,我们未捕获的异常会触发 uncaughtException 捕获,由它的 callback 进行处理,并且我们可以看到,当使用了 uncaughtException 的情况下,程序是不会退出的。

setInterval(() => {
    console.log('still running');
}, 500)


process.on('uncaughtException', (err) => {
    console.log('uncaught exception', err);
});

throw 'simple';

/*
uncaught exception simple
still running
still running
still running
still running
*/
复制代码

Promise 的异常情况

在这里,事情开始变得有些不一样了:可以看到,Promise 内的异常并不会导致程序退出,程序依旧在继续运行,而 Node.js 给出了 UnhandledPromiseRejectionWarning 的警告。当然,虽然和一般的编程语言不一致,但是这还是符合大家对 JavaScript 异常认知的。

setInterval(() => {
    console.log('still running');
}, 500);

async function main() {
    throw 'async';
}

main();

/*
(node:27996) UnhandledPromiseRejectionWarning: async
(Use `node --trace-warnings ...` to show where the warning was created)
(node:27996) UnhandledPromiseRejectionWarning: ...
still running
still running
still running
*/
复制代码

try-catch 与 Promise 异常

对上面的代码加入 try-catch 逻辑,自然也是无法捕获异常并使程序退出,因为 main 函数没有 await,在执行完成后才抛出异常

setInterval(() => { console.log('still running'); }, 500);

async function main() {
    console.log('main start');
    throw 'async';
}

try {
    main();
    console.log('main finish');
} catch (err) {
    console.log(err);
}

/*
main start
main finish
(node:15004) UnhandledPromiseRejectionWarning: async
(Use `node --trace-warnings ...` to show where the warning was created)
(node:15004) UnhandledPromiseRejectionWarning: ...
still running
still running
*/
复制代码

熟悉 Promise 机制的同学可能知道,在 Promise 内异步事件真正发生之前的逻辑是同步进行处理的,也就是说上面的代码在我们的认知中,上面代码的执行顺序应是如下:

  1. 输出 main start
  2. 抛出 async 异常
  3. 输出 main finish

但是,实际上我们可以看到,当 main startmain finish 都输出完成后,Node.js 才开始处理这个 unhandledReject 的异常,也就是说,async 函数中的 throw,对应 Promise 中对 reject 的调用,对外部来说实际被当成异步事件处理

通过下面的代码试验,给 async 函数中的 throw 加入 try-catch,我们可以发现 throw 语句世纪还是同步执行的,只是它作为 async 函数抛出的结果被延后了

setInterval(() => { console.log('still running'); }, 500);
async function main() {
   console.log('main start');
   try {
       throw 'async';
   } catch(err) {
       console.log('catch', err);
   }
}

try {
    main();
   console.log('main finish');
} catch (err) {
   console.log(err);
}
复制代码

这样做,主要是为了保证promise 中异常在不同位置抛出时,都能有一致的表现

process.on("uncaughtException") 与 Promise 异常

Node.js 目前还不支持顶层 await,那么,难道说异步事件抛出的异常,就无法令程序退出吗?这当然是不合理的,我们可能会想到使用之前的 process.on("uncaughtException") 来处理:

setInterval(() => { console.log('still running'); }, 500);

async function main() {
    throw 'async';
}

main();

process.on('uncaughtException', (err) => {
    console.log('uncaught exception', err);
})

/*
(node:16181) UnhandledPromiseRejectionWarning: async
(Use `node --trace-warnings ...` to show where the warning was created)
(node:16181) UnhandledPromiseRejectionWarning: ...
still running
still running
still running
*/
复制代码

很可惜,可以看到这样的处理是无效的,因为这样的异常是一个 unhandledRejection,而不是 uncaughtException

process.on("unhandledRejection") 与 Promise 异常

当然,这并不是没有办法的。我们可以用 unhandledRejection 来捕获这个异常事件:

setInterval(() => { console.log('still running'); }, 500);

async function main() {
    throw 'async';
}

main();

process.on('unhandledRejection', (err) => {
    console.log('unhandled exception', err);
    throw err;
})

/*
unhandled exception async

/Users/wwwzbwcom/Downloads/test/test.js:11
    throw err;
    ^
async
*/
复制代码

可以看到,在 unhandledRejection 的事件回调函数中可以进行异常的捕获,并在此处将异常再次抛出,则需要再次进行 throw

定时器回调内的异常情况

别忘了,JavaScript 中并不只 Promise 一种异步机制,还有 setTimeout 等带来的 Timer 异步事件:

setInterval(() => { console.log('still running'); }, 500);

setTimeout(() => {
    throw 'setTimeout';
}, 1000);

/*
still running

/Users/wwwzbwcom/Downloads/test/test.js:4
    throw 'setTimeout';
    ^
setTimeout
(Use `node --trace-uncaught ...` to show where the exception was thrown)
*/
复制代码

它的回调中产生的异常和 Promise 中的又不一致了,其中的异常会直接抛出并导致程序直接终止。

Promise 中定时器回调异常

Promise 和定时器的组合会有一些复杂,但也还能理解:

  • 当 Promise 内定时器回调抛出异常时,和普通异常一样,直接抛出顶层的 uncaughtException

综合来看,我们可以了解到

  • 定时器回调的运行和定时器在哪定义无关
  • 定时器中的异常机制和普通的异常机制是一样的
setInterval(() => { console.log('still running'); }, 500);

async function main() {
    setTimeout(() => {
        throw 'setTimeout in async';
    }, 1000);
}

main();

/*
still running

/Users/wwwzbwcom/Downloads/test/test.js:5
        throw 'setTimeout in main';
        ^
setTimeout in main
(Use `node --trace-uncaught ...` to show where the exception was thrown)
*/
复制代码

定时器回调中的 Promise 异常

上面说到,定时器中的异常处理与普通的异常情况一致,故在定时器回调函数中的 async 函数异常,也是抛出 unhandledRejection 的事件:

setInterval(() => { console.log('still running'); }, 500);

setTimeout(function () {
    main();
}, 1000);

async function main() {
    throw 'async in setTimeout'
}

/*
still running
(node:18741) UnhandledPromiseRejectionWarning: async in setTimeout
(Use `node --trace-warnings ...` to show where the warning was created)
(node:18741) UnhandledPromiseRejectionWarning: ...
still running
still running
*/
复制代码

Worker 内的异常情况

Node.js 的 Worker 机制加大了异常处理的复杂情况,但除了 worker 本身的特性外,还是和其他异常处理一样的逻辑,让我们将这部分梳理清晰:

Worker 内的普通异常

在 Worker 线程中抛出普通异常,我们可以看到:

  • Worker 线程停止运行
  • 主线程收到 Worker 线程发来的 error 事件
  • 主线程不停止运行
const { Worker, isMainThread } = require('worker_threads');

if (isMainThread) {
		setInterval(() => { console.log('main still running'); }, 500);
    const worker = new Worker(__filename)
    worker.on('error', (err) => {
        console.log('worker error event: ' + err);
    });
} else {
    setInterval(() => { console.log('worker still running'); }, 500);
    throw 'worker error';
}

/*
worker error event: Error: Unhandled error. ('worker error')
main still running
main still running
main still running
main still running
*/
复制代码

Worker 内的 Promise 异常

Worker 内发生 Promise 异常时,和主线程发生异步异常是类似的,Worker 线程不会终止,更不会导致程序终止

const { Worker, isMainThread } = require('worker_threads');


if (isMainThread) {
    setInterval(() => { console.log('main still running'); }, 500);
    const worker = new Worker(__filename)
    worker.on('error', (err) => {
        console.log('worker error event: ' + err);
    });
} else {
    setInterval(() => { console.log('worker still running'); }, 500);
    async function main() {
        throw 'worker async error';
    }
    main();
}

/*
(node:19866) UnhandledPromiseRejectionWarning: worker async error
(Use `node --trace-warnings ...` to show where the warning was created)
(node:19866) UnhandledPromiseRejectionWarning: ...
main still running
worker still running
main still running
worker still running
main still running
worker still running
*/
复制代码

Worker 内的定时器回调异常

这里和主线程定时器回调内发生异常也是类似的,Worker 线程不会终止,更不会导致程序终止

const { Worker, isMainThread } = require('worker_threads');


if (isMainThread) {
    setInterval(() => { console.log('main still running'); }, 500);
    const worker = new Worker(__filename)
    worker.on('error', (err) => {
        console.log('worker error event: ' + err);
    });
} else {
    setInterval(() => { console.log('worker still running'); }, 500);
    setTimeout(() => {
        throw 'worker setTimeout error'
    }, 1000);
}

/*
main still running
worker still running
main still running
worker error event: worker setTimeout error
main still running
main still running
main still running
main still running
*/
复制代码

总结

JavaScript 中的异步机制多种多样,有和其他变成语言类似的 throw 异常,还有异步事件中产生的异常等。

Node.js 的出现更引入了更多异常的情况,如 Worker 内产生的异常。也引入了更多异常的处理手段,包括 process.on("uncaughtException")process.on("unhandledRejection")

虽然这些异常的情况在对这些异步机制与异常机制有良好理解的情况下都可以进行分析和推导,但在实际开发中也常常让我们陷入混乱,希望这篇文章可以在列出各种奇怪的异常情况的同时,借助对这些情况的分析,梳理好 Node.js 异步机制与异常机制的内在逻辑。

文章分类
前端
文章标签