【JS03】搞定EventLoop 事件循环机制

578 阅读10分钟

EventLoop 事件循环

事件轮询是一直循环往复的,只有当任务队列为空时,才会停止循环,且在每一趟循环中,每一个环节都会有对应的操作。

JavaScript 有一个主线程 main thread,和调用栈 call-stack 也称之为执行栈。所有的任务都会放到调用栈中等待主线程来执行。

任务队列(task queue)

Task Queue 就是承载任务的队列。JavaScript 的 Event Loop 就是会不断地过来找这个 queue,问有没有 task 可以运行运行。

同步任务(SyncTask)、异步任务(AsyncTask)

同步任务就是主线程来执行的时候立即就能执行的代码

异步任务就是你先去执行别的 task,等我这 xxx 完之后再往 Task Queue 里面塞一个 task 的同步任务来等待被执行

setTimeout(()=>{ 
    console.log(2) 
}); 
console.log(1);

setTimeout 就是一个异步任务,主线程去执行的时候遇到 setTimeout 发现是一个异步任务,就先注册了一个异步的回调,然后接着执行下面的语句console.log(1),等上面的异步任务等待的时间到了以后,在执行console.log(2) image.png

  • 主线程自上而下执行所有代码
  • 同步任务直接进入到主线程被执行,而异步任务则进入到 Event Table 并注册相对应的回调函数
  • 异步任务完成后,Event Table 会将这个函数移入 Event Queue
  • 主线程任务执行完了以后,会从Event Queue中读取任务,进入到主线程去执行。
  • 循环如上
  • 主线程任务执行完毕,看Event Queue是否有待执行的 task,这里是不断地检查,只要主线程的task queue没有任务执行了,主线程就一直在这等着

宏任务(MacroTask)、微任务(MicroTask)

JavaScript 的任务不仅仅分为同步任务和异步任务,同时从另一个维度,也分为了宏任务(MacroTask)和微任务(MicroTask)。

宏任务

  • 主体script
  • setTimeout
  • setInterval
  • setImmediate (Node独有)
  • requestAnimationFrame (浏览器独有)
  • I/O
  • UI rendering (浏览器独有)

微任务 Promise.then,process.nextTick

  • process.nextTick (Node独有)
  • Promise
  • Object.observe
  • MutationObserver

不同执行环境下的EventLoop

浏览器环境下的EventLoop

主线程执行栈处理

执行顺序(宏任务->微任务->宏任务->微任务···)

image.png

具体怎么执行,咱们一起看两个案例分析,看完绝对就没问题了。

    setTimeout(() => {

      new Promise(resolve => {
        // 宏任务 
        console.log('1') // 1
        resolve();
      }).then(() => {
        //微任务
        console.log('3') // 3
        //setTimeout(() => {
        // console.log('5') // 5
        //}, 1000)
      });
      // 宏任务 
      console.log(2); // 2
      // 宏任务 
      setTimeout(() => {
        console.log(4)
      }, 1000); // 4
    })
 
##
- 首先,会执行第一个setTimeout(宏任务),把其推入eventLoop,由于没有其他的宏任务,
当时间到了时,会直接将其回调推入执行栈。
- 宏任务先执行, newPromise先执行打印 1 ,
- 再打印 2, 
- 然后再执行setTimeout,将其推入eventLoop, 
- 没有宏任务了,再执行微任务.then(),打印 3,
- 最后主线程执行完毕,将第二个 setTimeout执行内容从事件队列中推入主线程
- 最后打印 4
setTimeout(function() { 
    console.log('5');
}, 3000)
setTimeout(function() { 
    console.log('4');
})
new Promise(function(resolve) {
    console.log('1'); 
    resolve()
}).then(function() {
    console.log('3'); 
})
console.log('2');  
// 12345
## 
这段代码作为宏任务,进入主线程。 
- 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述)
- 接下来遇到了Promisenew Promise立即执行,then函数分发到微任务Event Queue。
- 遇到console.log(),立即执行。

- 微任务中去promise.then

ok,第一轮事件循环结束了,我们开始第二轮循环,
- 当然要从宏任务Event Queue开始。我们发现了宏任务Event QueuesetTimeout对应的回调函数,立即执行。

image.png

image.png

Node环境下的EventLoop

Node.js的运行机制如下:

  • V8引擎解析JavaScript脚本。
  • 解析后的代码,调用Node API。
  • libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
  • V8引擎再将结果返回给用户

要明确执行环境,Node 和浏览器的Event Loop是两个有明确区分的事物,不能混为一谈。nodejs的event是基于libuv,而浏览器的event loop则在html5的规范中明确定义。

image.png

  • Timer, 处理所有 setTimeout 和 setInterval 的回调

    对于timers中队列的处理,setTimeout或setInterval中的函数都添加到队列里,同时记下来这些函数什么时间被调用到了调用时间就调用,没到时间就进入下一阶段,然后会停留在poll阶段

  • i/o cycle

    • Pending I/O Callback, 执行 I/O 回调,文件操作、网络操作等

      除了以下操作的回调函数,其他的回调函数都在这个阶段执行。

      • setTimeout()setInterval()的回调函数,(因为它在timer阶段执行)
      • setImmediate()的回调函数,(因为它在Check阶段执行)
      • 用于关闭请求的回调函数,比如socket.on('close', ...),(因为它在Close callbacks阶段执行)
    • Idle, Prepare 内部使用

    • Poll

      这个阶段是轮询时间,用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。 执行I/O回调

      在这个阶段会一直去问执行相关任务的模块任务执行完了没有,一旦任务执行完成,相关的数据就会放到一个回调里面然后加入到poll queue中,也就是除了timers阶段外的所有回调都是在poll这个阶段处理的。poll阶段会一直重复检查刚才timers里没有到时间的计时器有没有到时间,如果到时间了,就直接通过check到达timers阶段通知timers执行这个回调,然后从timers队列中清除。

  • Check,只处理 setImmediate 的回调函数

  • Close Callback,专门处理一些 close 类型的回调,如关闭网络连接等

    > **关于setTImeout和setImmediate的执行顺序**       
    > 答:正常情况下是setImmediate先执行,只有第一次启动nodejs的情况下timers里的定时器到时间了`才可能`setTimeout先执行。        
    > 原因:因为nodejs启动会执行三件事,开始执行脚本也就是执行你页面的代码,
    这时候会执行你的setTImeout,然后才会去处理event loop,
    而从执行脚本到处理event loop中间也需要时间,setTimeout最小的时间是4ms,
    如果从执行脚本到处理event loop花了5ms时间,那么一进入timers就会发现时间到了就会立刻处理回调,
    所以这时候setTimeout就会先执行
    

process.nextTick()

这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列 nextTickQueue,当每个阶段完成后(包括,一开始执行所有的同步任务,执行完之后会立即清空nextTickQueue以及microTaskQueue,有点类似macroTaskQueue script转到执行栈的这个过程),如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行

然后进入下一阶段,包括nodejs启动的时候也会执行。 如果存在 nextTickQueue,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)

process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1

Node Event Loop 样例

console.log('1');
setTimeout(function () {
  console.log('5');
  process.nextTick(function () {
    console.log('7');
  })
  new Promise(function (resolve) {
    console.log('6');
    resolve();
  }).then(function () {
    console.log('8')
  })
})
setImmediate(() => {
  console.log('13');
  process.nextTick(function () {
    console.log('15');
  })
  new Promise(function (resolve) {
    console.log('14');
    resolve();
  }).then(function () {
    console.log('16')
  })
});
process.nextTick(function () {
  console.log('3');
})
new Promise(function (resolve) {
  console.log('2');
  resolve();
}).then(function () {
  console.log('4')
})

setTimeout(function () {
  console.log('9');
  process.nextTick(function () {
    console.log('11');
  })
  new Promise(function (resolve) {
    console.log('10');
    resolve();
  }).then(function () {
    console.log('12')
  })
})
// 1 2 3 4 。。。

# 解释一下
先执行一遍,主线程执行
先打印 1 
注册2setTimeout,加入timer队列
注册setImmediate,加入check队列
TicketQueue中加入回调
执行promise, 打印 2
微任务队列中加入promise.then

第一轮宏任务执行完毕,
TicketQueue先执行,打印 3
微任务再执行,打印 4

poll队列通知Timer执行,
打印 5
TickQueue中加入 
执行promise, 打印 6
加入微任务
当前阶段执行完毕,
tickQueue执行, 打印 7 
微任务执行,打印8


poll队列通知Timer执行,
打印 9
TickQueue中加入 
执行promise, 打印 10
加入微任务
当前阶段执行完毕,
tickQueue执行, 打印 11
微任务执行,打印12


check队列执行,
打印 13
TickQueue中加入 
执行promise, 打印 14
加入微任务
当前阶段执行完毕,
tickQueue执行, 打印 15
微任务执行,打印16

完事,上面这个例子一定可以帮助你理解Node事件队列,相信我,自己启动node执行环境试下,详细分析一下。

Node与浏览器的 Event Loop 差异

浏览器环境下,microtask的任务队列是每个macrotask都执行完之后执行。 而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是macro中 一个阶段执行完毕,就会去执行microtask队列的任务。

浏览器和Node 环境下,microtask 任务队列的执行时机不同

Node端,microtask 在事件循环的各个阶段之间执行 浏览器端,microtask 在事件循环的 macrotask 执行完之后执行 由于node版本更新到11,Event Loop运行原理发生了变化,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,这点就跟浏览器端一致。

知识拓展

异步执行的运行机制

  • (1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  • (2)主线程之外,还存在一个"任务队列"(task queue & macrotask queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
  • (3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  • (4)主线程不断重复上面的第三步

"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。
"任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数

image.png 除了放置异步任务的事件,"任务队列"还可以放置定时事件,需要注意的是,setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行.