准备知识

1. 进程(process)

进程是系统资源分配一个独立单位,一个程序至少有一个进程。比方说:一个工厂代表一个 CPU, 一个车间就是一个进程,任一时刻,只能有一个进程在运行,其他进程处于非运行状态。

2. 线程(Thread)

线程是 CPU 调度和分派的基本单位,一个线程只能属于一个进程,一个进程可以有多个线程且至少有一个。比方说一个车间的工人,可以有多个工人一起工作。

生活中常常能看到,某某电脑 CPU 的 4 核 4 线程,其意思是指,这款 CPU 同一时间最多只能运行 4 个线程,所以有些线程会处于工作状态,有的线程会处于中断,堵塞,睡眠状态。

经常看到有很多任务同时在进行,一边工作,一边听歌,还一边下载电影。那是因为这些线程在以闪电般的速度不断的切换主要的几个线程,所以,人的体验上感觉是很多很多任务在同时进行。

3. 栈(stack)

栈是一种数据结构,具有后进先出的特点,最开始进入栈结构的数据反而最后才能出来。

4. 队列(queue)

队列也是一种数据结构,数据只能从一边进,一边出,先进去的自然就先出来。

5. 同步和异步(sync async)

同步和异步关注的消息通信机制,同步在函数调用时,如果调用者没有拿到响应结果,程序会继续等待,知道拿到结果为止。而异步会执行其后的代码,等到有响应结果后,才处理响应。

6. 阻塞和非阻塞(blocking & non-blocking)

阻塞和非阻塞关注的是程序等待调用结果时的状态,阻塞的意思是,在调用结果返回响应前,线程会被挂起占用,程序无法继续往下走,而非阻塞的线程则不会挂起,后面的代码能够继续往下执行。

比方说:我去超市买包薯片,老板告诉我货架上没货了,马上去库房拿,这过程中,老板要我站着等他,直到他拿到货出来给我。这个过程就是阻塞。

如果老板告诉我,可以先回去,他一会去库房拿,拿到了之后打电话给我。这个过程,就是非阻塞的,我不用等待,还可以干其他的事情。

7. 执行栈(execution stack)

js 代码在执行代码时,JS 会给调用代码生成一个执行上下文对象,并将其压入执行上下文栈,首先进入栈底的是全局上下文,然后是函数的执行上下文(Execution Context),函数执行完之后,函数上下文从栈中弹出,直到退出浏览器,全局上下文才从栈底弹出。

用代码举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
var globalName = "window";

var foo1 = function() {
console.log("foo1");
};

var foo2 = function() {
console.log("foo2");
foo1();
};

foo2();

上面的图片大致能够描述执行上下文栈的实现逻辑,有关执行上下文的知识,大家可以翻看我之前的文章 - 《JavaScript 之执行上下文》

二、为什么 JS 是单线程模型?

JavaScript 的一个非常有趣的特性是事件循环模型,与许多其他语言不同,它永不阻塞。 处理 I/O 通常通过事件和回调来执行 – MDN

浏览器主要任务是给用户是视觉和交互上的体验,如果页面使用过程中,偶尔出现阻塞、挂起、无响应的体验一定是非常糟糕的。同时,如果采用多线程同步的模型,那么如何保证同一时间修改了 DOM, 到底是哪个线程先生效呢。

浏览器执行环境的核心思想在于任务调度方式的特别:

哪个任务的优先级高,先来就先运行,直到执行完了才执行下一个,并且同一时刻只能执行一个代码片段,即所谓的单线程模型。

比方说,银行的柜台只开启了一个柜台,每个人想要办理业务,就得先拿号排队,叫到了你的号码,你才能上去办理业务。不能多个人同时在一个柜台办理业务,不然就很容易出差错。

三、事件循环

事件循环是 JS 处理各种事件的核心,由于多个线程同时操作 DOM, 造成不可控的问题,所以 JS 采用了单线程模型。另外,由于所有的事件同步执行,执行完一个才能执行下一个,会造成页面渲染的堵塞。JS 中存在异步事件,用户可以在点击页面的时候,请求网络响应的同事,还可以进行其他的点击操作,保证了页面不会因为网络请求,多种 IO 接口响应慢造成代码执行的堵塞和挂起。

事件循环的顺序是:

  1. 进入 script 标签,创建全局上下文
  2. 执行全局上下文中的函数,将其压入执行调用栈
  3. 某个函数执行完后,函数弹出执行栈,清空函数上下文中的变量对象和内存空间,判断是否需要更新渲染,如果需要则更新渲染。
  4. 如果遇到异步事件,也会压入执行调用栈,但浏览器识别到它是异步事件后,会将其弹出执行栈,然后将异步事件的回调函数放入事件队列中。
  5. 执行直到函数调用栈清空只剩全局执行上下文,这时,JS 会检查事件队列中是否有事件,如果有,则将事件队列中的一个事件出队,然后压入执行栈中执行。
  6. 当执行栈又清空只剩全局执行上下文时,又会重复第 5 步。这就是 JS 的事件循环。
  7. 当用户关闭浏览器,全局执行上下文弹出执行栈,清空相应上下文中的变量对象和内存空间。

接下来我们用代码来解释:

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
console.log("script start!");

function foo1() {
console.log("foo1");
}

foo1();

setTimeout(function () {
console.log("setTimeout!");
}, 1000);

function foo2() {
console.log("foo2");
}

foo2();

console.log("script end!");

打印:
// script start!
// foo1
// foo2
// script end!

// setTimeout!

那我们尝试把 setTimeout 的延迟时间改为 0,想要立即执行,看会不会立即执行:

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
console.log("script start!");

function foo1() {
console.log("foo1");
}

foo1();

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

function foo2() {
console.log("foo2");
}

foo2();

console.log("script end!");

打印:
// script start!
// foo1
// foo2
// script end!
// setTimeout!

可以看出 setTimeout 属于异步事件,总是会在主线程的任务执行完后才开始执行。

顺便说一下事件循环几个原则:

  1. 一次只处理一个任务
  2. 一个任务从开始到完成,不会被其他任务所中断

这两个原则保证了浏览器任务单元的完整性,事件调用的有序性。

四、宏任务和微任务

事件循环的实现本来应该由一个用于宏任务的队列和一个用于微任务的队列进行完成,这使得事件循环要根据任务类型来进行优先处理。

宏任务:
宏任务包括:

  1. 创建文档对象、解析 HTML、执行主线程代码(script)
  2. 执行各种事件:页面加载、输入、点击
  3. setTimout,setInterval 异步事件

宏任务代表一个个离散、独立的工作单元,运行完任务后,浏览器可以进行其他的任务调度,如更新渲染或执行垃圾回收。宏任务需要多次事件循环才能执行完。

微任务:
微任务包括:

  1. Promise 回调函数
  2. new MutaionObserver()

微任务是更小的任务,微任务需要尽可能地、通过异步方式执行,微任务更新浏览器的状态,但必须在浏览器执行其他任务之前执行。微任务使得我们避免不必要的 UI 重绘。微任务在一次事件循环中必须全部执行完。

宏任务和微任务的执行优先级原则是:

完成一个宏任务后,执行余下的微任务

同一次事件循环中,宏任务永远在微任务之前执行。

ok,知道了优先级原则后,我们来看一段代码:

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
35
36
37
38
39
40
41
42
43
44
console.log(1);

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

new Promise(resolve => {
console.log(6);
resolve(7);
console.log(8);
}).then(data => {
console.log(data);
});

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

console.log(10);

output:
第一次循环:
// 1
// 6
// 8
// 10
// 7

第二次循环:
// 2
// 3
// 5
// 4


第三次循环
// 9

我们一起来分析以上代码:

  1. 进入第一次事件循环,script 这个宏任务,输出 1
  2. 第一个 setTimeout 函数本身是函数调用,属于任务源,setTimeout 的回调函数,即第一个参数,才是被分发的任务,任务被加入宏任务队列,第二次循环时调用。
  3. Promise 属于微任务,但是 Promise 初始化中代码会立即进行。所以会立即输出 6 和 8;
  4. Promise 初始化后的回调放入微任务队列
  5. 第二个 setTimeout 也属于宏任务源,回调函数的任务放入宏任务队列,第三次事件循环时调用
  6. 继续调用栈,输出 10, 没毛病
  7. 第一次事件循环的宏任务执行完毕,执行余下的所有微任务,所以输出 7,
  8. 第二次事件循环,发现有宏任务,即第一个 setTimeout 的回调,输出 2,调用 Promise 构建函数的调用栈,直接执行,所以输出 3 和 5
  9. 第一个 setTimeout 的 promise 回调放入微任务队列。
  10. 第二次事件循环的宏任务调用执行完,执行刚才前一步 Promise 创建的微任务,输出 4,第二次循环执行完毕。
  11. 进入第 3 次事件循环,只有一个宏任务,即第二个 SetTimeout,所以输出 9;

关于事件循环宏任务和微任务的执行过程:

  1. 首先两个类型的任务都是逐个执行
  2. 微任务会前下一个渲染或垃圾回收前全部执行完
  3. 一次事件循环中先只执行一个宏任务,在下一次事件循环前执行完所有的微任务,包括新创建的微任务。

五、web worker

尽管 HTML5 新标准加入了 web worker 的多线程技术,但是 web worker 只能用于计算,并且 JS 的多线程 worker 无法操作 DOM, 不然就无法控制页面是在被谁操作的了。

主线程传给子线程的数据是通过拷贝复制,同样子线程传给主线程的数据也是通过拷贝复制,而不是共享同一个内存空间。

以上说明,JS 不存在线程同步,所以还是可以把 JS 看做单线程模型,把 web worker 当做 JS 的一种回调机制。

总结

事件循环是 JS 和 Nodejs 事件调用机制的核心,保证了页面可以有序无阻塞的进行。

事件循环的主要逻辑是先执行调用栈,直到清空调用栈只剩下全局上下文。

然后 JS 检查宏任务队列,如果有任务则取出一个进行调用,进行页面渲染和垃圾回收。

同时将所有的微任务源派发的任务加入微任务事件队列,最后执行余下的所有微任务。微任务执行后完,进行页面渲染和垃圾回收后进行下一轮事件循环。