为什么 promise 和 setTimeout 执行时序不确定?

说明一下,我并非不知道正常的用法,也知道这两种添加异步代码的方式是不同的(很多人提到的本轮event loop 和下一轮的问题)。
我希望了解到的是为什么运行结果存在不同的可能,因为延时都是0 ms(按照规范也就是默认的浏览器内置的最小间隔 k ms)。

至于截图,已补。

代码如下:

let p = new Promise((res) => {
    setTimeout(() => {
        res(233)
    }, 0)
})

let another = p.then(() => {
    let a = 'then 1'
    setTimeout(() => {
        console.log(a)
    }, 0)
}).then(() => {
    console.log('then 2')
})

setTimeout(() => {
    console.log('time')
}, 0)

// time
// then 2
// then 1

// 或者

// then 2
// time
// then 1

运行环境:node.js v6.11.2
系统:win10 专业版

图片描述

图片描述

阅读 6.8k
8 个回答

其实直接拿setTimeout0做测试是不严谨的,0的时间是不能确定的,js代码每次执行花费的时间也是不确定的。有setTimeout0的时候,我一般会在这一层的最后加一个

function sleep(time) {
  let startTime = new Date()
  while (new Date() - startTime < time) {}
  console.log('1s over')
}

确保在同步代码执行完毕后,setTimeout确实被触发了。


顺便纠正一个别的答案的关于then的连续调用的问题。
then中没有返回值时,会自动创建一个promise,并且终态为调用then的promise的终态,也就是
.then().then().then().....可以一直这么写下去。


具体分析

当前分析仅针对当前正式版本node 8.x,较低版本和未来版本不做保证。

先假设你已经了解了,node中事件循环的几个阶段,如果不了解的话可以看看浏览器和Node不同的事件循环(Event Loop),我们以这几个阶段为基础说下分析。

摘要:

  • new Promise本身的内部代码并不是异步任务,其resolve和reject触发的then和catch才是。
  • node端的事件循环是分阶段的。

我们先删去引起问题的timeout,看一下其他的流程

let p = new Promise((res) => {
  setTimeout(() => {
    console.log('timeout-promise')
    res()
  }, 0)
})
let another = p.then(() => {
    // then1
    setTimeout(() => {
      console.log('timeout-then')
    }, 0)
}).then(() => {
    // then2
    console.log('then.then')
})

以确确实实开始执行的事件循环作为列举出的循环(还会有别的没有执行但是在等待的循环,会因为第一个timer的到来儿结束,进入下轮循环)

  1. 同步代码
  2. 第一轮循环

    • 在timer阶段,timeout-promise此时已经触发,已被加入Timers Queue,执行输出timeout-promise,then1加入Microtask Queue
    • 执行Microtask Queue,timeout-then被创建,then2加入Microtask Queue,继续清空Microtask Queue,输出then.then
  3. 第二轮循环

    • 此时timeout-then已经触发,已被加入Timers Queue,在timer阶段,被执行,输出timeout-then

所以输出是:
// timeout-promise// then.then <-第一轮// timeout-then <-第二轮
可以保证的是这三个顺序是确定的。

我们再来看一段代码

let p = new Promise((res) => {
  setTimeout(() => {
    console.log('timeout-promise')
    res()
  }, 0)
})
let another = p.then(() => {
    setTimeout(() => {
      // timeout2
      console.log('timeout-then')
    }, 0)
})
setTimeout(() => {
  console.log('timeout-out')
}, 0)

按照node的事件循环机制,我们依然以确确实实开始执行的事件循环作为列举出的循环。
由于setTimeout的0延迟实际肯定不为0,js代码每次执行花费的时间也是不确定的,这两种不确定性,导致了我们不知道setTimeout被添加到Timers Queue队列的具体时刻:

  1. 第一轮循环,Timers Queue里是肯定有timeout-promise的,那么这个时候Timers Queue里的后面有没有timeout-out呢,可能前面的代码比较慢,已经触发,所以被加进来了?
  2. 第二轮循环,Timers Queue里肯定是有timeout-then的,那么timeout-out是在第一轮的队列里还是这这个的队列里的最前面呢,可能前面的代码比较快,导致第二轮循环之前才被触发,才被加进来?

不管怎么样,我们可以确定的是,三个timeout的输出顺序是:
//timeout-promise <-第一轮// timeout-out <-第?轮// timeout-then <-第二轮
所以我才讲timeout之间的执行先后顺序是百分百确定的。

总结

结合上面的两种情况,有两个顺序是确定的:
// timeout-promise// then.then <-第一轮// timeout-then <-第二轮
//timeout-promise <-第一轮// timeout-out <-第?轮// timeout-then <-第二轮
把你的两种结果拆开来看,也是符合这个顺序的。timeout-out的位置的不确定才导致了出现了两种情况。
所以我才讲需要加一个sleep函数,来确保,同步代码完成后,已经创建的两个setTimeout0都已经被触发,被加入到了第一轮循环的Timers Queue中。

其他示例

  1. 一个setTimeout和一个setImmediate
    我们知道在同一轮循环中,setTimeout执行的阶段比setImmediate执行的阶段要靠前,但是这段代码

    setTimeout(()=>{console.log('out')}, 0)
    setImmediate(()=>{console.log('Immediate')})

    基本上有对半的几率是先Immediate,这就是因为setTimeout在第二轮循环才被加入了Timer Queue队列中,有两个解决办法:

    1. 可以在最后加上长时间的sleep,使进入循环时,确保两个事件都被触发,都在第一轮循环,这个很靠谱。
    2. 甚至可以在后面多写几行代码,也能一定程度上确保两个事件都被触发,这个方法就像setTimeout的0一样不太靠谱。
  2. 很多setTimeout和一个setImmediate

    for(let i = 0; i < 20; i++) {
      setTimeout(()=>console.log(i), 0);
    }
    setImmediate(() => {console.log('我是第一轮')})

    你可以看到这个我是第一轮可能会出现在任意位置。
    immediate位置

promisesetTimeout是有顺序的,分了macro-task 机制和 micro-task 机制,promise永远比setTimeout先调用

以下是按我个人理解精简过的你的代码

setTimeout(function () {
  console.log('time 1');
  Promise.resolve().then(function () {
    console.log('then 1');
  });
}, 100);
setTimeout(function () {
  console.log('time 2');
}, 100);
//time 1  time 2  then 1
//or
//time 1  then 1  time 2

问题就是 两个定期器有时是同时执行有时是分别执行,也就是说node每次去取的并不是一个定时器
再看下面代码

for (var i = 0; i < 100; i++) {
  setTimeout(function (i) {
    console.log('time %d', i);
    Promise.resolve().then(function () {
      console.log('then %d', i);
    });
  }, 0, i);
}

我之前以为是每次去异步队列里面取一个放入主线程调用然后在触发下一次轮询,也就是time 0 then 0 time 1 then 1 ... time 99 then 99 ,实际上浏览器是这样的,但是在node中不是
上例中大部分请求都是 time 0~99 then 0~99
但偶尔有特殊请求 比如 time 0 then 0 time 1~99 then 1~99或者 time 0~46 then 0~46 time 47~99 then 47~99或者其他情况
也就是说node是在批量调用异步队列的时候不一定是一次取完,像上例偶尔会分两次

@toBeTheLight 我还是不太明白,希望与你继续讨论:

let p = new Promise((res) => {
  // 1
  setTimeout(() => {
  // 2
  }, 0)
})
let another = p.then(() => {
    // 3
    setTimeout(() => {
        // 4
    }, 0)
}).then(() => {
    // 5
})
// 6
setTimeout(() => {
  // 7
}, 0)

我们看我标记的这几处关键位置。1是在Promise回调里面,所以属于同步代码,最先执行,因此将2这个setTimeout任务添加到事件队列里面。

OK,2、3、4、5先不说,因为又隔了一层或两层setTimout,肯定要放到第二轮循环里面了,因此暂不考虑。下面主要看6,因为它是同步代码,所以应该立即执行的,于是把7这个setTimeout任务添加到事件队列里面去。

现在1和6已执行完毕,事件循环队列里现在有两个任务2和7,它们都是setTimeout任务。等到下一轮事件循环,应该依次执行,因此是2先执行,将该Promise对象resolve,然后是7,打印“time”。

到此为止,7已经执行完毕了,打印出了time。接下来才是两个then的执行,过程比较简单,就不分析了。所以最后应该是time->then 2->then 1。而这也跟我实际测试的结果一致。

所以我认为,虽然Node的事件循环比浏览器的要复杂一点,但是这里并不会出现你说的问题。因为每一轮循环的执行顺序都具有明确的优先级,不会有不确定的事情发生。

看阮一峰的博客,他举了一个会出现不确定性的例子:

setTimeout(() => console.log(1));
setImmediate(() => console.log(2));

这个例子可能会打印1,2,也可能是2,1,这是可以理解的,因为setTimeoutsetImmediate是属于同一级别的异步任务,但是在一轮循环里面的位置不同,而定时器的0秒并不是真正的0,所以孰先孰后要看运气。但是题主这个程序,执行顺序应该是确定一定以及肯定的才对。

以上。如有问题欢迎指出。

可以参考下阮一峰老师的 Node 定时器详解

1.本轮循环一定早于次轮循环执行
2.Node 规定,process.nextTick和Promise的回调函数,追加在本轮循环,即同步任务一旦执行完成,就开始执行它们。而setTimeout、setInterval、setImmediate的回调函数,追加在次轮循环。

原问题的回答

在同一次循环中,Promise是优先的,楼主的代码不是PromisesetTimeout执行顺序不确定,是Promise里的setTimeout的回调和不被Promise封装的setTimeout的回调执行顺序不确定,我的意思是说例子有点问题,事实上在Promiseconsole.log是肯定会在前面的。

补充问题的补充回答

以下为lib/timer源码的注释

// ╔════ > Object Map
// ║
// ╠══
// ║ refedLists: { '40': { }, '320': { etc } } (keys of millisecond duration)
// ╚══ ┌─────────┘
// │
// ╔══ │
// ║ TimersList { _idleNext: { }, _idlePrev: (self), _timer: (TimerWrap) }
// ║ ┌────────────────┘
// ║ ╔══ │ ^
// ║ ║ { _idleNext: { }, _idlePrev: { }, _onTimeout: (callback) }
// ║ ║ ┌───────────┘
// ║ ║ │ ^
// ║ ║ { _idleNext: { etc }, _idlePrev: { }, _onTimeout: (callback) }
// ╠══ ╠══
// ║ ║
// ║ ╚════ > Actual JavaScript timeouts
// ║
// ╚════ > Linked List

用人话说就是

// 10/100/1000分别为延迟10/100/1000毫秒后执行
refedLists = {
10: item<->item<->item<->item<->item,
100: item<->item<->item<->item<->item,
1000: item<->item<->item<->item<->item
......
}

我的理解是,这个问题其实都是由于例子不惬当引起的。
@代码宇宙 想说的是timer既然是链表,那么顺序是不是一定的,答案是是的。
那么为什么执行顺序不一样呢,我简化一下代码。

let p = new Promise((res) => {
    setTimeout(() => {
        console.log('promise 1')
        //新创建的timer一定在前面
        res()
        //...其他同步代码
    }, 0)
})

p.then(() => {
    //这是promise的回调 并不在timer中 所以如果不是同一轮 出现在哪都有可能
    console.log('then 2')
})


setTimeout(() => {
    console.log('time')
}, 0)



// promise 1
// time
// then 2

// 或者

// promise 1
// then 2
// time

不知道我有没有说错

浏览器跟 node 的 event loop 是不一样的,因为它们处理的领域不一样。

whatwg 定义中,每处理完一个 task (从任意 task 队列中取最旧的)就要清空 micro tasks 队列,这导致了 then2time 要早。

而 node 中,到期的 timers 是在 poll 阶段一并处理。而 micro task 队列是在 next tick 队列之后才处理,也就是要等当前阶段(poll)的 tasks 处理完毕。所以 p 产生的 micro tasks 是在 time 之后才被处理。

可以这么观察

var p = new Promise((resolve) => {
  console.log('233 started')
    setTimeout(() => {
        resolve(console.log('233 resolved'))
    }, 0)
})

var another = p.then(() => {
    console.log('then1 started')
    setTimeout(() => {
        console.log('then1 resolved')
    }, 0)
}).then(() => {
    console.log('then2 resolved')
})

console.log('timer started')
setTimeout(() => {
    console.log('timer resolved')
}, 0)

因为promise和setTimeout都是异步的,当然就没有固定的执行顺序。
但promise中的then一定是先执行前一个then的“同步”部分的代码,再执行下一个then的同步部分的代码。
比如,楼主的代码如果改成:

let p = new Promise((res) => {
    setTimeout(() => {
        res(233)
    }, 0)
})

let another = p.then(() => {
    let a = 'then 1'
    console.log(a);
    // setTimeout(() => {
    //     console.log(a)
    // }, 0)
}).then(() => {
    console.log('then 2')
})

setTimeout(() => {
    console.log('time')
}, 0)

则 then1 一定会出现在 then2 之前。

嗯,应该是用法不对!Promise里的异步需要加上res/rej一起,顺序才能达到你想要的:

function p1(){
    return new Promise((res,rej) => {
        let flag = Math.random()*10;
        setTimeout(() => {
            if(flag > 1){
                console.log('p1')
                res('success p1');
            }else {
                rej('fails p1');
            }
        },1000);
    });
} 

function p2(){
    return new Promise((res,rej) => {
        let flag = Math.random()*10;
        setTimeout(() => {
            if(flag > 1){
                console.log('p2');
                res('success p2');
            }else {
                rej('fails p2');
            }
        },1000);
    });

}  

let p = new Promise((res,rej) => {
    let flag = Math.random()*10;
    setTimeout(() => {
        if(flag > 1){
            console.log('p');
            res('success p');
        }else {
            rej('fails p');
        }
    },1000);
})

p.then(p1).then(p2).then((str) => {
    console.log(str);
}).catch((str)=>{
    console.log(str);
})

多步then的话,前面的then应该返回Promise的,不然后面就不要再then了

推荐问题
宣传栏