[学习]-浏览器的单线程机制和事件循环

我们常常称JavaScript为一种单线程的语言. 比如Node.js出现后,也自称是使用了 异步的非阻塞的事件驱动的IO模型(实际上也在表达Node.js的代码执行线程只有一个)。

  • 这些概念到底该怎么理解呢?
  • 浏览器、Node.js、JavaScript语言,他们是说的一个东西吗?
  • 如果不是,他们之间又是什么关系呢?
  • 浏览器里真的是单线程的吗?
  • 既然单线程可为什么你的网页渲染和ajax请求是并行的呢?

这些疑问也是我个人在学习过程中的疑问,正是出于对这些疑问的学习,才有了本文。其实上面说的这些概念正阐述了JavaScript运行平台的实现机制,所谓治病一定要找到病灶,本文会对浏览器和Node.js这两个典型平台上的JavaScript运行机理进行细致的阐述,搞懂他们对于JavaScript代码编写具有很大的指导意义;由于掺杂了个人理解在里面,受限于本人水平,难免会有错误,还望帮我指出。

在阅读本文之前,我们需要对异步IO有一定的了解和认识,我有一篇文章异步IO应该怎么理解分析了文件IO的多种类型。本文将在此基础上,主要围绕JavaScript平台上的事件循环机制,揭示JavaScript异步编码的原理

同步异步的概念回顾

在另一篇文章[同步异步以及阻塞非阻塞]中,我专门讨论了下同步、异步、阻塞、非阻塞之间的关系。这里我们做简要的回顾。

同步与异步

同步异步是指程序的行为。同步(Synchronous)是程序发出调用的时候,一直等待直到返回结果,没有结果之前不会返回。也就是说,同步是调用者主动等待调用过程的结果。(注意:在文件IO领域,也有发出调用后可以立即返回的同步情景,叫做同步非阻塞,具体可以查看我的: 异步IO应该怎么理解

异步(Asynchronous)是发出调用之后,马上返回,但是不会马上返回结果。调用者不必主动等待,当被调用者得到结果之后会主动通知调用者。异步可以用多线程来实现。

阻塞与非阻塞

阻塞与非阻塞是指的一种等待状态。阻塞(Blocking)是指调用在等待的过程中线程被“挂起”(CPU资源被分配到其他地方去了)。

非阻塞(Non-blocking)是指等待的过程CPU资源还在该线程中,线程还能做其他的事情。

简单总结同步异步

我们大可不必细究其字眼细节。我们只需大概知道我们”通常所说的同步都是阻塞的”(直到调用返回结果),就是主线程调用一个API时要等待他的结果后才往下执行;而异步”通常说的是非阻塞的异步”,是指主线程调用后主线程继续往下执行,被动接受那个异步开始的任务的后续通知。

实际上,在操作系统中使用多线程技术可以实现异步(把本来同步的东西做成异步的),其实在JavaScript当中,所有的异步任务都是靠多线程封装来实现的。JavaScript里所谓的发起IO实际上也不是直接针对底层发起的IO;所谓的回调,其实已经不是真正的系统底层IO进行的回调了。

理解单线程的JS

进程(Process)是系统资源分配和调度的单元。一个运行着的程序就对应了一个进程。一个进程包括了运行中的程序和程序所使用到的内存和系统资源。线程(Thread)是进程下的执行者,一个进程至少会开启一个线程(主线程),也可以开启多个线程

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?为了避开这种并行处理的复杂情况,索性浏览器设计者就没有给这门脚本语言多线程的能力!

当我们说到单线程时,指的到底是什么?

说到JS单线程还是多线程得结合具体运行环境。JS的运行通常是在浏览器中进行的,具体由JS引擎去解析和运行,这里所说的JS单线程其实应该理解为 JS引擎 是单线程的(你的JS业务代码是在单线程中执行的),在不使用webWork等API的前提下你无法书写并行的代码(你在JavaScript语言中你无法创建新的线程)。但其实本质上,一张网页在浏览器中工作实际上浏览器不止使用了一个线程来运行它的一些任务 (如调用浏览器提供的WebAPI–XMLHttpRequest,SetTimeout等), 还是利用了多线程和操作系统的任务调度的。 当异步任务操作在后台被处理完成后(例如ajax接收完毕了服务器的响应), 会将结果告知给JavaScript执行线程(通过事件队列), 并最终被JavaScript执行线程来执行.
https://segmentfault.com/a/1190000009579127

JavaScript这种单线程的设计,让编写JS程序特别简单,它让这些弱智低智商的开发者在编写代码时再也不用担心多线程带来的问题。而且其中采用异步IO,消息事件通知的方式,给主线程腾出了宝贵的时间,让JavaScript可以有更多CPU时间去处理各种任务。

浏览器中的线程

为了避免UI上的复杂性,从一诞生,JavaScript的 执行引擎 就是单线程,能修改DOM的只有这一个线程。这已经成了这门语言的核心特征,将来也不会改变。为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript的主执行引擎是单线程的本质。

虽然我们常说JavaScript是单线程的,也经常说Node.js是单线程的非阻塞IO。但实际上浏览器的内核是多线程的,因为浏览器除了执行js代码,还有很多事情要做,例如处理用户DOM事件(操作系统只会把UI事件扔给浏览器某个进程,进程里是如何调用到具体handler函数的,总得需要有个线程去处理这件事),例如处理Timer(比如执行setTimeout后,要计时并确定应该触发哪个timer回调函数)。所以一个浏览器通常由以下几个常驻的线程:

  • 渲染引擎线程:顾名思义,该线程负责页面的渲染
  • JS引擎线程:负责JS的解析和执行
  • 定时触发器线程:处理定时事件,比如setTimeout, setInterval
  • 事件触发线程:处理DOM事件
  • 异步http请求线程:处理http请求

如果没有这些线程的存在。可能你的一句 setTimeout(fn, 10000) 就能导致你的主线程卡死。

JS引擎可以说是JS虚拟机,负责JS代码的解析和执行。通常包括以下几个步骤:

  • 词法分析:将源代码分解为有意义的分词
  • 语法分析:用语法分析器将分词解析成语法树
  • 代码生成:生成机器能运行的代码
  • 代码执行
    不同浏览器的JS引擎也各不相同,Chrome用的是V8,FireFox用的是SpiderMonkey,Safari用的是JavaScriptCore,IE用的是Chakra.

需要注意的是,渲染线程和JS引擎线程是不能同时进行的。渲染线程在执行任务的时候,JS引擎线程会被挂起。因为JS可以操作DOM,若在渲染的过程中JS又处理了DOM,浏览器可能就不知所措了。 这里也解释了那句经典的 “为什么不能把script标签放在html头部”, 因为JavaScript执行会阻塞渲染线程的渲染,他俩是互相阻塞的。在日常编码过程中,我们也不要经常对DOM进行修改,因为改动DOM会引起渲染,渲染过程会暂停JavaScript代码的执行。

Node.js中的线程机制

Node.js中跟浏览器中的js是类似的,整个Node.js平台暴漏出来的也是单线程的JavaScript编程。

一般来说,高并发的解决方案就是提供多线程模型,服务器为每个客户端请求分配一个线程(或者进程,取决于配置文件),使用同步 I/O,系统通过线程切换来弥补同步 I/O 调用的阻塞时间开销。比如 Apache 就是这种策略,由于 I/O 一般都是耗时操作,因此这种策略很难实现高性能,但非常简单,可以实现复杂的交互逻辑。Apache随着并发连接数的增加以及为了服务更多的并发客户端需要新建更多的线程,对内存消耗较大且存在一个上下文切换的CPU消耗问题。

而事实上,大多数网站的Web服务器这一层都不会做太多的计算,它们接收到请求以后,把请求交给其它服务来处理(比如读取数据库),然后等着结果返回,最后再把结果发给客户端。因此,Node.js 针对这一事实采用了单线程模型来处理,它不会为每个接入请求分配一个线程,而是用一个主线程处理所有的请求,然后对 I/O 操作进行异步处理,避开了创建、销毁线程以及在线程间切换所需的开销和复杂性。Node.js和Nginx它们都是单线程的,但是是事件驱动。通过在一个线程中处理多个连接,这消除了由上千个线程/进程所产生的系统消耗

可见,Node.js里实际上执行异步任务的对象是libuv里的一个线程(当然,这个libuv线程最终还是要走内核来IO),但是对JS引擎来说其实是他的操作直接对象是交给另外一个线程来做IO的事情了,所以不严谨地说的话,可以说Node.js里的异步IO是通过线程来实现的。

单线程与异步IO的关系

Node.js平台暴漏给JavaScript语言的异步IO是典型的对底层异步IO方式的封装:

  1. JavaScript平台(浏览器/Node)是通过另开线程实现了异步的IO。Node系统底层的libuv使用单独的线程进行IO,其异步IO又使用了真正的内核异步IO API(epoll),而内核通过信号或回调来通知libuv里的IO线程,libuv的IO线程通过事件的方式告知Node.js主线程。
  2. JavaScript里感受到的异步IO通知并不是真正的IO回调,它已经被底层封装过了。在操作系统底层,所谓异步IO的回调是通过系统给进程发送信号等中断机制来实现的,确实是内核通知或回调进程里的代码来实现了通知; 而在JavaScript平台上,之所以你写的异步IO代码可以被动得执行回调函数,是由JavaScript主执行引擎里的EventLoop事件循环线程实现的。JavaScript平台底层libuv收到底层通知后,会给任务装入IO数据,放入会被JavaScript主线程轮询的 任务/事件队列 中,从而JavaScript主线程才能轮询到任务进行执行。 所以JavaScript的这个回调机制,是其主线程配合其他线程实现了一套异步的事件机制,表面上看起来是被回调的效果。

从网页或Node.js开发者编码的角度,JavaScript看起来就是发起异步任务,并且异步任务完成后会自动执行回调的样子。我们完全可以认为用JavaScript进行的相关开发就是异步非阻塞的IO。

Event Loop 事件循环

上文中,我们提到2个词—“主线程”、”JS执行引擎线程”。在这里,我说下我的理解:

  • 任何程序中都会有个线程是主线程。我认为主线程是程序的入口
  • 由于JavaScript是解释性的语言,所以JS在执行时需要有一个引擎来执行它

那么,在JavaScript的运行时(浏览器或Node)里面,到底谁是JavaScript的主线程呢?JS执行引擎负责执行JavaScript,所以它就是浏览器或者Node.js里的主线程吗?

我个人认为,作为整个网页或Node程序的主线程,必然是下文即将要讲的 事件循环 线程。而所谓的”JavaScript是需要在单线程的JS引擎内执行的”,这句话是没毛病,但这个JS引擎是否需要单独开启一个线程来执行JavaScript我觉得可以依据不同的实现而考量: 这个过程可以是”事件循环线程”去把JavaScript扔给”引擎线程”来执行,也可以是事件循环线程自身内部使用V8来执行JavaScript。总之,这里可能有1个线程来实现也可以有2个线程来实现,但我们要记住,作为主线程的必然是承担 事件循环 角色的这个线程。

执行栈和任务队列

要理解事件循环,我们需要理解JavaScript执行时有哪几种逻辑结构的存在。我们先来看一下这样一个Node中的例子

1
2
3
4
5
6
7
8
9
process.nextTick(function A() {
console.log(1);
process.nextTick(function B(){console.log(2);});
});
console.log('main');
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0)

这个例子会依次输出: main, 1, 2, TIMEOUT FIRED.

为什么nextTick会优先于setTimeout触发, 这里涉及到两个概念 “执行栈” 和 “事件队列”, “执行栈”也叫”调用栈”,”事件队列”我又称之为 “任务队列”。

对于执行栈来说,我们写过程序的应该比较容易理解,他是一个程序执行过程中,函数的相关参数、指针等的入栈出栈动作,基于栈这样的数据结构,实现了函数执行过程中上下文的保存、跳转。如下图:

那么,JavaScript中遇到setTimeout或ajax这样的异步API调用时,实际上并没有放入执行栈执行其回调函数。它只是向底层线程发起了一个异步任务,异步任务完成后,会放入主线程的事件队列。进而主线程从队列中拿到任务,再执行回调。其大概流程是这样的:

可我们左侧这个JS线程是单线程的,所以该主线程在执行栈中还有未完成的工作时,setTimeout的回调函数就得不到执行,需要等待调用栈变空。回调函数等待执行的中间状态被称为任务队列(又称作事件队列、消息队列)。

现在,可以回答上面那个代码示例的问题了。由于nextTick是Node.js中提供的一个可以将任务放入执行栈最底端的API,所以你会很容易明白,对于单线程的JavaScript主线程来说,执行nextTick永远会优先于去执行异步任务队列里的任务的哦!

事件循环


我自己的理解是这样的:左侧的JavaScript线程可以理解为一个基于V8引擎的执行线程;当然,上文中我也有说过: 它也可以作为中间EventLoop线程的一部分功能。中间的黄色EventLoop就是事件循环线程,他是JavaScript代码和回调任务可以被执行的关键。

当一个JS程序启动时相当于启动了两个东西–【V8JS执行线程(可以没有本线程)】和【EventLoop线程】。V8这个只负责被扔进js代码来执行,EventLoop线程才控制着整个页面的最核心的东西-事件循环。 理解JS的运行机制没必要去研究这个V8执行引擎线程,而应该研究这个EventLoop事件循环。

自从页面或JS程序启动后,首要任务是启动EventLoop,然后你的所有JS代码会被执行首次(我认为EventLoop会把你的JS代码当做第一个MacroTask任务放入了任务队列执行,参考依据: http://mp.weixin.qq.com/s?__biz=MzA4NjE3MDg4OQ==&mid=2650965567&idx=1&sn=824b930b0494fd9a3030519c03c1124b&chksm=843aea59b34d634f5e4718ae59549a4cbe5552ce669aa38b8889b0ddfe92065412db31137292&mpshare=1&scene=23&srcid=1123aX3fgRUjTVbTOTf97oIZ#rd),事件循环启动后,自然就获取到这第一个任务代码开始执行。之后就靠EventLoop事件循环来获取任务队列中的其他事件任务来执行JS回调代码了,比如页面UI事件、AjaxIO事件、Timer事件等。JS执行线程是单线程的,因此EventLoop也会等JS执行线程空闲时,才从任务队列中选取任务,交给JS线程来执行。我认为当JS线程空闲且任务队列中暂时没有任务时,EventLoop就会不停地轮询任务队列,直到里面有了任务,则EventLoop继续取出来交给引擎来执行。

Node.js中的事件循环

Node.js由于直接属于一个完整的编程平台,所以其功能更广泛,但其事件循环的原理跟浏览器也都是类似的。Node.js里的事件循环是靠libuv实现的,浏览器中应该也是浏览器内核里一个类似libuv的程序实现的。关于libuv事件循环的具体实现机制,可参考: Node.js 探秘:初识单线程的 Node.js。 我们在这里大概瞅一下:

我认为Node.js中的主线程就是图中间的EventLoop事件循环,他负责调度并发起异步任务,并负责调度并执行任务回调的JavaScript代码(具体的执行会交给V8来做)。 而且libuv中还实现了线程池的机制,在进行IO操作时,使用线程池进行各种不同IO类型理。

任务队列中的任务,可以看做类似这样的数据结构:

1
2
3
4
5
6
//定义一个事件对象
var event = createEvent({
params:request.params, //传递请求参数
result:null, //存放请求结果
callback:function(){} //指定回调函数
});

当事件循环线程拿到这个任务后,他就可以轻而易举的执行该任务对应的callback回调了; 即,当JavaScript进行IO操作时,会扔一个这样的对象给底层libuv的线程池,然后底层libuv会选择线程来执行该IO操作。执行完成之后IO的结果会放入result属性,并会通过回调的方式,将event这个对象放入 事件队列/任务队列。 然后,libuv的主线程事件循环就会相当于一个 while(true) 的死循环,他会读到任务队列中有新的事件发生,就会执行新加入的任务中的callback函数,执行时会把result放入callback作为参数。

所以可以理解为,Node.js中跟浏览器中的表现是一致的—都是通过单线程来执行JavaScript代码,使用异步IO来执行IO任务,使用事件循环任务队列的机制来执行回调代码。这种方式能提高单线程的CPU利用率,不过一旦JavaScript主线程自身执行JavaScript时被阻塞了,那么事件循环就无法得到执行,从而无法执行任务队列中的新任务。

关于事件循环的底层原理,例如其libuv的事件循环实现,比如nodeAPI跟底层C++的关系,在这篇文章中讲的比较透彻了: http://taobaofed.org/blog/2015/10/29/deep-into-node-1/

以上事件循环的理解主要参考这篇文章: http://www.zcfy.cc/article/node-js-at-scale-understanding-the-node-js-event-loop-risingstack-1652.html

不止一个任务队列—Promise与EventLoop中的JobQuery

上面我们已经基本上理解了浏览器和Node中的事件循环机制。但实际上,在任务队列这一块,Node和浏览器里都有做更多的手脚,比如: 一个浏览器环境下,只有一个事件循环,但有多个任务队列,每个任务都有一个任务源。相同任务源的任务,只能放到一个任务队列中,不同任务源的任务放到不同的任务队列中。

在浏览器中,总体上看任务队列有两种:marcrotask,microtask(又叫JobQuery)。marcrotask与microtask有不同的执行顺序. 在每一次tick中,会执行macro-task的一个任务,执行完,执行microTask queue中所有任务(如果有),再执行macroTask queue的下一个任务,直至任务队列清空。

其实可以理解为如果一旦micro-task小型任务开始执行,如果它的队列没被清空,macro-task异步任务队列就一直得不到执行。所以放入micro-task队列的一般都是优先级较高的类型–如Promise的then,DOM变更的回调等。

规范中定义

  • macro-task: script(整体代码),setTimeout,setInterval,setImmediate,I/O,IO rendering
  • micro-task: process.nextTick,Promises(浏览器原生Promise),Object.observe,MutationObserver

也就是说,macro-task才是我们之前常常说的任务队列。而实际上浏览器里还有个micro-task的存在,比如在原生支持Promise的浏览器里,Promise的then回调如果和setTimeout同时放入任务队列,then永远优先于setTimeout来执行

规范的规定也验证了网上这个人的猜测:
https://github.com/creeperyang/blog/issues/21
他发现setTImeout要晚于Promise的then执行,于是感叹:

setTimeout的异步和Promise.then的异步看起来 “不太一样” ——至少是不在同一个队列中。

Node.js中的任务队列类型更复杂

Node.js中的事件循环不像浏览器中那么简单,主要表现在它的任务队列有很多。尽管其回调执行时总是同一时刻只能执行一个异步任务,但是由于异步任务队列有多种类型,所以其异步回调执行的顺序是有区别的。

这篇文章https://segmentfault.com/a/1190000009579127 中讲到Node.js的事件循环是针对不同类型事件有优先级的,在处理完IO后, 需要处理check(setImmediate的一种类型), 因此setImmediate会先触发,setTimeout会后触发. 关于这一点,后文会有更多说明。

举例学习

看下面例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//promise.then, mo, setTimeout, script 的输出代码排序
console.log('script start')
let start = +new Date()
setTimeout(()=>{
console.log('setTimeout ',+new Date() - start)
}, 0)
Promise.resolve().then(()=>{
console.log('promise then ', +new Date() - start)
});
let mo = new MutationObserver(()=>{
console.log('mutation observer ', +new Date() - start)
})
let textNode = document.createTextNode('0');
//MO观察dom
observer.observe(textNode, {
characterData: true //观察characterData属性变化(TextNode的data值)
})
textNode.data = '1';
console.log('script end')

输出结果为:

1
2
3
4
5
// script start
// script end
// promise then
// mutation observer
// setTimeout

可以发现,MutationObserve,Promise,都早于setTimeout被调用。MutationObserver也属于一种micro-task任务事件,所以他也是优先于任务队列优先执行的。

甚至,我可以把JavaScript首次运行的代码本身,理解为macrotask类型的一个任务,所以JavaScript程序首次启动就会先执行它,执行完了如果有micro-task就开始micro-task的执行。

DOM事件相关的任务队列,应该也属于JobQueue(MicroTask)

通过测试下面的代码:

1
2
3
4
5
6
7
8
9
10
11
document.addEventListener('click', function (e) {
alert('click handler')
}, false)
setTimeout(function () {
alert('timer handler')
}, 0)
var startTime = new Date()
while ((new Date()).getTime() - startTime < 5000) {
}

这段代码的click点击的时机晚于setTimeout触发的时机, 但实际测试结果是click事件handler永远会在timer handler之前触发。而实际上通过事件循环的理解,timer在单独的线程中运行,并在0秒后会将待执行任务回调放入主线程会访问的 事件队列 中。因此,理论上 timer的事件任务 应该排在 click事件任务 的前面。 但实际执行后,却发现永远都是 click hanler 优先触发。

原因在于: 虽然setTimeout定时器的回调任务更早放入了任务队列,但我认为很可能click的这种Dom Event事件,放入了更高优先级的microTask任务队列,所以click的事件任务被优先处理了。

Node中的nextTick和setImmediate

在Node.js中,增加了额外两个方法,方便你对执行时机进行控制。其本质上是可以实现对 执行栈任务队列 的简单操作。

其中,nextTick是将一个任务扔到当前执行栈的末尾。相当于在主线程执行异步IO通知事件回调之前,总会先执行nextTick的任务。其实从名字也可以看出来,是在下一个tick执行之前来执行。因此, 在时间上看, 一定先于settimeout(callback, 0)和setImmediate()执行. 通常用来处理在下一个事件周期(异步任务)前, 必须要处理好的任务. 常见的有, 处理错误, 回收资源, 和 重新执行存在错误的操作等. 在《奇舞周刊》的这篇文章中,把Node.js中的nextTick函数认为是一种micro-task任务,我认为数有错误的,只是从表现上看没有问题。

而setImmediate是相当于setTimeout(xx, 0), 即会将任务放入macrotask事件队列末尾。从而,可以在主线程空闲时其microtask事件队列清空后,能够尽快执行到这个任务。setImmediate经过测试,跟setTimeout效果差不多, 无法保证他一定在setTimeout之前发生(可前可后)。那么到底什么情况下有优先级,如果需要深究则需要查找更多资料了(有些文章认为在IO操作的回调函数中发起的setImmediate会比setTimeout(xx, 0)的执行时机要早,但我无法考证)。

1
2
3
4
5
6
7
8
setImmediate(function A() {
console.log(1);
setImmediate(function B(){console.log(2);});
},0);
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0);

分析上面这段代码, process.nextTick, 永远先执行.
setImmediate和setTimeout, 那个先到时那个先执行. 如果同时, 则由系统调度负责.

由于nextTick()会插队执行, 因此, NodeJS限制了nextTick()递归调用的深度. 防止IO处理饥饿.一直在处理nextTick(). 由于该原因, 递归时, NodeJS建议使用setImmediate()完成.

setInterval不会持续累加

另外要注意: setInterval这个底层任务,不会累积在任务队列。经过测试发现,当任务队列中已有一个serInterval的回调还未被主线程执行的话,setInterval就算第二个时间到了也不会重复放入任务队列。所以在setInterval得不到触发之前,是不会有新的setInterval任务被放入macroTask任务队列的。

因此在JS主线程繁忙时,你不需要担心setInterval的触发事件不会无畏地累加到一起触发。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// sleep忙等待函数的简单实现,durationTime表示sleep的秒数
var sleep = function (durationTime) {
var startDate = Date.now() / 1000;
while(true) {
var nowDate = Date.now() / 1000;
if (nowDate - startDate > durationTime) {
break;
}
}
}
// 定时器每3毫秒就触发一次。但主线程需要8秒来执行单次任务。在主线程执行完毕后,任务队列中并没有累加8000/3个setInterval的回调任务。故可以认为setInterval不会让任务队列中累积自己的任务。
setInterval(function () {
console.log('chufale');
sleep(8);
}, 3)

Vue.js中的nextTick

Vue.js中的nextTick目的是为了在DOM异步更新完毕后来执行. 其源码实现中,优先使用了MutationObserve来实现,因为MutationObserve属于micro-task,它能保证在DOM变化之后且其他异步任务回调之前来优先执行。这就有点像Node.js中nextTick的感觉了。 Vue.js源码中在浏览器不支持MutationOberve时使用了setTimeout的方案,这种方案则会将nextTick推迟到异步任务队列尾部执行,可能会稍晚一点咯。
这篇文章和内网km上的一篇文章讲到了nextTick的实现,推荐看内网km上的那篇。https://www.lxxyx.win/2016/09/25/2016/Vue-nextTick-%E6%BA%90%E7%A0%81%E8%A7%A3%E8%AF%BB/

思考:

由这个前端nextTick的实现,我们可以发现一个问题: 浏览器在什么时刻去渲染DOM呢?

我们可以发现浏览器是在执行栈清空后去渲染DOM,且渲染DOM的过程中JavaScript被阻塞。

思考: 所以js适合什么

虽然看起来,Node.js 可以做很多事情,并且拥有很高的性能。但凡事没有百分百完美的。

做聊天室,搭建 Blog 等等,这些 I/O 密集型的应用,是比较适合 Node.js 的。但是,有一种类型的应用,可能 Node.js 处理起来会比较吃力,那就是 CPU 密集型的应用。前文提到,Libuv 通过事件循环来处理异步的事件,这是存在于 Node.js 主线程的机制。通过这个机制,所有的 I/O 操作,底层API的调用都变成了异步的。但用户的 Javascript 代码是运行在主线程中的,如果这部分代码运行耗时很长,就会导致事件循环被阻塞。因为,它对于事件的处理,都是按照队列顺序的,所以如果其中的任何一个事务/事件本身没有完成,那么其他的回调、监听器、超时、nextTick() 都得不到运行的机会,被阻塞的事件循环没有机会去处理它们。这样下去,轻则效率降低,重则运行停滞。

所以:

  • 不适用: 模板渲染,压缩,解压缩,加/解密等操作. 这都是 Node.js 的软肋,所以使用的时候要考虑到这方面。
  • 适用: RESTful API: 请求和响应只需少量文本,并且不需要大量逻辑处理, 因此可以并发处理数万条连接。
    聊天服务: 轻量级、高流量,没有复杂的计算逻辑。

Node.js不会造成IO请求瓶颈吗

你会发现,Node.js虽然能及时处理很多并发请求,但实际上他快速处理各个请求之后,却把大量的并发IO请求扔给了更后方的一层。 这依然造成了IO操作并发的加大,假设IO请求并发时也是起进程、或者排队的机制,那岂不是还是有瓶颈在这里?以前Apache模式的压力都在Web Server这块(由于为请求分配太多进程内存被爆了), 现在压力跑到了Web Server后方的IO这块,这可如何是好???

不过还好,IO这块一般是由操作系统来兜底的,实现上性能要高,另外Node.js也说了更适合那种RestAPI应用,其实就是主要应用在聚合各种后方服务器的API数据咯。比如请求各种DB服务器拿数据,请求各种后方或第三方服务来拿数据,再以json格式返回给前端。在这种场景下,所谓的IO其实都是网络IO,实际的计算和磁盘IO等都交给了性能更好的后台服务器上面,而网络IO又由操作系统网卡兜底,所以Node.js只管做好自己的本职工作: 给请求提供快速的响应即可。 这是Node.js可以充分体现自己高性能的优势,而IO压力又扔给了后方各种各样强大的服务器(反正他们保证可以快速返回资源给Node.js这边即可),最终Node.js这台服务器上的Server性能总归是很高的!

总结

「简单地说,JavaScript 是单线程执行的语言」,现在可以说得稍微复杂一点了:JavaScript Engine 对 JavaScript 程序的执行是单线程的,但是 JavaScript Runtime(整个宿主环境)并不是单线程的;而且,几乎所有的异步任务都是并发的,例如多个 Job Queue、Ajax、Timer、I/O(Node)等等。

上面说的是 JavaScript Runtime 层面,JavaScript 执行本身,也有一些特殊情况,例如:一个 Web Worker 或者一个跨域的 iframe,也是独立的线程,有各自的内存空间(栈、堆)以及 Event Loop Queue

在js当中的所谓异步其实就是靠其他线程将回调任务放入主线程的任务队列,主线程主动执行的。 所以在使用JavaScript的浏览器平台和Node.js平台上,这个”异步”的概念其实挺特殊的,跟底层的异步IO是不太一样的。

你的JS执行时,主线程就已经跑起EventLoop来了。你写的所有JS代码只是被运行时的主线程作为某个事件的handler触发运行而已,你的JavaScript代码并不是整个运行进程的main函数。你的JavaScript对自己所在的主线程根本没有完全的控制能力(比如无法创建线程,自己不是起始运行代码).

你自己写的JS代码,只不过是JS执行线程里的一个会被首次执行的MacroTask任务而已

Refer

turning-to-javascript-series-from-settimeout-said-the-event-loop-model
JavaScript单线程异步的背后——事件循环机制
浅析 Node.js 单线程模型
Javascript是单线程的深入分析
JavaScript单线程事件循环(Event Loop)那些事
理解Node.js中的Event Loop
再谈EventLoop