如果接触过其他的GUI编程,比如iOS、安卓等等,通常会有一个『主线程』的概念,而对UI进行的任何操作都只能在『主线程』里进行,这是因为多线程具有时间先后上的不确定性,这种不确定性会导致在访问共享变量时很容易发生一些未知的错误,而通常情况下我们认为UI就是一种共享变量。
本文会通过线程概念入手,介绍多线程的原理以及多线程带来的问题,鉴于有很多前端同学对多线程的概念比较模糊所以我会用JS写一些通俗易懂的伪代码来辅助说明,最后引出我们的主角 Event Loop,以及 Event Loop 在JS里的应用,比如 setTimeout 和 Promise。
多线程
进程
在介绍线程之前我们先简单的介绍下进程,本质上他们的概念差不多,但是进程可能更易于理解,因为它更接近我们日常的认知。
我们在配置电脑的时候,通常会关注一下CPU的参数,比如x核x线程,当然这个数字越大越好,这是因为大家都喜欢大的同一时间内单个核只能执行一个进程。
进程是一个抽象的概念,它由系统内核进行调度和维护,拥有独立的虚拟空间地址。简单的来说进程就相当于我们打开的每一个程序,比如Chrome浏览器,就是一个进程,当然它不止一个进程,打开活动监视器,我们可以看到在后台Chrome开着很多进程。
并发,我们这里只讨论单核的情况。
通常现在单核的电脑我们很难接触到了,但是即使在单核的电脑中我们也可以做到同时做很多事情,比如一边听歌一边玩游戏,这其实是在进程间快速切换来完成的。
简单描述一下就是在某一个进程执行一段时间之后切换到别的进程执行一段时间,如此循环,因为CPU速度太快,所以我们是感知不到的,比如我们听歌没有发现任何的停顿,简单流程参考下图。
时间片轮转调度算法来解决的,这里不展开了,大家可以自行了解一下。
既然CPU需要多个进程间切换,那如何得知当前的进程运行到哪里?通俗易懂一点,QQ音乐里阿杜在唱『我应该在车底,不应该在车里』,CPU来到这个进程的时候怎么知道播放到了这一句?所以进程需要自己储存这些信息,比如当前各个寄存器的值以及打开的文件标识符等等。CPU跳转过来的时候读取这些值,跳走的时候储存这些值。
所以更准确一点的说法,程序是运行在一个叫做进程的上下文里。
线程
线程相对于进程来说是一个更细化的单位,它是CPU操作的基本单位,线程共享当前进程的上下文环境,但是每一个线程都有属于自己的 栈。
CPU跳到我们程序所在的进程,会以同样的规则来对线程进行执行,也就是在线程中跳转,同时线程上下文负责储存当前线程的运行需要的数据,比如各个寄存器的值,代码执行的位置(事实上也是一个寄存器的值)等。
但是哪一条线程执行到哪里被停止,以及哪一条先执行哪一条后执行,我们无从得知,这样我们访问共享变量的时候,就会出现问题,我们来举个例子。
当前进程上下文中有一个共享变量
let a = 5
线程1操作如下
let thread1A = a
if (thread1A === 5) {
console.log('thread1A的值等于5')
}
else {
console.log('thread1A的值不等于5')
}
线程2操作如下
a = 6
这里我们只假设两种情况,实际上情况要远远大于两种。
- 正常执行完一条线程,比如线程2,将
6赋值给a,然后线程1执行,此时thread1A读取a等于6,判断条件不成立,输出thread1A的值不等于5 - 线程1执行完
let thread1A = a后跳到线程2同时存储thread1A的值到自己的上下文环境中,也就是5,线程2执行后a等于6,这时回到线程1,但是线程1的上下文环境中thread1A已经储存过了是5,所以条件成立,输出thread1A的值等于5
可以看到,这两种情况会将整个程序处在魔幻的不确定之中,仿佛结果怎么样只看CPU的心情与女朋友何其相似,通常可以进行多线程编程的语言会通过加锁的方法来处理多线程间数据冲突的情况,线程1 的这种可以被中断的操作我们称为 非原子操作,关于原子操作的解释你可以点击这里。
原子操作加餐
通常来说所谓的 原子操作 是指令级的,也就是汇编层面的指令,比如上面线程2的 a = 6 的操作,汇编只需要一条指令(并不是所有的汇编指令都是原子的)
非原子操作 通常是一系列指令的集合,这里有个在感觉上有些欺骗性的例子 i++,看起来它只有一句代码,跟 a = 6 差不多,但实际上 i++ 是 i = i + 1 的缩写,这在汇编里会生成三句指令
- 从内存读取
i的值到寄存器 - 在寄存器里进行
+1的操作 - 从寄存器里把计算后的结果赋值给内存里的
i
而这其中任何一步都可能被打断执行别的线程去了,所以 i++ 并不是一个原子操作,现在反过来思考一下,为什么 a = 6 是一个原子操作?
主线程
多线程访问共享变量的问题上文已经说过了,UI对每一个线程来说都是共享变量,那应该如何避免?比起给所有与UI有关的操作都加锁,有一个更方便也更简单的方法来解决这个问题,就是只通过一个线程来处理UI,这个线程一般被叫做『主线程』,也被叫做『UI线程』,也就是JS里的单线程。
原理上这条线程与其他的线程没有本质上的区别。
JS虽然是单线程的,但是JS引擎并不是单线程,明白这一点,是我们讨论接下来东西的基础。
回顾上面关于线程的介绍,现在我们有了一个新的问题,如何把其他线程的数据或者task交给我们的主线程去处理,我们的主线程又需要何时去处理其他线程传递过来的task?
第一个问题牵扯到线程间通信,与本文关系不大,包括进程跟线程都是简单介绍,如果有机会我可以专门开一篇文章来详细说明。
我们来到第二个问题,一个线程同时只能进行一个task,那执行过程中别的task来了怎么办?或者task有优先级顺序吗,怎么去给这些task安排执行顺序?
并且线程执行完代码通常就退出了,它还怎么接收到别的线程传递过来的东西?
带着对这些问题的思考,我们迎来了今天的主角Event Loop。
Event Loop
通常情况下,每一个需要被通信线程里都会存在Event Loop,它的原理其实很简单,就是一个循环,不停的去轮询当前有没有新的task,就像下面这样
function loop() {
initialize()
do {
var task = get_next_task()
process_task(task)
} while (task != quit)
}
因为是循环,所以这个线程可以一直存活到程序退出或者接收到退出指令为止,get_next_task 方法伪代码如下
function get_next_task() {
if ( tasks.length > 0 ) {
let task = tasks.shift()
// 可能的一些操作
return task
}
return null
}
这里的 tasks 我们定义为一个数组,当其他线程把 task 传递过来,我们就把它加入这个数组,然后依次执行(根据优先级的不同,task可能会被插入到数组不同的位置)
宏任务
提到JS里的task,最常见的就是宏任务了,包括鼠标点击产生的回调,还有setTimeout产生的都是宏任务。
每产生一个task都会被加入到我们的tasks数组中去。
浏览器为了能够使得JS内部task与DOM的task能够有序的执行,通常可能会在一个 task 执行结束后,在下一个 task 执行开始前,对页面进行重新渲染(task->render->task...),注意这里是 JS task 与 DOM task两种 task ,它们分别由JS引擎和渲染引擎处理。
而有些时候,我们一个宏任务执行完以后,根本不需要再去渲染,宏任务显得太笨重了一些,并且宏任务可能导致一些其他的问题。
举个例子,大家都知道定时器用 setTimeout 比 setInterval 要更稳妥一些,这是因为无论是 setTimeout 还是 setInterval 都是会隔一段时间就会把生成的 task 放到 tasks 数组里去,而如果一个 task 执行的时间太长,超过了定时器的时间,执行完这个 task 以后,如果没有其他宏任务插进来的情况下,下一个取出来还是 setInterval 生成的 task ,会造成一种连续执行而非定时器的感觉,而 setTimeout 执行完以后再调用 setTimeout 来模拟定时器,就是在执行完以后的某一个时间才把 task 插入到 tasks 数组中,至少不会造成连续执行的错觉。
但是通过上面的描述,我们也知道了,为什么无论是 setTimeout 还是 setInterval 都不精准的原因。
微任务
微任务是一种相比较宏任务而言更轻量级的一种task,它跟宏任务不同的地方在于宏任务由浏览器产生,而微任务由JS引擎产生,并且执行的时机也不一样。
只要当前JS执行栈中没有其他的JS代码正在执行并且当前的宏任务执行完之后,微任务队列会立即执行。
这说明微任务执行之前不需要浏览器对页面进行渲染,通常步骤是task->Microtasks->render->task...,是的,没有中间商赚差价。
如果微任务执行期间产生了新的微任务,那么会添加到微任务队列的后面,直到所有的微任务结束才会执行下一个task。
到这里我们的 get_next_task 需要稍微修改一下,需要先看看有没有微任务
function get_next_task() {
let tasks = microTasks.length > 0 ? microTasks : macroTasks
if ( tasks.length > 0 ) {
let task = tasks.shift()
// 可能的一些操作
return task
}
return null
}
目前仅仅就我知道的产生微任务的接口,前端有两个,一个是大名鼎鼎的Promise,如果你还对Promise不够了解,可以看看阮一峰老师的文章,这里不进行展开了。另一个是MutationObserver,MutationObserver是一个监视对DOM树所做更改的能力的接口,具体可以点击这里进行了解
了解完这两个,我们进行一点实验了。
输出实验
入门例子
setTimeout(() => {
console.log('setTimeout')
}, 0)
new Promise(function (resolve) {
console.log('Promise');
resolve();
}).then(function () {
console.log('then')
})
- 首先这段代码整体作为一个宏任务,执行到
setTimeout将它作为一个宏任务加入macroTasks队列 - 往下
Promise的构造函数的function会直接执行 - 然后执行到
then产生一个微任务加入microTasks队列 - 这个宏任务执行完成,回到上面的
loop方法,执行get_next_task发现有微任务,执行微任务 - 最后执行宏任务
setTimeout
所以输出的结果就是
Promise
then
setTimeout
理解这个以后,我们来看一个复杂的例子
复杂例子
HTML
<div id="outer">
<div id="inner"></div>
</div>
JS代码
let outer = document.getElementById('outer')
let inner = document.getElementById('inner')
//监听元素 attributes 属性变化 产生的是微任务
new MutationObserver(function () {
console.log('mutate') // attributes 改变以后会调用
}).observe(outer, {
attributes: true
})
function onClick() {
console.log('click')
setTimeout(function () {
console.log('timeout');
}, 0)
new Promise(function (resolve) {
console.log('Promise')
resolve()
}).then(function () {
console.log('then')
})
outer.setAttribute('data-random', Math.random())
}
inner.addEventListener('click', onClick)
outer.addEventListener('click', onClick)
我们点击一下 inner 会输出什么?
- 根据我们上一个例子的分析,点击事件发生以后,会产生一个
inner的click宏任务加入macroTasks队列 - 接下来因为冒泡的原因,所以还会产生一个
outer的click的宏任务同样加入macroTasks队列 - 然后是
inner的setTimeout加入macroTasks队列 then加入microTasks队列setAttribute产生一个微任务的回调,同样加入入microTasks队列inner的click事件执行结束- 来到
outer的click事件,顺序跟inner相同
所以最终输出为
click
Promise
then
mutate
click
Promise
then
mutate
timeout
timeout
如果你理解了上面这个,那么下面还有个终极的例子在等你
终极例子
现在我们不通过点击 inner 来触发事件,而是通过代码触发
inner.click()
在JS脚本的最后加上这句会发生什么?
结果可能会让你觉得奇怪
click
Promise
click
Promise
then
mutate
then
timeout
timeout
在看分析我建议你先思考一下,为什么会这样
我们再看一遍关于微任务的定义
只要当前JS执行栈中没有其他的JS代码正在执行并且当前的宏任务执行完之后,微任务队列会立即执行。
好了,定义里面说 JS执行栈中没有其他的JS代码正在执行 ,而我们通过代码调用 inner.click() 的时候,这个方法会被压入执行栈,等到所有的 click 事件执行完毕以后才会出栈,所以顺序如下。
inner的click事件产生宏任务加入macroTasks队列outer的click事件产生宏任务加入macroTasks队列- 执行
inner的click的宏任务- 输出
click setTimeout被加入macroTasks队列- 输出
Promise then被加入入microTasks队列setAttribute被加入入microTasks队列inner的click的宏任务执行结束
- 输出
- 因为这时候
inner.click()方法还在执行栈里,所以微任务不执行 - 执行
outer的click的宏任务 顺序同inner inner.click()方法出栈,此时microTasks里是['then','mutate','then','mutate']macroTasks里是['setTimeout','setTimeout']- 微任务:5000年终于轮到我上场,现在我就要输出
inner的then你们有没有意见,嗯? - 再输出个
inner的mutate没毛病吧? - 喔,轮到
outer的then了,好了装完逼我跑了 inner的setTimeout执行outer的setTimeout执行
看完了这个例子,我们的 get_next_task 方法还需要修改一下
function get_next_task() {
let tasks = (jsStack.lenght === 0 && microTasks.length > 0) ? microTasks : macroTasks
if ( tasks.length > 0 ) {
let task = tasks.shift()
// 可能的一些操作
return task
}
return null
}
但是我们发现这里还有一个问题,mutate 只输出了一次,还记得微任务的执行时机吗
task->Microtasks->render->task...
也就是微任务执行完之前是不会 render 的,而 MutationObserver 则是在发生改变的时候产生回调,所以在没有 render 以前只需要把值更新了就可以,故这里只产生了一次回调。
感谢 @睡不醒的黑客 提醒,这里具体的实现为当前执行JS环境的代理通过维护一个变量mutation observer microtask queued来确认是否一个mutation observer microtask可以被入列,可以点击这里进行具体了解。
结束
关于上文中提到的当前执行JS环境的代理的简单解释
Conceptually, the agent concept is an architecture-independent, idealized "thread" in which JavaScript code runs. Such code can involve multiple globals/realms that can synchronously access each other, and thus needs to run in a single execution thread.
你也可以在这里得到更深入的了解。
再次感谢 @睡不醒的黑客 。
如果有问题也欢迎在评论里进行讨论,大家共同进步。