浏览器的单线程机制和事件循环
我们常常称 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 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?为了避开这种并行处理的复杂情况,索性浏览器设计者就没有给这门脚本语言多线程的能力!
Node 生态里底层其实使用了各种方式来实现异步 IO(比如在 linux 上使用 epoll 这种不完全异步的非阻塞 IO 方式)。由于 Node 会用一个独立的 epoll 线程进行 epoll,所以 IO 事件可以异步的通知到 JS 主线程,从而让 JS 主线程优雅地实现了一个更容易理解的事件循环机制。
Node 的异步 IO 实现是通过 libuv,Node.js 在多平台支持了非阻塞 IO:
- Network IO,依赖 Linux 上的 epoll,OSX 和 BSD 类 OS 上的 kqueue,SunOS 上的 event ports 以及 Windows 上的 IOCP 等内核事件通知机制。
- File IO,使用 thread pool (线程池,多线程)。
当我们说到单线程时,指的到底是什么?
说到 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 方式的封装:
- JavaScript 平台(浏览器/Node)是通过另开线程实现了异步的 IO。Node 系统底层的 libuv 使用单独的线程进行 IO,其异步 IO 又使用了真正的内核异步 IO API(epoll),而内核通过信号或回调来通知 libuv 里的 IO 线程,libuv 的 IO 线程通过事件的方式告知 Node.js 主线程。
- 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 | process.nextTick(function A() { |
这个例子会依次输出: 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 | //定义一个事件对象 |
当事件循环线程拿到这个任务后,他就可以轻而易举的执行该任务对应的 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 | //promise.then, mo, setTimeout, script 的输出代码排序 |
输出结果为:
1 | // script start |
可以发现,MutationObserve,Promise,都早于 setTimeout 被调用。MutationObserver 也属于一种 micro-task 任务事件,所以他也是优先于任务队列优先执行的。
甚至,我可以把 JavaScript 首次运行的代码本身,理解为 macrotask 类型的一个任务,所以 JavaScript 程序首次启动就会先执行它,执行完了如果有 micro-task 就开始 micro-task 的执行。
DOM 事件相关的任务队列,应该也属于 JobQueue(MicroTask)
通过测试下面的代码:
1 | document.addEventListener( |
这段代码的 click 点击的时机晚于 setTimeout 触发的时机, 但实际测试结果是 click 事件 handler 永远会在 timer handler 之前触发。而实际上通过事件循环的理解,timer 在单独的线程中运行,并在 0 秒后会将待执行任务回调放入主线程会访问的 事件队列
中。因此,理论上 timer的事件任务
应该排在 click事件任务
的前面。 但实际执行后,却发现永远都是 click hanler
优先触发。
原因在于: 虽然 setTimeout 定时器的回调任务更早放入了任务队列,但我认为很可能 click 的这种 Dom Event 事件,放入了更高优先级的 microTask 任务队列,所以 click 的事件任务被优先处理了。
然而当我开始做实验对比 click 和 Promise 微任务谁先触发的时候,又发现 Promise 任何时候永远都比 click 事件触发要更早,哪怕我点击 click 时机 要比 Promise 注册更早,这让我有点郁闷:到底 dom click 是微任务还是宏任务,还是介于两者之间的一个任务—-即他总是比其他宏任务要快,因为要及时响应用户操作提高流畅度,但他又比微任务要晚—因为微任务是一旦 js 空闲后最需要优先清空的队列。
于是我可以认为:dom 交互操作事件是一种具有高优先级的宏任务,这样看起来更容易理解一些。
备注:
这里要注意点,在做实验的时候,务必不要通过 ele.click() 或者 dispatchEvent 的方式来触发 click 事件,因为这样触发的 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 | setImmediate(function A() { |
分析上面这段代码, process.nextTick, 永远先执行.
setImmediate 和 setTimeout, 那个先到时那个先执行. 如果同时, 则由系统调度负责.
由于 nextTick()会插队执行, 因此, NodeJS 限制了 nextTick()递归调用的深度. 防止 IO 处理饥饿.一直在处理 nextTick(). 由于该原因, 递归时, NodeJS 建议使用 setImmediate()完成.
setInterval 不会持续累加
另外要注意: setInterval 这个底层任务,不会累积在任务队列。经过测试发现,当任务队列中已有一个 serInterval 的回调还未被主线程执行的话,setInterval 就算第二个时间到了也不会重复放入任务队列。所以在 setInterval 得不到触发之前,是不会有新的 setInterval 任务被放入 macroTask 任务队列的。
因此在 JS 主线程繁忙时,你不需要担心 setInterval 的触发事件不会无畏地累加到一起触发。
测试代码如下:
1 | // sleep忙等待函数的简单实现,durationTime表示sleep的秒数 |
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
JavaScript: How Is Callback Execution Strategy For Promises Different Than DOM Events Callback
Vue.js 升级踩坑小记
从浏览器多进程到 JS 单线程,JS 运行机制最全面的一次梳理