JavaScript
从诞生之日起就是一门单线程的非阻塞的脚本语言。这是由其最初的用途来决定的:与浏览器交互。
- 单线程,
JavaScript
代码在执行的任何时候,都只有一个主线程来处理所有的任务。 - 非阻塞则是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如I/O事件)的时候,主线程会挂起
pending
这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。
浏览器中的单线程中的异步表现
单线程是必要的,也是JavaScript
这门语言的基石,原因之一在其最初也是最主要的执行环境——浏览器中,我们需要进行各种各样的DOM
操作。试想一下如果JavaScript
是多线程的,那么当两个线程同时对dom进行一项操作,例如一个向其添加事件,而另一个删除了这个DOM
,此时该如何处理呢?因此,为了保证不会发生类似于这个例子中的情景,JavaScript
选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。
君子和而不同,美美与共,天下大同,并不是说在JavaScript
中只有单线程操作就很落后,随着时代的发展,现如今人们也意识到,单线程在保证了执行顺序的同时也限制了JavaScript
的效率,因此开发出了web worker
技术。这项技术号称让JavaScript
成为一门多线程语言,然而,使用web worker
技术开的多线程有着诸多限制,例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程。另外,这些子线程并没有执行I/O
操作的权限,只能为主线程分担一些诸如计算等任务。所以严格来讲这些线程并没有完整的功能,也因此这项技术并非改变了JavaScript
语言的单线程本质。可是浏览器又能很好的处理异步请求,那么到底是为什么呢?
浏览器执行线程
由于浏览器是多进程的,其每一个tab
标签都代表一个独立的进程,其中浏览器渲染进程(浏览器内核)属于浏览器多进程中的一种,主要负责页面渲染,脚本执行,事件处理等。其包含的线程有:GUI 渲染线程(负责渲染页面,解析 HTML,CSS 构成 DOM 树)、JS引擎线程、事件触发线程、定时器触发线程、http 请求线程等主要线程。
- 主线程:也就是 js 引擎执行的线程,这个线程只有一个,页面渲染、函数处理都在这个主线程上执行。
- 工作线程:也称幕后线程,这个线程可能存在于浏览器或js引擎内,与主线程是分开的,处理文件读取、网络请求等异步事件。
非阻塞的具体体现
JavaScript
的另一个特点是“非阻塞”,其有一个基于事件循环event loop
的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。这个模型与其它语言中的模型截然不同,比如 C 和 Java。
依赖概念
栈
函数调用形成了一个由若干帧组成的栈。
function foo(b) {
let a = 10;
return a + b + 11;
}
function bar(x) {
let y = 3;
return foo(x * y);
}
console.log(bar(7)); // 返回 42
当调用bar
时,第一个帧被创建并压入栈中,帧中包含了bar
的参数和局部变量。 当bar
调用foo
时,第二个帧被创建并被压入栈中,放在第一个帧之上,帧中包含foo
的参数和局部变量。当foo
执行完毕然后返回时,第二个帧就被弹出栈(剩下bar
函数的调用帧 )。当bar
也执行完毕然后返回时,第一个帧也被弹出,栈就被清空了。
堆
对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。
队列
一个JavaScript
运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。
在事件循环期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。
函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。
事件循环
之所以称之为事件循环,是因为它经常按照类似如下的方式来被实现:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
queue.waitForMessage()
会同步地等待消息到达(如果当前没有任何消息等待被处理)。
执行栈与事件队列
当JavaScript
代码执行的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针。 但是我们这里说的执行栈和上面这个栈的意义却有些不同。
我们知道,当我们调用一个方法的时候,js会生成一个与这个方法对应的执行环境context
,又叫执行上下文。这个执行环境中存在着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的this
对象。而当一系列方法被依次调用的时候,因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈。
当一个脚本第一次执行的时候,js引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么js会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码执行完毕并返回结果后,js会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境。这个过程会反复进行,直到执行栈中的代码全部执行完毕。
下面这个图片非常直观的展示了这个过程,其中的global
就是初次运行脚本时向执行栈中加入的代码:
从图片可知,一个方法执行会向执行栈中加入这个方法的执行环境,在这个执行环境中还可以调用其他方法,甚至是自己,其结果不过是在执行栈中再添加一个执行环境。这个过程可以是无限进行下去的,除非发生了栈溢出,即超过了所能使用内存的最大值。
以上的过程说的都是同步代码的执行。那么当一个异步代码(如发送ajax请求数据)执行后会如何呢?前文提过,js的另一大特点是非阻塞,实现这一点的关键在于下面要说的这项机制——事件队列Task Queue
。
js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码…,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环Event Loop
”的原因。
这里有一张图来展示这个过程:
图中的stack
表示我们所说的执行栈,web apis
则是代表一些异步事件,而callback queue
即事件队列。
执行栈: 同步代码的执行,按照顺序添加到执行栈中
function a() {
b();
console.log('a');
}
function b() {
console.log('b')
}
a();
我们可以通过使用 Loupe工具来了解上面代码的执行情况。
- 执行函数
a()
先入栈 -
a()
中先执行函数b()
令其入栈 - 执行函数
b()
,console.log('b')
入栈 - 输出
b
,console.log('b')
出栈 - 函数
b()
执行完成,出栈 -
console.log('a')
入栈,执行,输出a
, 出栈 - 函数
a
执行完成,出栈。
对于事件队列,其处理的是异步代码的执行,遇到异步事件不会等待它返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当异步事件返回结果,将它放到事件队列中,被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。
我们再上面代码的基础上添加异步事件,
function a() {
b();
console.log('a');
}
function b() {
console.log('b')
setTimeout(function() {
console.log('c');
}, 2000)
}
a();
此时的执行过程如下
我们同时再加上点击事件看一下运行的过程
$.on('button', 'click', function onClick() {
setTimeout(function timer() {
console.log('You clicked the button!');
}, 2000);
});
console.log("Hi!");
setTimeout(function timeout() {
console.log("Click the button!");
}, 5000);
console.log("Welcome to loupe.");
简单用下面的图进行一下总结
macro task与micro task
不同的异步任务被分为:宏任务macro task
和微任务micro task
为什么要引入微任务,只有一种类型的任务不行么?
页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。
以下事件属于宏任务:
- script(整体代码)
- setTimeout()
- setInterval()
- postMessage
- I/O
- UI交互事件
以下事件属于微任务:
- new Promise().then(回调)
- MutationObserver(html5 新特性)
运行机制
前面我们介绍过,在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。根据异步事件的类型,这个事件实际上会被放到对应的宏任务和微任务队列中去。
在当前执行栈为空时,主线程会查看微任务队列是否有事件存在
- 存在,依次执行队列中的事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的事件,把当前的回调加到当前指向栈。
- 如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;
当前执行栈执行完毕后时会立刻处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。
在事件循环中,每进行一次循环操作称为tick
,每一次tick
的任务处理模型是比较复杂的,但关键步骤如下:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
简单总结一下执行的顺序:
执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环。
console.log('start')
setTimeout(function() {
console.log('setTimeout')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
}).then(function() {
console.log('promise2')
})
console.log('end')
- 全局代码压入执行栈执行,输出
start
-
setTimeout
压入macrotask
队列,promise.then
回调放入microtask
队列,最后执行console.log('end')
,输出end
- 调用栈中的代码执行完成(全局代码属于宏任务),接下来开始执行微任务队列中的代码,执行
promise
回调,输出promise1
,promise
回调函数默认返回undefined
,promise
状态变成fulfilled
,触发接下来的then
回调,继续压入microtask
队列,此时产生了新的微任务,会接着把当前的微任务队列执行完,此时执行第二个promise.then
回调,输出promise2
- 此时,
microtask
队列已清空,接下来会会执行 UI渲染工作(如果有的话),然后开始下一轮event loop
, 执行setTimeout
的回调,输出setTimeout
最后的执行结果如下
总结
js的异步的实现有赖于事件循环的支撑,而在浏览器不崩溃的前提下,通过执行栈与事件队列在宏任务与微任务中左右横跳,从而令浏览器事件不形成死锁,保证永不阻塞。 其处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个IndexedDB 查询返回或者一个 XHR请求返回时,它仍然可以处理其它事情,比如用户输入。
以上就是对于在浏览器内核中对于js事件循环的处理,当然了对于nodejs来说又是另一种实现方式,这个下回分解
参考资料
- https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop
- https://zhuanlan.zhihu.com/p/33058983
- https://zhuanlan.zhihu.com/p/87684858
- https://cloud.tencent.com/developer/article/1731010
- https://www.jianshu.com/p/23fad3814398