写作不易,Star是最大鼓励,感觉写的不错的可以给个Star⭐,请多多指教。Github地址

[TOC] 事件循环机制产生的原因:主线程运行的时候,产生堆和栈,栈中的代码调用各种外部API,异步操作执行完成后,就在消息队列中排队。只要栈中的代码执行完毕,主线程就会去读取消息队列,依次执行那些异步任务所对应的回调函数。

基础概念

主线程不断的重复获得消息、执行消息、再取消息、再执行,这种机制就称为事件循环。

JavaScript EngineJavaScript Runtime(执行环境)。 简单来说,为了让JavaScript运行起来,要完成两部分工作(当然实际比这复杂的多):

  1. JavaScript Engine编译并执行JavaScript代码,完成内存分配、垃圾回收等;
  2. JavaScript RuntimeJavaScript提供一些对象或机制,使它能够与外界交互。

ChromeNode.js都使用了V8 EngineV8 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);
1
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();
1
2
3
4
5
6
7
8
9
10

如果我们在Chrome运行上述的代码,baz函数调用了bar函数,bar函数调用了foo函数,foo函数抛出了一个错误,我们看到会是这样的:它将整个栈树都打印了出来,错误从foo开始,到bar,到baz,到匿名函数(也就是所谓的main函数)。

内存泄露

function foo() {
    return foo();
}
foo();
1
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);
1
2
3
4
5
6
7
8

异步

JS是一门单线程的语言,那为什么JS还有异步的写法?其实JS的异步和其他语言的异步是不相同的,本质上还是同步。因为浏览器会有多个Queue 存放异步通知,并且每个Queue的优先级也不同,JS 在执行代码时会产生一个执行栈,同步的代码在执行栈中,异步的在 Queue 中。有一个 Event Loop 会循环检查执行栈是否为空,为空时会在 Queue 中查看是否有需要处理的通知,有的话拿到执行栈中去执行。

具体来说,异步执行的运行机制如下:(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

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

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执行之后');
1
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');
1
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

以上代码虽然setTimeout写在Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务,所以会有以上的打印。

宏任务和微任务分类

主线程每次只能执行一个任务,当主线程执行完成后,会首先执行微任务队列中的任务,把当前微任务队列中的任务执行完成后,才会执行宏任务队列中的任务。

异步任务=>等待队列=>分为宏任务队列和微任务队列。如果微任务队列中有多个任务,谁先放入的谁先执行。

  • macro-task(宏任务)包括:script(整体代码)setTimeoutsetIntervalI/OUI renderingsetImmediate(Node.js 环境)
  • micro-task(微任务)包括:Promise的then,async/await,process.nextTick(Node环境)Object.observe(已废弃)MutationObserver

不同类型的任务会进入对应的Event Queue,比如setTimeout和setInterval会进入相同的Event Queue

特别注意:很多人认为微任务快于宏任务,其实是错误的。因为宏任务中包括了script,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务。

所以正确的一次Event loop顺序是这样的:

  1. 执行同步代码,这属于宏任务;
  2. 执行栈为空,查询是否有微任务需要执行;
  3. 执行所有微任务;
  4. 必要的话渲染UI;
  5. 然后开始下一轮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个执行');
1
2
3
4
5
6
7
8
9
10
  1. 整体script作为第一个宏任务进入主线程,先遇到setTimeout,将其回调函数注册后分发到宏任务Event Queue;
  2. 接下来遇到了Promise,new Promise立即执行,then函数分发到微任务Event Queue;
  3. 接下来遇到console.log(),立即执行;
  4. 至此,整体script作为第一个宏任务执行结束,看看有哪些微任务,我们发现了then在微任务Event Queue里面,执行;
  5. ok,到此第一轮事件循环就结束了。接着我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行;
  6. 结束。

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);
1
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);
    })
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

上述例子主要用来说明process.nextTickPromise.then在node环境中的优先级。process.nextTickPromise.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
1
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
1

process.nextTick与Promise.then都是微任务,为什么process.nextTick会先执行?

process.nextTick永远优先于promise.then执行,原因其实很简单。在Node中,_tickCallback在每一次执行完TaskQueue中的一个任务后被调用,而这个_tickCallback中实质上干了两件事:

  1. nextTickQueue中所有任务执行掉(长度最大1e4,Node版本v6.9.1);
  2. 第一步执行完后执行_runMicrotasks函数,执行microtask中的部分(promise.then注册的回调)。

所以很明显process.nextTick优先于promise.then

参考文档

  1. 彻底吃透 JavaScript 执行机制
  2. JavaScript:彻底理解同步、异步和事件循环(Event Loop)
  3. 一篇文章教会你Event loop——浏览器和Node
  4. 深入浅出Javascript事件循环机制(上)
  5. 深入浅出JavaScript事件循环机制(下)
  6. JS:事件循环机制、调用栈以及任务队列
  7. JavaScript 运行机制详解:再谈Event Loop
  8. Stack(栈)的三种含义
  9. 深入理解JavaScript运行机制
  10. 一篇文章教会你Event loop——浏览器和Node
  11. 并发模型与事件循环
  12. 详细说明 Event loop
  13. 这一次,彻底弄懂 JavaScript 执行机制
  14. Js 的事件循环(Event Loop)机制以及实例讲解
  15. 从一道题浅说 JavaScript 的事件循环
  16. JS中的事件循环与定时器
  17. Philip Roberts的演讲《Help, I'm stuck in an event-loop》
  18. 事件循环动画演示
  19. Understanding Javascript Function Executions — Call Stack, Event Loop , Tasks & more
  20. 深入理解 JavaScript Event Loop
  21. 并发模型与事件循环
  22. JavaScript Event Loop Explained
  23. Understanding the JavaScript event loop
  24. JavaScript 中的 task queues
  25. 深入理解Event Loop
  26. JavaScript 在浏览器中的事件循环
  27. [面试专题]JS异步原理(事件,队列)
  28. JS task到底是怎么运行的
  29. 带你彻底弄懂Event Loop前言正文总结