【JS】事件循环机制(Event Loop)

单线程

什么是单线程?

  • 就是进程只有一个线程。单线程在程序执行时,所走的程序路径按照连续顺序排下来,前面的必须处理好,后面的才会执行。

单线程处理的优点:

  • 同步应用程序的开发比较容易,但由于需要在上一个任务完成后才能开始新的任务,所以其效率通常比多线程应用程序低。如果完成同步任务所用的时间比预计时间长,应用程序可能会不响应
  • JavaScript语言的一大特点就是单线程:同一个时间只能做一件事,可以提高效率。

JavaScript的单线程,与它的用途有关。

  • 作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。
  • 比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
  • 所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
  • 为了利用多核CPU的计算能力,HTML5提出Web Worker标准:
  • 允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。
  • 所以,这个新标准并没有改变JavaScript单线程的本质。

任务队列

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去

  • 所有任务可以分成两种,
1. 同步任务(synchronous):
	在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务
2. 异步任务(asynchronous):
	不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
  • 异步执行的运行机制如下:
1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步。
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。
  • 栈先进后出,队列先进先出

事件和回调函数

"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。

  • 主线程读取"任务队列",就是读取里面有哪些事件。
事件:
	除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。
	只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
回调函数:
	就是那些会被主线程挂起来的代码。
	异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
  • "任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。
  • 主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。
  • 但是由于存在"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

事件循环机制

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)。

在这里插入图片描述

主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。

只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。

定时器(setTimeout)

除了放置异步任务的事件,“任务队列"还可以放置定时事件,即指定某些代码在多少时间之后执行。这叫做"定时器”(timer)功能,也就是定时执行的代码。

  • 将setTimeout()中的代码推迟到1000毫秒之后执行
console.log(1);
setTimeout(function(){console.log(2);},1000);
console.log(3);
// 执行顺序为:1,3,2

-如果将setTimeout()的第二个参数设为0,就表示当前代码执行完(执行栈清空)以后,立即执行(0毫秒间隔)指定的回调函数。

console.log(1);
setTimeout(function(){console.log(2);},0);
console.log(3);
// 执行顺序为:1,3,2
  • 分析:
setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。

它在"任务队列"的尾部添加一个事件,因此要等到同步任务和"任务队列"现有的事件都处理完,才会得到执行。

setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。

要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在 setTimeout()指定的时间执行。

宏任务(macro-task)、微任务(micro-task)

JavaScript 单线程中的 异步任务 可以细分为 宏任务(macro-task) 和 微任务(micro-task)
ES6 规范中,microtask 称为 jobs,macrotask 称为 task,宏任务是由宿主(Node、浏览器)发起的,而微任务由JavaScript自身发起。

  • 宏任务macro-task包括:
script(整体代码), 
setTimeout, 
setInterval, 
setImmediate, 
I/O, 
UI rendering。
  • 微任务micro-task包括:
process.nextTick,
Promises, 
Object.observe, 
MutationObserver。
  • 事件循环中:每进行一次循环操作称为tick
1. 执行一个宏任务(栈中没有就从事件队列中获取)
2. 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
3. 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
4. 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
5. 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

在这里插入图片描述


  • 以宏任务中的 setTimeout 和 微任务中的 Promise 为例

第一次事件循环中:JavaScript 引擎会把整个 script 代码当成一个宏任务执行

  • 执行完宏任务之后,再检测本次循环中是否寻在微任务,存在的话就依次从微任务的任务队列中读取执行完所有的微任务,
  • 再读取宏任务的任务队列中的任务执行,再执行所有的微任务,如此循环。

JS 的执行顺序:就是每次事件循环中的 宏任务 — 微任务 。

console.log(1);
setTimeout(function() {
    console.log(2);
})
var promise = new Promise(function(resolve, reject) {
    console.log(3);
    resolve();
})
promise.then(function() {
    console.log(4);
})
console.log(5);
  • 分析
1. 上面的示例中,第一次事件循环,整段代码作为宏任务进入主线程执行。
2. 遇到了 setTimeout ,就会等到过了指定的时间后将回调函数放入到宏任务的任务队列中。
3. 遇到 Promise,将 then 函数放入到微任务的任务队列中。
4. 整个事件循环完成之后,会去检测微任务的任务队列中是否存在任务,存在就执行。
5. 第一次的循环结果打印为: 1,3,5,46. 接着再到宏任务的任务队列中按顺序取出一个宏任务到栈中让主线程执行,那么在这次循环中的宏任务就是 setTimeout 注册的回调函数,执行完这个回调函数,发现在这次循环中并不存在微任务,就准备进行下一次事件循环。
7. 检测到宏任务队列中已经没有了要执行的任务,那么就结束事件循环。
8. 最终的结果就是 1,3,5,4,2

  • 示例 2
console.log(1);

setTimeout(function() {
  console.log(2);
}, 0);

Promise.resolve().then(function() {
  console.log(3);
}).then(function() {
  console.log(4);
});

console.log(5);
  • 分析
1. 整体 script 作为第一个宏任务进入主线程,遇到 console.log,输出 1
2. 遇到 setTimeout,其回调函数被分发到宏任务 Event Queue 中
3. 遇到 Promise,其 then函数被分到到微任务 Event Queue 中,记为 then1,之后又4. 遇到了 then 函数,将其分到微任务 Event Queue 中,记为 then2
5. 遇到 console.log,输出 5
6. 执行微任务,首先执行then1,输出 3, 然后执行 then2,输出 4,这样就清空了所有微任务
7. 执行 setTimeout 任务,输出 2
8. 最终的结果就是 1, 5, 3, 4, 2

  • 示例 3:
console.log(1);

// 'timeout1'
setTimeout(function() {
  console.log(2);
}, 10);

new Promise(resolve => {
    console.log(3);
    resolve();
    // 'timeout2'
    setTimeout(() => console.log(4), 10);
}).then(function() {
    console.log(5)
})

console.log(6);
  • 分析:
事件循环从宏任务 (macro-task) 队列开始,最初始,宏任务队列中,只有一个 scrip t(整体代码)任务;
当遇到任务源 (task source) 时,则会先分发任务到对应的任务队列中去。

1. 首先遇到了console.log,输出 12. 接着往下走,遇到 setTimeout 任务源,将其分发到任务队列中去,记为 timeout1; 
接着遇到 Promise,new Promise 中的代码立即执行,输出 3, 
3. 然后执行 resolve() ,遇到 setTimeout ,将其分发到任务队列中去,记为 timemout2,
4. 将其 then 分发到微任务队列中去,记为 then1; 
6. 接着遇到 console.log 代码,直接输出 6
7. 接着检查微任务队列,发现有个 then1 微任务,执行,输出 5 再检查微任务队列,发现已经清空,则开始检查宏任务队列,
8. 执行 timeout1,输出 2; 接着执行 timeout2,输出 4 至此,所有的都队列都已清空,执行完毕。
9. 其输出的顺序依次是:1, 3, 6, 5, 2, 4

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>