写作不易,Star是最大鼓励,感觉写的不错的可以给个Star⭐,请多多指教。Github地址。
[TOC] 事件循环机制产生的原因:主线程运行的时候,产生堆和栈,栈中的代码调用各种外部API,异步操作执行完成后,就在消息队列中排队。只要栈中的代码执行完毕,主线程就会去读取消息队列,依次执行那些异步任务所对应的回调函数。
基础概念
主线程不断的重复获得消息、执行消息、再取消息、再执行,这种机制就称为事件循环。
JavaScript Engine
和JavaScript Runtime
(执行环境)。
简单来说,为了让JavaScript运行起来,要完成两部分工作(当然实际比这复杂的多):
JavaScript Engine
编译并执行JavaScript
代码,完成内存分配、垃圾回收等;JavaScript Runtime
为JavaScript
提供一些对象或机制,使它能够与外界交互。
Chrome
和Node.js
都使用了V8 Engine
:V8 Engine
实现并提供了ECMAScript
标准中的所有数据类型、操作符、对象和方法(注意并没有DOM
)。
但Chrome和Node.js的Runtime并不一样:Chrome提供了window、DOM,而Node.js则是require、process等等。 上图是一个javascript的运行环境示意图。堆(heap)记录了内存的分配,栈用于执行上下文。调用栈(stack)基本上是一个记录当前程序所在位置的数据结构,如果当前进入了某个函数,这个函数就会被放在栈里面。如果当前离开了某个函数,这个函数就会被弹出栈外,这是栈所做的事情。
function multiply(a, b) {
return a * b;
}
function square(a) {
return multiply(a, a);
}
function printSquare(n) {
var squared = square(n);
console.log(squared);
}
printSquare(3);
2
3
4
5
6
7
8
9
10
11
当我们执行上述的js文件时,将会有一个类似main
的函数,它指代文件本身,我们首先把它放入栈;然后,我们从上往下查看该文件声明的函数,最后是printSquare
,我们知道了printSquare
被调用了,那么我们把它推进栈里,然后它调用了square
,所以也把square
推进栈里,square
又调用了multiply
,同样也把multiply
推进栈。最后形成了如上图所示的调用栈。
最后我们得到了multiply
的返回值,我们把multiply
弹出栈,然后square
也得到了返回值,再把square
弹出栈,最后到printSquare
,它调用了console.log
,到这里已经没有返回值了,到了函数的最后部分,到此完成整个过程。
function foo() {
throw new Error('Oops');
}
function bar() {
foo();
}
function baz() {
bar();
}
baz();
2
3
4
5
6
7
8
9
10
如果我们在Chrome运行上述的代码,baz
函数调用了bar
函数,bar
函数调用了foo
函数,foo
函数抛出了一个错误,我们看到会是这样的:它将整个栈树都打印了出来,错误从foo
开始,到bar
,到baz
,到匿名函数(也就是所谓的main函数)。
内存泄露
function foo() {
return foo();
}
foo();
2
3
4
以上就是一个内存泄露的例子,一个调用自身的函数foo,将会导致:超过最大限量的栈调用。
Call Stack(调用栈)
Call Stack
是一个记录函数调用的数据结构。当调用一个函数执行时,会先将函数调用推到调用栈中,当函数调用返回结果时,将函数从调用栈的顶部弹出。
function multiply(a, b) {
return a * b;
}
function square(a) {
const sq = multiply(a, a);
console.log(sq);
}
square(3);
2
3
4
5
6
7
8
异步
JS是一门单线程的语言,那为什么JS还有异步的写法?其实JS的异步和其他语言的异步是不相同的,本质上还是同步。因为浏览器会有多个Queue 存放异步通知,并且每个Queue
的优先级也不同,JS 在执行代码时会产生一个执行栈,同步的代码在执行栈中,异步的在 Queue 中。有一个 Event Loop 会循环检查执行栈是否为空,为空时会在 Queue 中查看是否有需要处理的通知,有的话拿到执行栈中去执行。
具体来说,异步执行的运行机制如下:(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack);
- 主线程之外,还存在一个
任务队列
(task queue)。只要异步任务有了运行结果,就在任务队列
中放置一个事件; - 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务结束等待状态,进入执行栈,开始执行;
- 主线程不断重复上面的第三步。
Event Loop
javascript是单线程单一并发语言,这意味着它一次只能处理一个任务。这是由浏览器的场景决定的,避免了复杂的同步问题。HTML5提出「Web Worker」标准,允许js创建多个线程,但子线程仍受主线程控制,且不能操作DOM,js的本质未变。
为什么js是单线程的?
众所周知JS是门非阻塞单线程语言,因为在最初JS就是为了和浏览器交互而诞生的。如果JS是门多线程的语言话,我们在多个线程中处理 DOM 就可能会发生问题(一个线程中新加节点,另一个线程中删除节点),当然可以引入读写锁解决这个问题。
JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。
setTimeout(function() {
console.log('2222');
}, 2000);
setTimeout(function() {
console.log('1111');
}, 1000);
function fn() {
console.log('fn');
}
fn();
console.log('alert执行之前');
alert('------'); // alert会暂停当前主线程的执行,同时也会暂停定时器,点击确定后,恢复程序执行和定时器计数。
console.log('alert执行之后');
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
console.log('script end');
2
3
4
5
6
7
以上代码虽然setTimeout延时为0,其实还是异步。这是因为HTML5标准规定这个函数第二个参数不得小于4毫秒,不足会自动增加。所以 setTimeout还是会在script end之后打印。
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
new Promise((resolve) => {
console.log('Promise')
resolve()
}).then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
// script start => Promise => script end => promise1 => promise2 => setTimeout
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
以上代码虽然setTimeout写在Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务,所以会有以上的打印。
宏任务和微任务分类
主线程每次只能执行一个任务,当主线程执行完成后,会首先执行微任务队列中的任务,把当前微任务队列中的任务执行完成后,才会执行宏任务队列中的任务。
异步任务=>等待队列=>分为宏任务队列和微任务队列。如果微任务队列中有多个任务,谁先放入的谁先执行。
- macro-task(宏任务)包括:
script(整体代码)
,setTimeout
,setInterval
,I/O
,UI rendering
,setImmediate(Node.js 环境)
。 - micro-task(微任务)包括:
Promise的then
,async/await,process.nextTick(Node环境)
,Object.observe(已废弃)
,MutationObserver
。
不同类型的任务会进入对应的Event Queue
,比如setTimeout和setInterval
会进入相同的Event Queue
。
特别注意:很多人认为微任务快于宏任务,其实是错误的。因为宏任务中包括了script
,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务。
所以正确的一次Event loop
顺序是这样的:
- 执行同步代码,这属于宏任务;
- 执行栈为空,查询是否有微任务需要执行;
- 执行所有微任务;
- 必要的话渲染
UI
; - 然后开始下一轮
Event loop
,执行宏任务中的异步代码。
通过上述的Event loop
顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作DOM
的话,为了更快的界面响应,我们可以把操作DOM
放入微任务中。
可能很多人要问了,那怎么知道主线程执行栈为空呢?js引擎存在monitoring process
进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue
里检查是否有等待被调用的函数。如果存在,则立即加入主线程执行栈中执行。
如上图所示:在JavaScript运行的时候,JavaScript Engine会创建和维护相应的堆(Heap)和栈(Stack),同时通过JavaScript Runtime
提供的一系列API
(例如setTimeout、XMLHttpRequest
等)来完成各种各样的任务。调用栈中的代码调用各种外部API(Web APIS,是浏览器提供的),它们在任务队列(Callback Queue)中加入各种事件。只要回调栈(Call Stack)中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。执行栈中的代码(同步任务),总是在读取任务队列(异步任务)之前执行。
示例分析
setTimeout(() => {
console.log('我第4个执行');
}, 0);
new Promise((resolve, reject) => {
console.log('我第1个执行');
resolve();
}).then(() => {
console.log('我第3个执行');
});
console.log('我第2个执行');
2
3
4
5
6
7
8
9
10
- 整体script作为第一个宏任务进入主线程,先遇到setTimeout,将其回调函数注册后分发到宏任务Event Queue;
- 接下来遇到了Promise,new Promise立即执行,then函数分发到微任务Event Queue;
- 接下来遇到console.log(),立即执行;
- 至此,整体script作为第一个宏任务执行结束,看看有哪些微任务,我们发现了then在微任务Event Queue里面,执行;
- ok,到此第一轮事件循环就结束了。接着我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行;
- 结束。
demo1
// 3 7 4 1 2 5
const first = () => (new Promise((resolve, reject) => {
console.log(3);
const p = new Promise((resolve, reject) => {
console.log(7);
setTimeout(() => {
console.log(5);
resolve(6);
}, 0);
resolve(1);
});
resolve(2);
p.then(data => {
console.log(data);
});
}));
first().then(data => {
console.log(data);
});
console.log(4);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
分析:
- 第一轮事件循环:先执行宏任务,主script,
new Promise
立即执行,输出【3】,执行p这个new Promise
操作,输出【7】,发现setTimeout
,将回调放入下一轮任务队列Event Queue
,p的then,暂且叫做then1,放入微任务队列,发现first的then,叫then2,放入微任务队列。执行console.log(4),输出【4】,宏任务执行结束。再执行微任务,执行then1,输出【1】,执行then2,输出【2】。到此为止,第一轮事件循环结束。 - 第二轮事件循环:先执行宏任务里面的,也就是setTimeout的回调,输出【5】。resovle不会生效,因为p这个Promise的状态一旦改变(resolve(1)已经变为成功态了)就不会在改变了。所以最终的输出顺序是3、7、4、1、2、5。
需要注意的是:Promise构造函数是立即执行。
demo2
// 1 2 4 3 5
console.log(1);
setTimeout(() => {
console.log(2);
new Promise(resolve => {
console.log(4);
resolve();
}).then(() => {
console.log(5);
});
process.nextTick(() => {
console.log(3);
})
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
上述例子主要用来说明process.nextTick
和Promise.then
在node环境中的优先级。process.nextTick
和Promise.then
都属于微任务,process.nextTick
的优先级高一点。因此输出:1 2 4 3 5。
demo3
console.log('script start'); // 1
async function async1() {
await async2();
console.log('async1 end'); // 5
}
async function async2() {
console.log('async2 end'); // 2
}
async1();
setTimeout(function() {
console.log('setTimeout'); // 8
}, 0);
new Promise(resolve => {
console.log('Promise'); // 3
resolve();
}).then(function() {
console.log('promise1'); // 6
}).then(function() {
console.log('promise2'); // 7
})
console.log('script end'); // 4
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
chrome 73版本输出结果如下:
script start => async2 end => Promise => script end => async1 end => promise1 => promise2 => setTimeout
process.nextTick与Promise.then都是微任务,为什么process.nextTick会先执行?
process.nextTick
永远优先于promise.then
执行,原因其实很简单。在Node中,_tickCallback
在每一次执行完TaskQueue
中的一个任务后被调用,而这个_tickCallback
中实质上干了两件事:
- nextTickQueue中所有任务执行掉(长度最大1e4,Node版本v6.9.1);
- 第一步执行完后执行
_runMicrotasks
函数,执行microtask
中的部分(promise.then注册的回调)。
所以很明显process.nextTick
优先于promise.then
。
参考文档
- 彻底吃透 JavaScript 执行机制
- JavaScript:彻底理解同步、异步和事件循环(Event Loop)
- 一篇文章教会你Event loop——浏览器和Node
- 深入浅出Javascript事件循环机制(上)
- 深入浅出JavaScript事件循环机制(下)
- JS:事件循环机制、调用栈以及任务队列
- JavaScript 运行机制详解:再谈Event Loop
- Stack(栈)的三种含义
- 深入理解JavaScript运行机制
- 一篇文章教会你Event loop——浏览器和Node
- 并发模型与事件循环
- 详细说明 Event loop
- 这一次,彻底弄懂 JavaScript 执行机制
- Js 的事件循环(Event Loop)机制以及实例讲解
- 从一道题浅说 JavaScript 的事件循环
- JS中的事件循环与定时器
- Philip Roberts的演讲《Help, I'm stuck in an event-loop》
- 事件循环动画演示
- Understanding Javascript Function Executions — Call Stack, Event Loop , Tasks & more
- 深入理解 JavaScript Event Loop
- 并发模型与事件循环
- JavaScript Event Loop Explained
- Understanding the JavaScript event loop
- JavaScript 中的 task queues
- 深入理解Event Loop
- JavaScript 在浏览器中的事件循环
- [面试专题]JS异步原理(事件,队列)
- JS task到底是怎么运行的
- 带你彻底弄懂Event Loop前言正文总结