前言
感觉知识就像网贷,是个无底洞啊,本来只是在犀牛书上看到定时器的内容,只有一页而已,然而我却花了几周的时间来整理它,不过真的是学无止境,还有很多细节无法深入,大家一起学习进步吖~
简单的栗子
例1:
setTimeout(() => {
console.log('hello world')
}, 0)
function printStr(str) {
console.log(str)
}
printStr('hello Melody')
例2:
let startTime = Date.now();
setTimeout(() => {
console.log(Date.now()-startTime)
}, 100)
for (let i = 0; i < 1000000000; i++) {}
这两个问题绝大部分人都能答的上来,不过答案的扩展性极强,我想面试官问你这个问题也只是以此为引子想看看你的基础是否够扎实。
最最基本也要知道以上两个问题的答案,由此延伸其内部原理,包括但不限于:
- js引擎为什么是单线程的?
- 什么叫阻塞?异步是如何解决阻塞问题的?
- 线程和进程的区别
- 浏览器进程
- 什么是任务队列?什么是执行栈?
- 事件循坏(Event Loop)
- Microtasks 和 Macrotasks
- HTML5的Web Worker?
......
js引擎为什么是单线程的?
其实关于 js引擎为什么是单线程的 这个问题我觉得就像 地球为什么是圆的花儿为什么这样红 一样无聊,但是我还是问了这个无聊的问题,(我就是这么无聊(* ̄︶ ̄))js诞生之初本就没指望他干什么大事,它不走C和Java的“高端”路线,一开始就找准定位--脚本语言,作为脚本语言,它不需要很快的速度,很强的性能,所以本着简单易学的原则,js被设计成单线程语言。
还有一个说法是,js会操作dom,而dom的修改会触发浏览器的渲染进程去渲染界面,如果js是多线程的,当它同时对同一个节点做了增加和删除操作,渲染进程就不知道该怎么渲染这个节点了。关于这个原因其实我觉得比较牵强,Python不就是多线程的,你们咋不说它也会造成这个问题哪?
所以单线程就单线程,它也不见得就比多线程差多少啊。
什么叫阻塞?异步是如何解决阻塞问题的?
js里面的阻塞就是成群结队的函数啊网络请求啊排着队等js执行,执行就得花时间,某个任务花的时间长了,占用js的时间多了,就导致后面的任务等待执行的时间很长,比较坏的情况是,js引擎线程和GUI渲染线程是互斥的,如果js久久执行不完,就会导致窗口一直白屏,甚至浏览器认为该窗口失去响应,会询问用户是否要关闭该窗口。
阻塞不是单线程的专利,事实上无论是单线程or多线程,当并发量过高时都会造成阻塞,只是单线程更容易阻塞,不需要高并发量,只需要来一个耗时的I/O读取操作就可以让线程无法继续往下处理,只能干等着。所以对于这类耗时的操作,没必要等待他们执行完成,只需要告诉他们:我还有事要先去忙没空等你,你结果返回了再来通知我,我自会回去处理,这就是异步和回调。
因为异步的用处实在太多,一不小心就是异步接异步,回调套回调,然后就陷入了“回调地狱”,不过近几年js的回调已经可以处理得非常优雅了,包括有:
- Promise对象
- Generator函数
- async函数(Generator函数的语法糖,用起来确实非常顺手)
线程和进程
我们说:xxx语言是单(多)线程的,浏览器是多进程的
我们说:电脑好卡啊,打开任务管理器看看哪些进程占用CPU过多
所以:进程和线程到底是个啥?
官方解释进程是cpu资源分配的最小单位, 线程是cpu调度的最小单位
不懂,有没有通俗一点的解释?
CPU是计算机的“大脑”,“身体”的各个机能运转都依靠于“大脑”处理,我们的大脑永远在工作,除非我们挂掉了。CPU也会一直运作,除非切断了电源。但是不管CPU有多忙,它一次只能运行一个任务,或者说:它在一个时间段内只会运行一个进程。
比如说这一时刻我开启了一个浏览器窗口,并且正在用键盘输入文字,那么CPU要管理的进程就有浏览器和键盘,当然除此之外显卡啊RAM啊等各种资源的进程也都是一直在运行的。总之不管有多少个进程(任务)要执行,都只能排着队等待CPU的“临幸”,还有一点是,CPU“临幸”某个进程的时间并不是根据这个进程执行完需要多久来确定的,而是由CPU自己分配,如果CPU分配的时间用完了,那么CPU就会存储有关这个进程的执行环境,我们称之为“执行上下文”,等CPU下一次回来继续执行该进程时,实际要做的:
- 加载执行上下文
- 执行进程
- 如果CPU分配时间用完,跳到4,如果进程结束,跳到5
- 保存进程的执行上下文,等待下一次CPU处理
- 回收该进程占用资源,包括其执行上下文。
好了,现在再来理解进程是cpu资源分配的最小单位是不是容易多了?计算机运转的过程实际就是CPU在操控各个进程运转的过程,CPU资源有限,能容纳的进程数量有限,CPU能力有限,它一次始终只能运行一个进程。
而进程还很“大”,为了将进程细分,就引入了线程的概念,这就像一段程序代码由函数和全局变量组成,进程也是由很多个线程组成的,这些线程共享进程的资源,也就是cpu为该进程分配的上下文环境。线程是cpu调度的最小单位这句话的通俗解释是cpu将自己的资源给进程,但真正使用这些资源的是线程。
浏览器进程
浏览器是多进程的,它包括:
- 浏览器进程(主进程):管理浏览器的前进后退、与用户交互,同时负责处理所有和磁盘、网络的通信,不分析和渲染任何网页内容。
- 渲染进程(浏览器内核):渲染进程是多线程的,它负责解析HTML,CSS构建DOM树,负责解析js和事件触发等等。要注意的是它对磁盘和网络等都没有访问权限,这些都是通过浏览器进程去访问的。
- 插件进程:专为Flash, Adobe reader这类插件创建的进程。
- GPU进程:目前绝大部分浏览器都有GPU进程(GPU就是我们通常说的显卡的芯片)GPU进程主要负责硬件加速,即提高浏览器对视频和图像的渲染体验。
以此我们可以看到浏览器做的事情其实非常多,它的各个进程相互配合,可以实现浏览网页、播放视频、查看pdf, excel等各类文档(只要安装了对应插件)、发起网络请求、读取计算机磁盘等等功能。不过由于浏览器内核是由不同厂商开发,所以浏览器之间也有差别,虽然它们都被要求遵守W3C(万维网联盟)的规范,但也仅仅是遵守了部分规范而已。
渲染进程(浏览器内核)
渲染进程是我们最关注的进程,它有多个线程,主要包括:
- GUI渲染线程:负责渲染界面,即解析HTML和CSS构建DOM树
- JS引擎线程: 负责解析JavaScript脚本,处理自己内部的任务(执行栈),如果没有任务就去执行队列的栈顶取任务执行。
- 计时器线程:等待延时时间到达就将计时器里的事件放到执行队列中。
- http请求线程:等待网络请求返回结果就将其回调函数放到执行队列中。
-
事件触发线程:等待用户做点击、按下鼠标等操作时将该事件放到执行队列中。另外,该线程还负责控制事件循环。
......
现在让我们从这段简单的话里理出几条重要的信息。
GUI渲染线程和JS引擎线程是互斥的
因为js脚本可以操作DOM树,所以为了避免GUI渲染和js执行在操作DOM时发生冲突,它们并不会一起发生,当GUI渲染过程遇到<script>标签时就会停下来等待这段js代码执行完再继续渲染。js引擎是由js当前所在环境提供的
js可以在浏览器里面运行是因为浏览器提供了解析js语法的引擎,Node让js可以运行在服务端是也是因为Node给js提供了引擎,并且,各浏览器之间、Node和浏览器之间实现的js引擎都是有差异的。很多事情是浏览器做的而不是js引擎做的
我以前一直没深究过单线程的js是如何调配事件,如何监听异步事件的回调函数的,现在才知道这些都是浏览器在处理,或者说是浏览器的渲染进程在处理,而js引擎要做的就是依次处理执行栈中的任务,当执行栈为空就去执行队列取出第一个任务接着处理。浏览器会给异步任务开辟另外的线程
这里另外的线程就是指计时器线程、http请求线程和事件触发线程等。而js会在执行栈(同步任务)为空时才去处理任务队列(异步任务)的任务。
执行栈和任务队列
执行栈又叫主线程,任务队列又叫消息队列
上面我们已经提到了这两个概念了,现在让我们结合例子和图来加深理解。
例3:
setTimeout(() => {
console.log('setTimeout延时到了')
}, 500)
function excute(a, b) {
let addRes = add(a, b);
console.log(`add result: ${addRes}`)
}
function add(a, b) {
return a+b
}
excute(2,3)
代码开始遇到了setTimeout,由计时器线程处理,计时器线程会负责计时,当到达setTimeout的延时时间即这里的500ms时会将其加入到任务队列中等待js执行。
接着执行excute()函数,excute()是同步函数,所以进入执行栈(主线程)中:
在执行excute()过程中发现它内部调用了add()函数,于是将add()函数也加入执行栈中:
add()函数执行完以后将结果返回并从执行栈中弹出:
excute()函数打印结果,并从执行栈中弹出,此时执行栈中为空,js开始去处理任务队列中的任务,假如现在前面的定时器任务已经加入到任务队列中了:
js会去任务队列询问是否有待处理事件,如果有就取第一条执行,此时打印出
console.log('setTimeout延时到了')
。
本篇文章开头的第一个例子setTimeout的延时是0,不是延时0ms执行,而是延时0ms加入到任务队列中,所以它会在所有的同步任务执行完以后再执行。而在第二个例子中,for (let i = 0; i < 1000000000; i++) {}
是一个比较耗时的同步任务,所以setTimeout打印出的时间差是大于100ms的。(当然,即使是一个耗时很短的同步任务也会导致setTimeout打印出的值大于100,这里只是为了放大同步任务对其的影响而已)
事件循环(Event Loop)
经过前面的分析可以得出以下结论:
js先顺序执行执行栈中的任务,当执行栈为空时再去询问任务队列是否有任务,而任务队列是一个先进先出的机构,js引擎始终从任务队列的顶部取任务执行,js引擎从任务队列取事件的过程是循环不断的,所以这个过程又被称为“事件循环(Event Loop)”
整个过程大致是这样:
但是!但是!不仅仅是这么简单,如果仅仅是同步任务和异步任务这种区分方式,那么看下面这个例子:
例4:
setTimeout(() => {
console.log('定时器1开始了~')
}, 0)
Promise.resolve().then(() => {
console.log('promise1 开始了~')
})
setTimeout(() => {
console.log('定时器2开始了~')
Promise.resolve().then(() => {
console.log('promise2 开始了~')
})
}, 0)
console.log("---end---");
这段代码的输出结果是什么?js引擎是如何处理不同类型的异步任务的?
答案是Microtasks 和 Macrotasks。
Microtasks 和 Macrotasks
Macrotasks也称Tasks,后文我就直接写Tasks,也方便大家区分。
我在学习这里的时候就被误导过,当时以为Tasks和Microtasks是针对异步任务而言的,而其实不是,应该说这才是区分任务最准确的方式。
- Tasks:所有的同步任务(执行栈)、setTimeout、setInterval等
- Microtasks:Promise、process.nextTick等
一个简单的结论是:先执行Tasks,Tasks执行完以后再执行Microtasks
当我看到这个结论时,心中已经有了答案,我觉得例4代码的打印结果是:
---end---(因为这是在执行栈中的任务,会最先执行)
定时器1开始了~ (setTimeout属于Tasks,先于Promise执行)
定时器2开始了~
promise1 开始了~ (Promise属于Microtasks,会在Tasks执行完以后才开始执行)
promise2 开始了~
然而正确的结果是:
---end---
promise1 开始了~
定时器1开始了~
定时器2开始了~
promise2 开始了~
咦?说好的Tasks先于Microtasks执行呢?怎么反倒是Promise先执行了?然后我又仔细读了这段话:
js开始执行Tasks,执行过程中如果遇到Microtasks就将其加入任务队列中,当Tasks执行完毕以后就去执行Microtasks。然后触发GUI渲染线程重新渲染界面,当GUI渲染完成以后再继续下一轮Tasks,如果下一轮又遇到了Microtasks则等这一轮Tasks执行完毕以后又继续执行Microtasks......
所以,准确的事件循环应该是:Tasks -> Microtasks -> GUI渲染 -> Tasks....
前面的结论其实也没有问题,确实是先执行Tasks,Tasks执行完以后再执行Microtasks,只是这句话有歧义,先执行Tasks 的意思是先执行当前这一个Tasks,所以!!并不是说Tasks会先于所有的Microtasks执行,而是在每一次的事件循环过程中,当前的Tasks一定会先于当前的Microtasks执行
如果还不明白,再看例4的代码(一部分):
setTimeout(() => {
console.log('定时器1开始了~')
}, 0)
Promise.resolve().then(() => {
console.log('promise1 开始了~')
})
console.log("---end---");
setTimeout
和console.log("---end---")
是两个Tasks,Promise
是Microtasks,而setTimeout
和Promise
是异步任务会加入到任务队列中等待执行,console.log("---end---")
会直接进入主线程(执行栈)执行,现在重新画一个流程图就应该是这样的:
(画图画到吐血啊~)
执行过程已经非常清楚了,每一轮事件循环只会执行一个Tasks和多个Microtasks,而所有的同步任务一开始就在执行栈中了,它们的执行优先级最高,所以setTimeout
或者setInterval
这类Tasks会在第二轮以后才被执行。
现在再来看例4的全部代码:
//代码块1
setTimeout(() => {
console.log('定时器1开始了~')
}, 0)
//代码块2
Promise.resolve().then(() => {
console.log('promise1 开始了~')
})
//代码块3
setTimeout(() => {
console.log('定时器2开始了~')
//代码块3-1
Promise.resolve().then(() => {
console.log('promise2 开始了~')
})
}, 0)
//代码块4
console.log("---end---");
根据前面的分析,第一轮事件循环包括:
- 主线程里的代码,属于Tasks的代码块4
- 任务队列里的代码,属于Microtasks的代码块2
第二轮事件循环包括:
- 任务队列里面的代码,属于Tasks的代码块1
第三轮事件循环包括:
- 任务队列里面的代码,属于Tasks的代码块3
- 任务队列里的代码,属于Microtasks的代码块3-1
注意:不同的浏览器结果不一样,但根据规范,这确实才是正确的结果。
HTML5的Web Worker
Web Worker是让js可以模拟多线程工作的技术,即Web Worker里面的任务不会阻塞主线程执行和GUI渲染,但是,由于我们前面提到的原因,Web Worker是不能处理与DOM相关的任务的,具体来说,在Web Worker里可以操作的对象有:
- navigator对象
- location对象(只读)
- XMLHttpRequest对象
- setTimeout和setInterval方法
- 应用缓存
不可操作的对象有:
- DOM对象
- Window对象
- document对象
因为我也还没用到过这个技术,所以就不再展开它的详细用法了,建议大家阅读MDN上的使用 Web Workers,讲的非常详细。
总之呢,Web Worker并没有让js由单线程变成多线程,它只是让js有了多线程的能力,一般来说,会放在Web Worker里的任务都是耗时或计算量很大的,而大部分时候我们都不需要js来做计算量很大的工作,所以目前用到它的地方还不多,不过这也只是我的看法~
写在最后
感觉写在最后的话被我写在前言里面了,所以好像也没啥好总结的了,只是感觉自己很拖沓,这篇文章前前后后拖了大半个月,真的是很懒很拖延了~