前言

JS单线程、JS的事件循环(Event Loop)、执行栈、任务队列(消息队列)、主线程、宏队列(macrotask)、微队列(microtask),前端er相信很多人对这些词并不陌生,即便对js的api熟能生巧,但是却并不理解这些机制流程的话,那可能JS的提升很难了,这里也是属于提升JS的一个分水岭,在介绍这些概念之前,我们先思考几个非常经典的面试题,答案最后公布,看完这篇文章,或许就能够焕然大悟:透过现象看本质!
注:本章所有环境都是基于浏览器环境,暂不考虑node环境;
题目一:

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(() => {
console.log(1);
}, 0);

new Promise((resolve) => {
console.log(2);
resolve();
}).then(() => {
console.log(3);
});

console.log(4);
// 输出最后的结果

题目二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
setTimeout(() => {
console.log(1);
}, 0);

new Promise((resolve) => {
console.log(2);
setTimeout(() => {
console.log(5);
}, 0);
resolve();
}).then(() => {
console.log(3);
});

console.log(4);
// 输出最后的结果

题目三:

1
2
3
4
5
6
7
8
9
10
11
setTimeout(() => {
console.log(1);
}, 0);
new Promise((resolve,reject) =>{
console.log(2)
resolve(3)
}).then((val) =>{
console.log(val);
})
console.log(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
26
27
28
29
30
31
32
33
34
let a = () => {
setTimeout(() => {
console.log('任务队列函数1')
}, 0)
for (let i = 0; i < 5000; i++) {
console.log('a的for循环')
}
console.log('a事件执行完')
}

let b = () => {
setTimeout(() => {
console.log('任务队列函数2')
}, 0)
for (let i = 0; i < 5000; i++) {
console.log('b的for循环')
}
console.log('b事件执行完')
}

let c = () => {
setTimeout(() => {
console.log('任务队列函数3')
}, 0)
for (let i = 0; i < 5000; i++) {
console.log('c的for循环')
}
console.log('c事件执行完')
}

a();
b();
c();
// 输出最后的结果

JS单线程

JavaScript为什么是单线程,难道不能实现为多线程吗?

进程与任务

一般情况下,一个进程一次只能执行一个任务,如果有很多任务需要执行,不外乎三种解决方法:

(1)排队:因为一个进程一次只能执行一个任务,只好等前面的任务执行完了,再执行后面的任务。
(2)新建进程:使用fork命令,为每个任务新建一个进程。
(3)新建线程:因为进程太耗费资源,所以如今的程序往往允许一个进程包含多个线程,由线程去完成任务。
它是一种单线程语言,所有任务都在一个线程上完成,即采用上面的第一种方法。一旦遇到大量任务或者遇到一个耗时的任务,网页就会出现”假死”,因为JavaScript停不下来,也就无法响应用户的行为。

单线程

JavaScript从诞生起就是单线程,这跟历史有关系。原因大概是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。后来就约定俗成,JavaScript为一种单线程语言。(Worker API可以实现多线程,但是JavaScript本身始终是单线程的。)

如果某个任务很耗时,比如涉及很多I/O(输入/输出)操作,那么线程的运行大概是下面的样子。
JS单线程
上图的绿色部分是程序的运行时间,红色部分是等待时间。可以看到,由于I/O操作很慢,所以这个线程的大部分运行时间都在空等I/O操作的返回结果。这种运行方式称为”同步模式”(synchronous I/O)或”堵塞模式”(blocking I/O)。

如果采用多线程,同时运行多个任务,那很可能就是下面这样。
JS单线程

上图表明,多线程不仅占用多倍的系统资源,也闲置多倍的资源,这显然不合理。

其实JavaScript单线程是指浏览器在解释和执行javascript代码时只有一个线程,即JS引擎线程,浏览器自身还会提供其他线程来支持这些异步方法,浏览器的渲染线程大概有一下几种:

JS引擎线程
事件触发线程
定时触发器线程
异步http请求线程
GUI渲染线程

浏览器环境

js作为主要运行在浏览器的脚本语言,js主要用途之一是操作DOM。
在js高程中举过一个栗子,如果js同时有两个线程,同时对同一个dom进行操作,这时浏览器应该听哪个线程的,如何判断优先级?
为了避免这种问题,js必须是一门单线程语言,并且在未来这个特点也不会改变。

解决的问题

Event Loop就是为了解决这个问题而提出的。

“Event Loop是一个程序结构,用于等待和发送消息和事件。(a programming construct that waits for and dispatches events or messages in a program.)”

简单说,就是在程序中设置两个线程:一个负责程序本身的运行,称为”主线程”;另一个负责主线程与其他进程(主要是各种I/O操作)的通信,被称为”Event Loop线程”(可以译为”消息线程”)。
JS单线程
上图主线程的绿色部分,还是表示运行时间,而橙色部分表示空闲时间。每当遇到I/O的时候,主线程就让Event Loop线程去通知相应的I/O程序,然后接着往后运行,所以不存在红色的等待时间。等到I/O程序完成操作,Event Loop线程再把结果返回主线程。主线程就调用事先设定的回调函数,完成整个任务。

可以看到,由于多出了橙色的空闲时间,所以主线程得以运行更多的任务,这就提高了效率。这种运行方式称为”异步模式”(asynchronous I/O)或”非堵塞模式”(non-blocking mode)。

这正是JavaScript语言的运行方式。单线程模型虽然对JavaScript构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果部署得好,JavaScript程序是不会出现堵塞的,这就是为什么node.js平台可以用很少的资源,应付大流量访问的原因。

执行栈与任务队列

因为js是单线程语言,当遇到异步任务(如ajax操作等)时,不可能一直等待异步完成,再继续往下执行,在这期间浏览器是空闲状态,显而易见这会导致巨大的资源浪费。

执行栈

当执行某个函数、用户点击一次鼠标,Ajax完成,一个图片加载完成等事件发生时,只要指定过回调函数,这些事件发生时就会进入任务队列中,等待主线程读取,遵循先进先出原则。

执行任务队列中的某个任务,这个被执行的任务就称为执行栈。

主线程

要明确的一点是,主线程跟执行栈是不同概念,主线程规定现在执行执行栈中的哪个事件。
主线程循环:即主线程会不停的从执行栈中读取事件,会执行完所有栈中的同步代码。
当遇到一个异步事件后,并不会一直等待异步事件返回结果,而是会将这个事件挂在与执行栈不同的队列中,我们称之为任务队列(Task Queue)。
当主线程将执行栈中所有的代码执行完之后,主线程将会去查看任务队列是否有任务。如果有,那么主线程会依次执行那些任务队列中的回调函数。

js异步执行的运行机制

1)所有任务都在主线程上执行,形成一个执行栈。
2)主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
3)一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”。那些对应的异步任务,结束等待状态,进入执行栈并开始执行。

主线程不断重复上面的第三步。

浏览器事件机制

浏览器在执行js代码过程中会维护一个执行栈,每个方法都会进栈执行之后然后出栈(FIFO)。与此同时,浏览器又维护了一个消息队列,所有的异步方法,在执行结束后都会将回调方法塞入消息队列中,当所有执行栈中的任务全部执行完毕后,浏览器开始往消息队列寻找任务,先进入消息队列的任务先执行。
浏览器事件机制

宏任务和微任务

那么如果两个不同种类的异步任务执行后,哪个会先执行?就像开头提到的面试题,setTimeout和promise哪个会先执行?这时候要提到概念:宏任务和微任务。
概念如下:

宏任务(Macrotasks):js同步执行的代码块,setTimeout、setInterval、XMLHttprequest、setImmediate、I/O、UI rendering等。
微任务(Microtasks):promise、process.nextTick(node环境)、Object.observe, MutationObserver等。

执行栈中执行的任务都是宏任务,当宏任务遇到Promise的时候会创建微任务,当Promise状态fullfill的时候塞入微任务队列。在一次宏任务完成后,会检查微任务队列有没有需要执行的任务,有的话按顺序执行微任务队列中所有的任务。之后再开始执行下一次宏任务。具体步骤:

(1)执行主代码块
(2)若遇到Promise,把then之后的内容放进微任务队列
(3)一次宏任务执行完成,检查微任务队列有无任务
(4)有的话执行所有微任务
(5)执行完毕后,开始下一次宏任务。

如何区分宏任务和微任务呢?划分的标准是什么?

宏任务本质:参与了事件循环的任务。

回到 Chromium 中,需要处理的消息主要分成了三类:

Chromium 自定义消息
Socket 或者文件等 IO 消息
UI 相关的消息

  1. 与平台无关的消息,例如 setTimeout 的定时器就是属于这个
  2. Chromium 的 IO 操作是基于 libevent 实现,它本身也是一个事件驱动的库
  3. UI 相关的其实属于 blink 渲染引擎过来的消息,例如各种 DOM 的事件
    其实与 JavaScript 的引擎无关,都是在 Chromium 实现的。

微任务本质:直接在 Javascript 引擎中的执行的,没有参与事件循环的任务。

(1)是个内存回收的清理任务,使用过 Java 的童鞋应该都很熟悉,只是在 JavaScript 这是V8内部调用的
(2)就是普通的回调,MutationObserver 也是这一类
(3)Callable
(4)包括 Fullfiled 和 Rejected 也就是 Promise 的完成和失败
(5)Thenable 对象的处理任务

宏任务,微任务的优先级

promise是在当前脚本代码执行完后,立刻执行的,它并没有参与事件循环,所以它的优先级是高于 setTimeout。
宏任务和微任务的总结:
宏任务 Macrotasks 就是参与了事件循环的异步任务。
微任务 Microtasks 就是没有参与事件循环的“异步”任务。

执行顺序

1、先执行主线程
2、遇到宏队列(macrotask)放到宏队列(macrotask)
3、遇到微队列(microtask)放到微队列(microtask)
4、主线程执行完毕
5、执行微队列(microtask),微队列(microtask)执行完毕
6、执行一次宏队列(macrotask)中的一个任务,执行完毕
7、执行微队列(microtask),执行完毕
8、依次循环。。。

Event Loop(事件循环)

  js是单线程的,执行较长的js时候,页面会卡死,无法响应,但是所有的操作都会被记住到另外的队列。比如:点击了一个元素,不会立刻的执行,但是等到js加载完毕后就会执行刚才点击的操作,能够知道有一个队列记录了所有有待执行的操作,这个队列分为微观和宏观。微观会比宏观执行得更快。

  event loop它最主要是分三部分:主线程、宏队列(macrotask)、微队列(microtask)
js的任务队列分为同步任务和异步任务,所有的同步任务都是在主线程里执行的,异步任务可能会在macrotask或者microtask里面。

  事件循环就是多线程的一种工作方式,Chrome里面是使用了共享的task_runner对象给自己和其它线程post task过来存起来,用一个死循环不断地取出task执行,或者进入休眠等待被唤醒。Mac的Chrome渲染线程和浏览器线程还借助了Mac的sdk Cococa的NSRunLoop来做为UI事件的消息源。Chrome的多进程通信(不同进程的IO线程的本地socket通信)借助了libevent的事件循环,并加入了到了主消息循环里面。

称为事件循环的原因大多来源于源码:

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

宏任务 > 所有微任务 > 宏任务,如下图所示:
事件循环

事件循环中,每一次循环称为 tick, 每一次tick的任务如下:

执行栈选择最先进入队列的宏任务(通常是script整体代码),如果有则执行
检查是否存在 Microtask,如果存在则不停的执行,直至清空 microtask 队列
更新render(每一次事件循环,浏览器都可能会去更新渲染)
重复以上步骤

Event Loop整体流程

事件循环

题目解析

题目一:

1
答案:2 4 3 1

(1)setTimeout丢给浏览器的异步线程处理,因为时间是0,马上放入消息队列
(2)new Promise里面的console.log(2)加入执行栈,并执行,然后退出
(3)直接resolve,then后面的内容加入微任务队列
(4)console.log(4)加入执行栈,执行完成后退出
(5)检查微任务队列,发现有任务,执行console.log(3)
(6)发现消息队列有任务,执行下一次宏任务console.log(1)

题目二:

1
答案:2 4 3 1 5

(1)setTimeout丢给浏览器的异步线程处理,因为时间是0,马上放入消息队列
(2)new Promise里面的console.log(2)加入执行栈,并执行
(3)setTimeout给浏览器的异步线程处理,因为时间是0,马上放入消息队列,然后退出
(4)直接resolve,then后面的内容加入微任务队列
(5)console.log(4)加入执行栈,执行完成后退出
(6)检查微任务队列,发现有任务,执行console.log(3)
(7)发现消息队列有任务,执行下一次宏任务console.log(1)
(8)发现消息队列有任务,执行下一次宏任务console.log(5)

题目三:

1
答案:2 4 3 1

(1)先执行script同步代码:
先执行new Promise中的console.log(2),then后面的不执行属于微任务然后执行console.log(4)
(2)执行完script宏任务后,执行微任务,console.log(3),没有其他微任务了
(3)执行另一个宏任务,定时器,console.log(1)

题目四:

1
2
3
4
5
6
7
8
9
10
答案:
(5000)a的for循环
a事件执行完
(5000)b的for循环
b事件执行完
(5000)c的for循环
c事件执行完
任务队列函数1
任务队列函数2
任务队列函数3

结果是当a、b、c函数都执行完成之后,三个setTimeout才会依次执行

node环境中的事件机制

node环境中的事件机制要比浏览器复杂很多,node的事件轮询有阶段的概念。每个阶段切换的时候执行,process.nextTick之类的所有微任务。
node环境中的事件机制

timer阶段

执行所有的时间已经到达的计时事件

peding callbacks阶段

这个阶段将执行所有上一次poll阶段没有执行的I/O操作callback,一般是报错。

idle.prepare

可以忽略

poll阶段

这个阶段特别复杂

阻塞等到所有I/O操作,执行所有的callback.
所有I/O回调执行完,检查是否有到时的timer,有的话回到timer阶段
没有timer的话,进入check阶段.

check阶段

执行setImmediate

close callbacks阶段

执行所有close回调事件,例如socket断开。