Promise.resolve与返回新Promise的区别

8
以下代码片段,我想了解NodeJS运行时如何处理它:
const billion = 1000000000;

function longRunningTask(){
    let i = 0;
    while (i <= billion) i++;

    console.log(`Billion loops done.`);
}

function longRunningTaskProm(){
    return new Promise((resolve, reject) => {
        let i = 0;
        while (i <= billion) i++;

        resolve(`Billion loops done : with promise.`);
    });
}

function longRunningTaskPromResolve(){
    return Promise.resolve().then(v => {
        let i = 0;
        while (i <= billion) i++;

        return `Billion loops done : with promise.resolve`;
    })
}


console.log(`*** STARTING ***`);

console.log(`1> Long Running Task`);
longRunningTask();

console.log(`2> Long Running Task that returns promise`);
longRunningTaskProm().then(console.log);

console.log(`3> Long Running Task that returns promise.resolve`);
longRunningTaskPromResolve().then(console.log);

console.log(`*** COMPLETED ***`);

第一种方法:

longRunningTask()函数将阻塞主线程,这是预期的。

第二种方法:

longRunningTaskProm()中,将相同的代码封装在Promise中,期望执行将移动到主线程之外,并作为微任务运行。但似乎并非如此,想要了解背后发生了什么。

第三种方法:

第三种方法longRunningTaskPromResolve()有效。

这是我的理解:

创建和执行Promise仍然与主线程挂钩。只有Promise被解析后的执行会作为微任务移动。

我对我找到的所有资源及自己的理解都不太满意。


请注意,Node.js即使使用promises也会在单个线程中执行您的代码。当我阅读有关此问题的文章时,我发现这篇文章非常有趣 => https://blog.insiderattack.net/promises-next-ticks-and-immediates-nodejs-event-loop-part-3-9226cbe7a6aa希望对您有所帮助。 - Yak O'Poe
微任务可以异步运行,但这并不意味着它们会“移出主线程”。它只是在队列中安排执行 - 在一个环境中的所有执行都是单线程的。 - Bergi
没错,new Promise的回调函数就是创建Promise。没有Promise的“执行”,Promise不会“运行”或者是“可执行”的东西。 - Bergi
1个回答

8
这三个选项都在主线程中运行代码并阻塞事件循环。它们在何时开始运行while循环代码以及何时阻塞事件循环方面略有不同,这将导致它们的运行时间与某些控制台消息不同。
第一和第二个选项会立即阻塞事件循环。
第三个选项从下一个Tick开始阻塞事件循环 - 就是Promise.resolve().then()调用你传递给.then()的回调函数时(在下一个Tick上)。
第一个选项只是纯同步代码。不出所料,它会立即阻塞事件循环,直到while循环完成。
在第二个选项中,新的Promise执行者回调函数也会同步调用,因此它也会立即阻塞事件循环,直到while循环完成。
在第三个选项中,它调用:
Promise.resolve().then(yourCallback);
Promise.resolve() 创建一个已经resolved的promise,并在这个新的promise上调用.then(yourCallback)。这将安排yourCallback在事件循环的下一个tick中运行。根据Promise规范,即使promise已经resolved,.then()处理程序也总是在未来的事件循环tick上运行。
与此同时,紧随其后的任何其他JavaScript都会继续运行,只有当该JavaScript完成时,解释器才能进入下一个事件循环tick并运行yourCallback。但是,当它运行回调时,它在主线程中运行,因此会阻塞直到完成。
创建和执行Promise仍然挂钩在主线程上。只有Promise resolved的执行被移动为微任务。
你示例中的所有代码都在主线程中运行。一个.then()处理程序被安排在未来的事件循环tick(仍然在主线程中)运行。这个安排使用了一个微任务队列,它允许它在事件队列中某些其他内容之前执行,但它仍然在主线程中运行,并且它仍然在未来的事件循环tick上运行。
另外,“执行Promises”的短语有点不准确。Promises是一种通知系统,您可以使用.then().catch().finally()在promise上注册回调函数,在未来的某个时候运行它们。因此,一般来说,您不应该考虑“执行Promise”。您的代码执行导致创建了一个Promise,然后您在该Promise上注册回调函数,以根据该Promise的情况在将来运行它们。Promises是一种专门的事件通知系统。
Promises帮助在事情完成或需要调度任务时通知您,而不是将任务移动到另一个线程。
作为说明,您可以在第三个选项之后插入setTimeout(fn, 1),并查看超时是否被阻止运行直到第三个选项完成。以下是一个示例。我将所有阻止的循环都设置为1000ms,这样您就可以更容易地看到。在浏览器中运行此代码或将其复制到node.js文件中并在那里运行,以查看setTimeout()如何被longRunningTaskPromResolve()的执行时间阻塞,因此longRunningTaskPromResolve()仍然是阻塞的。将其放在.then()处理程序内会改变它运行的时间,但它仍然会阻塞。

const loopTime = 1000;

let startTime;
function log(...args) {
    if (!startTime) {
        startTime = Date.now();
    }
    let delta = (Date.now() - startTime) / 1000;
    args.unshift(delta.toFixed(3) + ":");
    console.log(...args);
}

function longRunningTask(){
    log('longRunningTask() starting');
    let start = Date.now();
    while (Date.now() - start < loopTime) {}

    log('** longRunningTask() done **');
}

function longRunningTaskProm(){
    log('longRunningTaskProm() starting');
    return new Promise((resolve, reject) => {
        let start = Date.now();
        while (Date.now() - start < loopTime) {}
        log('About to call resolve() in longRunningTaskProm()');
        resolve('** longRunningTaskProm().then(handler) called **');
    });
}

function longRunningTaskPromResolve(){
    log('longRunningTaskPromResolve() starting');
    return Promise.resolve().then(v => {
        log('Start running .then() handler in longRunningTaskPromResolve()');
        let start = Date.now();
        while (Date.now() - start < loopTime) {}
        log('About to return from .then() in longRunningTaskPromResolve()');
        return '** longRunningTaskPromResolve().then(handler) called **';
    })
}


log('*** STARTING ***');

longRunningTask();

longRunningTaskProm().then(log);

longRunningTaskPromResolve().then(log);

log('Scheduling 1ms setTimeout')
setTimeout(() => {
    log('1ms setTimeout Got to Run');
}, 1);

log('*** First sequence of code completed, returning to event loop ***');

如果您运行此代码段并查看每个消息输出的确切时间和与每个消息相关的时间,则可以看到执行顺序的准确序列。

这是我在node.js中运行时的输出情况(添加了行号以帮助下面的解释):

1    0.000: *** STARTING ***
2    0.005: longRunningTask() starting
3    1.006: ** longRunningTask() done **
4    1.006: longRunningTaskProm() starting
5    2.007: About to call resolve() in longRunningTaskProm()
6    2.007: longRunningTaskPromResolve() starting
7    2.008: Scheduling 1ms setTimeout
8    2.009: *** First sequence of code completed, returning to event loop ***
9    2.010: ** longRunningTaskProm().then(handler) called **
10   2.010: Start running .then() handler in longRunningTaskPromResolve()
11   3.010: About to return from .then() in longRunningTaskPromResolve()
12   3.010: ** longRunningTaskPromResolve().then(handler) called **
13   3.012: 1ms setTimeout Got to Run

以下是逐步说明:
1. 开始执行。 2. 启动 longRunningTask()。 3. longRunningTask() 完成。这是一个完全同步的过程。 4. 启动 longRunningTaskProm()。 5. longRunningTaskProm() 调用 resolve()。从这个调用可以看出,Promise 执行函数(传递到 new Promise(fn) 的回调)也是完全同步的。 6. 启动 longRunningTaskPromResolve()。可以看到,longRunningTaskProm().then(handler) 中的处理程序尚未被调用。它已经安排在下一个事件循环的 tick 上运行,但由于我们还没有回到事件循环,因此尚未被调用。 7. 此时设置了 1 毫秒的计时器。请注意,此计时器与我们启动 longRunningTaskPromResolve() 仅相隔 1 毫秒。这是因为 longRunningTaskPromResolve() 还没有做太多事情。它运行了 Promise.resolve().then(handler),但所有这些只是安排了 handler 在将来的事件循环 tick 上运行。因此,安排这个只需要 1 毫秒。长时间运行的部分尚未开始运行。 8. 完成了这一系列代码,并返回到事件循环。 9. 在事件循环中安排要运行的下一件事是 longRunningTaskProm().then(handler) 中的处理程序,因此该处理程序被调用。可以看到,它已经等待运行,因为它仅在我们返回到事件循环后 1 毫秒就运行了。该处理程序运行后又返回到事件循环。 10. 在事件循环中安排要运行的下一件事是 Promise.resolve().then(handler) 中的处理程序,因此我们现在可以看到它开始运行,并且由于已经排队,所以它立即在上一个事件完成后运行。 11. longRunningTaskPromResolve() 中的循环需要精确地执行 1000 毫秒,然后从其 .then() 处理程序返回,该处理程序将下一条 .then() 处理程序在下一个事件循环 tick 上运行。 12. 该 .then() 得以运行。 13. 最后,当没有 .then() 处理程序安排运行时,setTimeout() 回调函数得以运行。它被设置为在 1 毫秒后运行,但是由于所有的 Promise 操作都以较高的优先级运行导致它被延迟了,因此它实际运行的时间是 1004 毫秒。

非常感谢@jfriend00提供详细的解释。为简化起见,只有Promise和执行函数会在第一次迭代中直接在主线程上运行。通过事件循环,已解决的回调将恢复在主线程上的执行。 - BeingSuman
跟进问题,传统上我们会这样写代码:async getUser(){return new Promise((resolve, reject) => { let data = await http.get(GET_API_URL); resolve(data);})} 这与我们的第二种情况类似,这是否意味着带有 API 调用的执行函数将挂起主线程,直到我们从 API 获取数据? - BeingSuman
1
@BeingSuman - 这不是编写代码的传统方式,事实上,这是错误的编写代码的方式。我无法确定您在使用executor函数时究竟想要什么,但您不应该在executor函数中使用await。在JS中,await从来不会挂起主线程,它只是暂停该函数本身,所有其他事件仍然可以运行。如果您想更好地说明/描述一个示例,也许您应该编写一个新的问题,并显示完整的问题。 - jfriend00
@Kaiido - 你在这里进行了一个语义论证。在它们被处理之前,堆栈完全展开,并且等待运行其.then()处理程序的其他承诺可能会先执行(进一步复杂化事情)。我认为你的语义论点与这个答案的要点无关,试图在这里解释所有这些将使答案变得2-3倍长(已经相当长了),并且可能会失去3/4的受众。如果您对几个单词有具体建议,可以更清楚地表达这一点,并且不会让人困惑,我会考虑的。 - jfriend00
简单地说,“在当前任务执行后”既简短又正确,我想对于任何具有最基本领域知识的观众来说都很容易理解。这不仅是语义上的问题,Node的事件循环仍然非常简单,但如果你考虑HTML的事件循环,有许多事情应该在单个迭代中发生,这些同步微任务检查点将被阻塞,这是一个非常重要的区别。我们经常看到人们期望UI不会冻结,因为他们做了doSynchronous1().then( doSynchronous2 ).then( doSynchronous3 )... - Kaiido
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接