JS 事件循环 event loop,前端烧脑一刻

2,020 阅读5分钟

本文不保证能说明透彻,因为它本来就存在着混乱,但力求讲到点子上。

比较下面这几个的执行顺序

  • setTimeout
  • setInterval
  • setImmediate (nodejs 支持,浏览器不适合使用
  • new Promise(cb)promise.then(cb)(promisePromiose 的实例)
  • process.nextTick(nodejs)

还有一些另外的(暂时不考虑)

分类

同步执行

new Promise(cb) cb 代码会同步执行,其实不属于考虑范畴了

microTask 微任务

  • process.nextTick tickTask
  • promise.then(cb) microTask

microTask (在 nodejs)可以进一步划分为 tickTask 和 microTask,都是微任务队列,同类型的微任务先进先执行

优先级是 tickTask > microTask

macroTask 宏任务

  • setTimeout
  • setInterval 优先级同 setTimeout,谁先被推到 timers 队列谁就先执行
  • setImmediate

在不同类型宏任务切换的间隙,一旦微任务队列有任务则会把微任务队列先执行完,然后继续执行下一个类型的宏任务队列。(注意是切换的时候,如果已经进入执行阶段是让该类型的宏任务执行完然后检查微任务队列,如果宏任务执行时又加入了同类型的宏任务,则会在下一个循环里面执行) 在同一个事件循环里面,microTask 永远比 macroTask 中的任务先执行,而且 microTask 在本次循环全部执行完。

进入 timers 或者 check 或者其他的宏任务队列时,如果 microTask 任务队列中没有任务,则会在执行完优先执行的宏任务队列之后再检查 microTask 任务队列(注意:如果某个 macroTask 执行时推入了新的 microTask,它依然会先把这个类型的宏任务队列执行完,然后切换下一个类型宏任务队列时先执行微任务),如果有则执行完 microTask,然后进入下一个类型的 macroTask 队列(这个过程可能会比较陌生,可以通过测试得到结果) 一次只会从 macroTask 队列里面拿出一个执行,执行完就执行 microTask 去了。

这里有一个非确定情况,setImmediatesetTimeout 的执行顺序在 nodejs 中不是固定的(nodejs 开发者这么说)。

For example, if we run the following script which is not within an I/O cycle (i.e. the main module), the order in which the two timers are executed is non-deterministic, as it is bound by the performance of the process

做个测试,下面的执行是不确定的,没有实际的意义

setTimeout(() => {
  console.log('1 setTimeout')
  setTimeout(() => {
    console.log('2 setTimeout')
  }, 0)
  setImmediate(() => {
    console.log('3 setImmediate')
  })
}, 0)
setImmediate(() => {
  console.log('4 setImmediate')
})

nodejs

可能
4 setImmediate
1 setTimeout
3 setImmediate
2 setTimeout

也可能
1 setTimeout
4 setImmediate
3 setImmediate
2 setTimeout

setTimeout 有一个隐形前提,它的第二个参数,也就是延迟执行的时间,最小是 4ms,即使指定的 0,另外注意它是 n ms 之后才可能执行,并不是 n ms 时就会执行,它的执行时间是不确定的,只能知道在 n ms 之前它不会执行。

setInterval 回调内如果是一个 while 循环,即使时间设定的 0,它也不会推无限多个回调到 timers 队列中,而是要等到这个执行完,才会把下一个回调推入 timers 队列,用 while 执行 2S 之后,清除掉 interval,发现回调只执行了一次,而不是执行很多次。

看下面的例子,这说明 setInterval 会推一个回调到 timers 队列,然后执行,然后再推下一个回调。

let count = 0
setTimeout(() => {
  console.log('1 setTimeout')
}, 0)
const i = setInterval(() => {
  console.log('2 setInterval')
  count++
  if (count === 5) {
    clearInterval(i)
  }
  setTimeout(() => {
    console.log('3 setTimeout ', count)
  }, 0)
}, 0)
setTimeout(() => {
  console.log('4 setTimeout')
}, 0)

---

1 setTimeout
2 setInterval
4 setTimeout
3 setTimeout  1
2 setInterval
3 setTimeout  2
2 setInterval
3 setTimeout  3
2 setInterval
3 setTimeout  4
2 setInterval
3 setTimeout  5

nodejs 事件循环

   ┌───────────────────────────┐
┌─>│           timers          │ setTimeout setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │ I/O 除了另外几种之外的几乎所有回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │ 内部使用(忽略)
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │ setImmediate
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │ 类似 socket.on('close', ...) 的 close 回调
   └───────────────────────────┘

上述基本都是 macroTask 宏任务

setTimeout(() => {
  console.log('1 setTimeout')
  setTimeout(() => {
    console.log('2 setTimeout')
  }, 0)
  setImmediate(() => {
    console.log('3 setImmediate')
  })
  setTimeout(() => {
    console.log('4 setTimeout')
  }, 0)
  setImmediate(() => {
    console.log('5 setImmediate')
  })
}, 0)

这个结果说明执行过程是整个队列执行完再执行下一个队列下面结果说明在同一事件循环内, check 队列会执行完之后再去执行 timers 队列。(check 未必比 timers 快)

1 setTimeout
3 setImmediate
5 setImmediate
2 setTimeout
4 setTimeout

如果在 check 队列执行期间推入 microTask 任务,那就先让当前 check 队列执行完,然后再执行 microTask,再执行 timers 队列。

setTimeout(() => {
  console.log('1 setTimeout')
  setTimeout(() => {
    console.log('2 setTimeout')
  }, 0)
  setImmediate(() => {
    console.log('3 setImmediate')
    new Promise(res => res()).then(() => {
      console.log('4 promise')
    })
    process.nextTick(() => {
      console.log('5 nextTick')
    })
  })
  setTimeout(() => {
    console.log('6 setTimeout')
  }, 0)
  setImmediate(() => {
    console.log('7 setImmediate')
  })
}, 0)

执行结果,两个 setImmediate 被放到 check 队列,check 队列中的 setImmediate 要先全部执行完,然后再下一步,而下一步过程中 microTask 就会执行。

1 setTimeout
3 setImmediate
7 setImmediate
5 nextTick
4 promise
2 setTimeout
6 setTimeout

注意下面代码和上面代码的区别

setTimeout(() => {
  console.log('1 setTimeout')
  setTimeout(() => {
    console.log('2 setTimeout')
  }, 0)
  setImmediate(() => {
    console.log('3 setImmediate')
  })
  new Promise(res => res()).then(() => {
    console.log('4 promise')
  })
  process.nextTick(() => {
    console.log('5 nextTick')
  })
  setTimeout(() => {
    console.log('6 setTimeout')
  }, 0)
  setImmediate(() => {
    console.log('7 setImmediate')
  })
}, 0)

---

1 setTimeout
5 nextTick
4 promise
3 setImmediate
7 setImmediate
2 setTimeout
6 setTimeout

题目

题目 1

setTimeout setInterval 是不是相同优先级?是否被推到同一个队列

setTimeout(() => {
  console.log('1 timeout')
}, 0)
setInterval(() => {
  console.log('2 interval')
}, 0)
setTimeout(() => {
  console.log('3 timeout')
}, 0)
setInterval(() => {
  console.log('4 interval')
}, 0)
setTimeout(() => {
  console.log('5 timeout')
}, 0)

题目 2

setTimeout(() => {
  console.log('1 setTimeout')
  setTimeout(() => {
    console.log('2 setTimeout')
  }, 0)
  setImmediate(() => {
    console.log('3 setImmediate')
    setImmediate(() => {
      console.log('4 setImmediate')
    })
    process.nextTick(() => {
      console.log('5 nextTick')
    })
  })
  setImmediate(() => {
    console.log('7 setImmediate')
  })
  setTimeout(() => {
    console.log('8 setTimeout')
  }, 0)
}, 0)

题目解答前往 give-me-job issue #1

总结

微任务(microTask)是大爷,宏任务(macroTask)得让着微任务,但是一旦让一个类型的宏任务开始执行,那就得等这个类型的宏任务执行完,然后才能执行微任务!!!在宏任务中被推入队列的宏任务得在下一轮才能开始执行,这一轮没新宏任务的份。


欢迎大家关注我的掘金和公众号,算法、TypeScript、React 及其生态源码定期讲解。