浏览器
消息队列
我们之前在这篇文章中讲到过关于浏览器线程和进程的知识。这些知识在本文中将继续被用到。浏览器上的任务主要执行在一条线程上,我们称这条线程为浏览器主线程。在它上面执行这众多的任务:包括界面绘制,排版,用户手势/鼠标动作,处理滚动,用户输入,以及执行我们编写的脚本。浏览器之所以采用了单线程的一个重要原因是因为浏览器的特性导致的:每个用户行为都有先后顺序,这些任务必须按照特定的顺序被执行,否则界面上的dom元素变化就会出现“怪异的现象”。为了合理地安排这些任务的执行,浏览器采取了消息队列的方式对这些任务进行管理。所谓队列,就是一个先进先出(first in first out)的线性结构表数据结构,上面说到的任务都是按照其产生的先后顺序被推入到这个消息队列中去执行的,而这些任务就是我们称之为宏任务。
长任务(long task):长任务是指执行时间超过50ms的宏任务,通常长任务是由于我们编写的糟糕的代码引起的,例如脚本执行时间过长,或者javascript脚本操作dom的导致重绘或者重排的时间过长。它也受计算机硬件设备影响。
延迟队列
延迟队列就是值暂时不放入消息队列中的函数所在的队列。延迟队列中都存储这一些异步的调用函数,他们等待定时事件耗尽,或者其他进程的IPC通知,再把对应的函数推入消息队列中。我们编写的异步代码,如ajax请求,settimeout和setinterval中的回调函数,就是存在延迟队列中的,知道定时器耗尽事件,或者网络经常通知渲染进程,这些函数才被推入消息队列。消息队列其实严格来讲是一种优先级队列,也就是说出队和入队的顺序不是他们被的时间顺序,而是等待时间的顺序。消息队列本质上是一个优先队列,因为需要判断时间先后,使用的是堆这种数据结构。
事件循环
宏任务按照时间顺序被添加到消息队列中,主线程从消息队列中不停地执行这些任务,按照先进先出的原则,将最旧(oldest)的任务提出并且执行,完成之后再取队列中的任务继续执行。整个过程,就像是一个大的滚轮不停的滚动执行,而实际上v8的底层也是通过一个while循环来执行的。我们称这样的一个执行过程为事件循环。
while(true){
...//
}
v8是google团队开发出来的javascript运行虚拟机。它本身只是对ECMAScript标准规范的实现,它的运行需要借助宿主环境。在浏览器中,渲染进程中的主线程就是v8用来编译和解析javascript的。
调用栈
调用栈指的是v8引擎用来管理javascript执行上下文的数据结构。v8在执行js代码的时候是把每个函数按照执行顺序推入一个栈中的(call stack),栈的特点与消息队列的一样,只不过它是LIFO (last in first out)后进先出的原则,也就是说最后进入栈中的函数,会最先被pop出来。由于调用栈在这种数据结构保持了内存上的连续性,因此在切换出栈和入栈的时候保持了比堆要好的效率。不过也正是因为栈对连续内存的要求比较高,所以一般函数的调用栈空间都有内存限制,超过了这个限制,浏览器就会报错, “Max call stack”。
当一个函数被调用,v8就会为该函数创建一个函数执行上线文(Function Execution Context),并将它推入到调用栈中,然后执行。如果这个函数调用了另外一个函数,v8会为调用的函数创建函数执行上下文,然后继续讲其推入到调用栈中,一直如此,直到最后一个被调用的函数完成,接着按照后入先出的原则,一次讲执行上下文出栈。
执行上下文是指一段函数执行时的环境:包括变量环境、词法环境、外部环境,还有this。
下面我用代码演示一下调用栈的工作方式:
function puppy() {
kitty()
}
function kitty() {
sing()
}
function sing() {
console.log("miwo");
}
puppy();//miwo
以上是三个独立的函数,调用关系分别是puppy>kitty>sing,按照执行顺序是限执行puppy,再执行kitty,然后是sing函数,最后打印miwo。它们按照代码的执行的时间顺序被压入到调用栈中,然后按照后进先出的方式被弹出调用栈,如下图所示:
调用栈中的情况可以通过chrome工具观察,你只需要在某一处打上断点(break point),当浏览器定位到断点时,查看开发者工具的右侧栏Call Stack栏,就可以看到每个函数的调用栈状态。
微任务
现在我们知道了消息队列,事件循环以及调用栈这些概念,我们才好继续理解微任务。为什么我们有了宏任务,还需要微任务呢?
早期的浏览器并没有区分宏任务和微任务,所有的任务统一都是宏任务。但是随着浏览器的发展,很多业务的复杂度上升,对性能就有所要求。但是如果假定任务数量不变,我们是在本质上是无法做到减少时间的,因此我们就需要将某一些优先任务进行细分,对不同的任务进行优先级排队。
优先任务:在一个网页时,dom操作和用户交互优先程度是最高的,这样才不会让用户有卡顿的感觉,因此,我们把dom变化作为一个优先任务考虑。
我们来举一个例子,来说明为什么需要微任务。早期的浏览器为了监听dom的变化,我们有两种方式
- 用setTimeout轮训,判断元素是否变化。
- 使用Mutation Event,判断元素的变化。
这两个方法都有各自的缺点,第一种我们无法判断dom变化的速率,如果间隔时间设置过快,毫无以为会浪费性能;而如果过慢则无法实时监听到dom的变化。而第二种虽然采用了异步的方式监听dom的变化,但是没有解决如果前面的任务执行过久的问题。而且dom的频繁变动会造成大量频繁的操作。为了解决这些问题,浏览器映入了映入了一个新的api:MutationObserver来监听dom变化,把以上两个问题都解决,第一,利用微任务将dom处理的优先级提升,第二,一次性收集多个dom变化一起处理。现在我们就来看看,浏览器是如何提升微任务的执行优先级的呢?我用下面的一张图来做说明:
消息队列中有很多个宏任务等待被执行,然后每个宏任务的队尾都有一个微任务队列,当执行某个宏任务的过程中有微任务(如MutationObserver监听到的dom变化,promise.resolve等)v8会把产生的任务加入到当前宏任务的微任务队列中,当这个宏任务执行完成,v8会去检查当前的任务的微任务队列是否为空(我们称这个时间点为检查点checpoint),如果为空,则继续下一个宏任务,如果不为空则去执行对应的微任务。可以想见,如果没有微任务的这种机制,那么我们新产生的任务就会被派到消息队列的最顶部分,等待其他的宏任务完成,再执行这些变化,这毫无疑问会影响dom改变的时间,从影响到客户的体验。
如果微任务中产生了新的微任务,那么下一个宏任务依旧要等待这个微任务被执行完成。
浏览器中哪些操作会产生微任务呢?
1.MutationObserver监听的dom变化时会回调函数会被作为微任务处理,因为dom的变化响应要非常及时,不能被其他的宏任务插队。
2.Promise.reslove也会产生微任务,详情我在之前的博文中已经提到过,有兴趣的可以过去查看。
Node.js
libuv和v8
- V8: V8是一套由google团队开发的高效运行js的虚拟机。负责v8的解析和编译工作,得益与google团队的开发,它编译js代码是变得异常的快速。nodejs在语法上使用了javascript,同样在底层虚拟机上实现也是由v8引擎对js进行编译的。
- libuv:是用C语言实现的一套异步功能库,nodejs高效的异步编程模型很大程度上归功于libuv的实现,我们的这里所讲的循环就是在libuv的基础上实现的。
网络IO:node采用的是单线程的,并且会基于不同的机器采用不一样的机制,通过封装不同平台的模型而确定node的IO运行机制。但文件IO和DNS操作以及用户代码则是运行在线程池中的。libuv内部默认启用的是4个线程池对上述IO进行处理。当代码层传递给 libuv 一个操作任务时,libuv 会把这个任务加到队列中。
事件循环
我们在上面已经提到过了浏览器的事件循环,下面我们来看node的事件循环。我们都知道node是一种基于事件驱动的非阻塞IO编程模型,node本身的IO操作非常频繁,在浏览器中我们也许经常只是使用网络IO就能满足我们日常的需求了。这使得我们的同步操作可以不用等待异步结束而先执行其他操作,等到异步结束,再通知主线程进行回调操作。nodejs的事件循环主要用来处理这种非阻塞的IO操作的。nodejs的事件循环总结有两个特点:
- 循环机制并非一直都是开启状态
If the loop is alive an iteration is started, otherwise the loop will exit immediately. So, when is a loop considered to be alive? If a loop has active and ref’d handles, active requests or closing handles it’s considered to be alive.
这是libuv官方文档上的一段原文,大概意思就是如果不存在异步IO操作或者回调未处理的情况,那么事件循环将终止。与浏览器的事件循环机制不同,node在同步代码运行过后如果发现没有正在执行的相关的操作,便不会启用事件循环。以下的操作会启用事件循环的:
- setimeout和setinterval
这两者属于定时器,它们的回调会在事件循环的timer阶段被执行。
- setImmediate
setImmediate方法的回调会在事件循环的checker阶段被执行。
- promise
promise和浏览器中的事件循环一样产生了微任务,它处在每个阶段的前面,每次阶段切换都需要执行
- process.nextTick
nexttick产生的并非微任务,但它的优先级比微任务都要高,会在微任务队列之前执行。
- 异步IO
磁盘操作,系统操作,DNS操作等异步io是事件循环处理的主要对象,他们的回调函数在poll阶段执行
网络IO
以上node操作,如果还未回调完成,那么nodejs就会处在一个循环中,总得来说它一般是用timer阶段为其实阶段开始轮训。
- 事件循环有可能暂停并且重启的顺序会改变
事件循环有可能暂停,如果异步IO未结束,而且其他阶段中的执行队列都是空的,事件循环会进入休眠状态等待poll阶段的回调函数,一旦完成IO,事件循环从poll阶段重新开始循环。这一点与浏览器的时间循环不同。下面我们用一张图来说明事件循环的各个阶段以及他们是怎么样运行的:
每个阶段有存在一个任务执行队列,一旦定时器过期或者IO结束,回调函数都会被推入执行队列中,等待下一次循环到该阶段时被取出调用。我们下面列出了一段代码,然后解释一下node是如何执行代码的:
console.log("welcome to node's world!!");
setImmediate(() => console.log('immediate')); //checker
setTimeout(() => console.log('time-out'), 0); //timer
Promise.resolve('promise').then(console.log)// miro task
process.nextTick(() => console.log('next-tick')); // tick task
fs.readFile('./a.txt', {encoding: 'utf-8'}, () => { // poll
setImmediate(() => console.log('immediate in next tick')); //checker
setTimeout(() => console.log('time-out in tick'), 0); //timer
console.log("done")
})
console.log('end');
- node
- 初始化 node 环境。
- 执行同步代码。line 1
- 执行 process.nextTick 回调。line 5
- 执行 microtasks。line 4
- 判断事件循环是否可以执行
- timer
- 执行timer的消息队列中过期的定时器。line 3 ... line 8
- 执行process.nextTick队列中的回调任务。empty
- 执行micro task队列中的微任务。empty
- 进入下一个阶段。
- IO callbacks
- 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
- 执行process.nextTick队列中的回调任务。no
- 执行micro task队列中的微任务。no
- 进入下一个阶段
- idle,prepare
- 此处为内部调用的系统方法,一些node底层的其他任务执行在此处。开发者无法介入,暂时略过。
- ** poll**
- 检查是否有消息队列,是否异步完成。line 9
- 如果没有,则判断checker和定时器是否完成,如果有,则进入下一个阶段,如果没有,则暂停在此阶段,停止轮询,直到异步IO完成,进入下阶段。
- check
- 检查是否有setimmediate回调,如果有则执行。line 7
- 执行process.nextTick队列中的回调任务。no
- 执行micro task队列中的微任务。no
- 进入下一个阶段。
- closing
- 检查一些socket等执行情况,例如exit等
- 检查是否有存货的handler(定时器,IO等)如果有责进入下一轮循环,如果没有,退出循环。
这段代码执行的的结果如下所示:
welcome to node's world!!
end
next-tick
promise
immediate/time-out
done
immediate in next tick
time-out in tick
现在,你应该知道循环到底是做什么用的了。但如果你仔细观察输出结果,会发现immediate和time-out的输出顺序会有变化。要弄明白这个问题,我们先看一下代码:
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop);
ran_pending = uv__run_pending(loop);
uv__run_idle(loop);
uv__run_prepare(loop);
……
uv__io_poll(loop, timeout);
uv__run_check(loop);
uv__run_closing_handles(loop);
…………
这是libuv库中实现循环的主体代码。你可以看到,所有的事件循环阶段都对应着这里面函数,那个巨大的while便是循环的主体,里面的函数从名字就能看出是哪个循环的那个阶段。其中有一个uv__update_time函数,根据libuv官方文档的解释如下:
The loop concept of ‘now’ is updated. The event loop caches the current time at the start of the event loop tick in order to reduce the number of time-related system calls.
意思就是用来缓存一个系统时间作为时间循环的开始时间。这个uv__update_time函数就是用来取这个时间的。函数里面调用的是另外一个uv__hrtime函数,具体实现如下代码所示:
UV_UNUSED(static void uv__update_time(uv_loop_t* loop)) {
*/
loop->time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
}
这个函数计算出来的是clock_gettime,因为clock_gettime的时间非常非常小(十亿分之一秒),我们必须对其做转换,转换成毫秒,在代码里可以看到将它放慢了一百万倍。同时也正是因为它的精确性,所以它会受到到其他正在运行的进程的影响。
有一点需要注意,在浏览器中设置定时器的延迟时间为0,那么实际的延迟时间大概是4.4ms-6ms,而在node中,尽管我们给定时器设定的是0ms的延迟时间,实际上在内部会被转换成1ms的时间。因而我们最终是用clock_gettime取的系统时间与这个1ms对比大小:
void uv__run_timers(uv_loop_t* loop) {
…
for (;;) {
….
handle = container_of(heap_node, uv_timer_t, heap_node);
if (handle->timeout > loop->time) // 比较循环开始时间和定时器定时的时间(1ms)
break;
….
uv_timer_again(handle);
handle->timer_cb(handle);
}
当这个时间被取出来的之后,timer阶段中的回调函数便开始执行。所以如果开始时间要大于1ms,那么就会循环进入下一个阶段,如果大于1ms,那么settimeout就会被正常执行。那么如果我们把代码写成下面这样呢?
fs.readFile('./a.txt', {encoding: 'utf-8'}, () => { // poll
setImmediate(() => console.log('immediate')); //checker
setTimeout(() => console.log('time-out'), 0); //timer
});
这个结果就是会一直先打印immediate,因为我们是在poll阶段的回调函数中创建的这两个任务,poll的回调执行完成在没有其他阶段的任务的情况下,IO结束时事件循环从poll阶段重新开始,poll阶段的下一个阶段就会去到了checker阶段看是否有回调,那么它就会先执行immediate,然后才会回到timer。这样才完成了一个完整的时间循环阶段。
总结
事件循环在浏览器和node中虽然都拥有相同的名字,但大体来说其实他们工作机制非常不同,这种不同首先来自于工具所面向的对象,在浏览器中处理是为了处理各个任务的优先级,而在node中则是处理不同的IO状态。其次。在node和浏览器是哪个都是用javascript语言并且采用的都是v8引擎,但是两者的区别还是需要我们来区分的。了解了事件循环机制,对于了解代代码是如何运行的很有帮助,可以帮助你在日后设计高性能的框架时一份知识储备。