
JavaScript为什么是单线程的呢?由于设计之初,JavaScript是用来做用户交互以及页面动态渲染,所以为了简洁和方便入手,决定了它只能是单线程,否则将会带来非常复杂的同步问题。
随着多核CPU的兴起,单线程的JavaScript不在能满足需求,HTML5标准的提出带来了Web Worker相关的一整套API,它允许JavaScript程序运行在主线程之外的另外一个线程中,但是由于worker线程是严格收到主线程控制且无法控制dom,限制重重,所以这个HTML5标准的提出并没有改变浏览器中JavaScript单线程运行的本质。
浏览器我们都知道,是典型的多进程模型,但是JavaScript的运行时却是单线程的架构,我们来从浏览器出发看看具体JavaScript的运行时在浏览器中处于什么位置。
多进程的浏览器主要分为以下几个主要的进程:
了解一下Chrome的四种启动模式:
www.baidu.com和www.baidu.com/fanyi就属于同一个域名范畴内,无论其是否有互相打开的关系。使用命令行参数-process-per-site开启。相信聪明的大佬已经感受到了,JavaScript就是在Renderer进程中运行,我们来看下Renderer进程到底有哪些线程。
GUI渲染线程负责渲染浏览器界面,解析HTML/CSS,构建DOM树和RenderObject树,负责布局和绘制等。
JS引擎线程,故名思义就是运行JS引擎的线程,换句话就是浏览器的内核,负责处理JavaScript脚本程序(例如Chrome的V8内核)。
JS引擎线程只做一件事情,就是等待JavaScript然后运行它,浏览器同一个Renderer进程中只能有一个JS引擎线程运行,所以我们说JS是严格单线程执行的。
注意:
事件触发线程不属于JS引擎,属于浏览器,主要作用是用来控制事件循环并且管理着一个事件队列task queue。
当js执行碰到事件绑定和一些异步操作(如setTimeOut,也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会走事件触发线程将对应的事件添加到对应的线程中(比如定时器操作,便把定时器事件添加到定时器线程),等异步事件有了结果,便把他们的回调操作添加到事件队列,等待js引擎线程空闲时来处理。
当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
因为JS是单线程,所以这些待处理队列中的事件都得排队等待JS引擎处理。
定时触发器线程即JS引擎提供的api:setInterval与setTimeout所在线程。
浏览器定时计数器并不是由JavaScript引擎计数的(因为JavaScript引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确)。
通过单独线程来计时并触发定时(计时完毕后,添加到事件触发线程的事件队列中,等待JS引擎空闲后执行),这个线程就是定时触发器线程,也叫定时器线程。
注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms
在XMLHttpRequest在连接后是通过浏览器新开一个线程请求。
将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中再由JavaScript引擎执行。
简单说就是当执行到一个http异步请求时,就把异步请求事件添加到异步请求线程,等收到响应(准确来说应该是http状态变化),再把回调函数添加到事件队列,等待js引擎线程来执行
了解JavaScript的执行机制前我们需要先来了解一下大家经常说的一个概念:宏任务和微任务。
在JavaScript中任务大致上分为两种任务:
由于宏任务是同步执行且不会中断,在JS引擎优先级高于GUI引擎的基础上,执行机制也从【宏任务->GUI->宏任务… …】变更为【宏任务->微任务->GUI->宏任务… …】。
需要注意的是:每一个宏任务执行完毕后都会去清空微任务队列,如果清空的过程中又产生了微任务,则将不会执行下一个宏任务,而是继续清空微任务队列。
我们来看下MDN中对宏任务和微任务的说明:
一个 任务 就是由执行诸如从头执行一段程序、执行一个事件回调或一个 interval/timeout 被触发之类的标准机制而被调度的任意 JavaScript 代码。这些都在 任务队列(task queue)上被调度。
在以下时机,任务会被添加到任务队列:
<script>元素中运行代码)。setTimeout() 或 setInterval() 创建的 timeout 或 interval,以致相应的回调函数被添加到任务队列时。事件循环驱动你的代码按照这些任务排队的顺序,一个接一个地处理它们。在当前迭代轮次中,只有那些当事件循环过程开始时 已经处于任务队列中 的任务会被执行。其余的任务不得不等待到下一次迭代。
一个 微任务(microtask)就是一个简短的函数,当创建该函数的函数执行之后,并且 只有当 Javascript 调用栈为空,而控制权尚未返还给被 user agent 用来驱动脚本执行环境的事件循环之前,该微任务才会被执行。事件循环既可能是浏览器的主事件循环也可能是被一个 web worker 所驱动的事件循环。这使得给定的函数在没有其他脚本执行干扰的情况下运行,也保证了微任务能在用户代理有机会对该微任务带来的行为做出反应之前运行。
微任务和宏任务有两个重要的区别:
queueMicrotask(), 向队列中加入了更多的微任务,则那些新加入的微任务 会早于下一个任务运行 。这是因为事件循环会持续调用微任务直至队列中没有留存的,即使是在有更多微任务持续被加入的情况下。注意: 因为微任务自身可以入列更多的微任务,且事件循环会持续处理微任务直至队列为空,那么就存在一种使得事件循环无尽处理微任务的真实风险。如何处理递归增加微任务是要谨慎而行的。
就其本身而言,应该使用微任务的典型情况,要么只有在没有其他办法的时候,要么是当创建框架或库时需要使用微任务达成其功能。虽然在过去要使得入列微任务成为可能有可用的技巧(比如创建一个立即 resolve 的 promise),但新加入的 queueMicrotask() 方法增加了一种标准的方式,可以安全的引入微任务而避免使用额外的技巧。
通过引入 queueMicrotask(),由晦涩地使用 promise 去创建微任务而带来的风险就可以被避免了。举例来说,当使用 promise 创建微任务时,由回调抛出的异常被报告为 rejected promises 而不是标准异常。同时,创建和销毁 promise 带来了事件和内存方面的额外开销,这是正确入列微任务的函数应该避免的。
简单的传入一个 JavaScript Function ,以在 queueMicrotask() 方法中处理微任务时供其上下文调用即可;取决于当前执行上下文, queueMicrotask() 以定义的形式被暴露在 Window 或 Worker 接口上。
queueMicrotask(() => {
/* 微任务中将运行的代码 */
});微任务函数本身没有参数,也不返回值。
在了解了以上概念之后,我们正式开始介绍JavaScript在浏览器中的执行机制。

解释,由上图我们可以大致了解到JS引擎维护了一个任务执行栈,JavaScript是严格按照以下的顺序进行执行:
当然,文字描述比不上动画更形象:loupe
了解到了上述的执行逻辑,我们对JavaScript每一行代码都在什么时间节点执行就有了大致的印象,来看下面一段代码:
console.log('start');
setTimeout(() => {
console.log('children2');
Promise.resolve().then(() => {
console.log('children3');
});
}, 0);
new Promise(function(resolve, reject) {
console.log('children4');
setTimeout(function() {
console.log('children5');
resolve('children6');
}, 0);
}).then((res) => {
console.log('children7');
setTimeout(() => {
console.log(res);
}, 0);
});
// [Log] start
// [Log] children4
// < Promise {status: "pending"}
// [Log] children2
// [Log] children3
// [Log] children5
// [Log] children7
// [Log] children6解析:(注释是在Safari浏览器中执行的结果)
在继续看代码前,我们需要来简单的了解一下await/async
Async/Await是在ES2017标准提出的两个操作符,或者说是语法糖。
await 操作符用于等待一个Promise 对象。它只能在异步函数 async function 中使用。await 表达式会暂停当前 async function 的执行,等待 Promise 处理完成。若 Promise 正常处理(fulfilled),其回调的resolve函数参数作为 await 表达式的值,继续执行 async function。若 Promise 处理异常(rejected),await 表达式会把 Promise 的异常原因抛出。另外,如果 await 操作符后的表达式的值不是一个 Promise,则返回该值本身。
async函数是使用async关键字声明的函数。 async函数是AsyncFunction构造函数的实例, 并且其中允许使用await关键字。async和await关键字让我们可以用一种更简洁的方式写出基于Promise的异步行为,而无需刻意地链式调用promise。
async函数可能包含0个或者多个await表达式。await表达式会暂停整个async函数的执行进程并出让其控制权,只有当其等待的基于promise的异步操作被兑现或被拒绝之后才会恢复进程。promise的解决值会被当作该await表达式的返回值。使用async / await关键字就可以在异步代码中使用普通的try / catch代码块。
注意这里await表达式会暂停整个async函数的执行并交出控制权,切换之前会将async函数的返回和await后续逻辑整个打包成微任务,这样来看就清晰了很多。
function testSomething() {
console.log("执行testSomething"); // 步骤1
return "testSomething";
}
async function testAsync() {
console.log("执行testAsync"); // 步骤2
return Promise.resolve("hello async");
}
async function test() {
console.log("test start..."); // 步骤3
const v1 = await testSomething();//关键点1
console.log(v1); // 步骤4
const v2 = await testAsync();
console.log(v2); // 步骤5
console.log(v1, v2); // 步骤6
}
test();
var promise = new Promise(
(resolve) => {
console.log("promise start.."); // 步骤7
resolve("promise");
}
);//关键点2
promise.then((val)=> console.log(val)); // 步骤8
console.log("test end..."); // 步骤9
// test start...
// 执行testSomething
// promise start..
// test end...
// testSometing
// 执行testAsync
// promise
// hello async
// testSometing hello async在了解完Async/Await之后,我们解析上面代码的具体执行过程就比较清晰了:
const v1 = testSomething(),此时v1拿到了testSomething函数的返回值,继续执行并输出步骤4。继续执行遇到第二个await,同理正常执行testAsync函数输出步骤2,在return之后注册微任务并挂起,交出线程控制权,此时微任务队列仍有两个任务:主代码块的Promise.then和第二个await注册的微任务。const v2 = testAsync(),此时v2拿到了testAsync函数的返回值,继续执行并输出步骤5,最后执行并输出步骤6。Node 中的 Event Loop 和浏览器中的是完全不相同的东西。Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现

nodejs底层多线程的意义是:底层的多线程实现了nodejs的异步操作,node将所有的阻塞操作都交给了内部的线程池去实现,本身只负责不断的往返调度。以此node就可以实现高并发操作。
我们所看到的node.js单线程只是一个js主线程,本质上的异步操作还是由线程池完成的,node将所有的阻塞操作都交给了内部的线程池去实现,本身只负责不断的往返调度,并没有进行真正的I/O操作,从而实现异步非阻塞I/O,这便是node单线程和事件驱动的精髓之处了。
我们来看一下NodeJS之中的事件循环:

每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。
由于这些操作中的任何一个都可能调度 更多的 操作和由内核排列在轮询阶段被处理的新事件, 且在处理轮询中的事件时,轮询事件可以排队。因此,长时间运行的回调可以允许轮询阶段运行长于计时器的阈值时间。
NodeJS事件循环各个阶段概览:
在每次事件循环运行之间,Node.js 会检查它是否正在等待任何异步 I/O 或 timers,如果没有,则将其干净地关闭。
定时器指定 可以执行所提供回调 的 阈值,而不是用户希望其执行的确切时间。在指定的一段时间间隔后, 计时器回调将被尽可能早地运行。但是,操作系统调度或其它正在运行的回调可能会延迟它们。
注意:轮询 阶段(poll) 控制何时定时器执行。
为了防止 轮询 阶段饿死事件循环,libuv(实现 Node.js 事件循环和平台的所有异步行为的 C 函数库),在停止轮询以获得更多事件之前,还有一个硬性最大值(依赖于系统)。
此阶段对某些系统操作(如 TCP 错误类型)执行回调。例如,如果 TCP 套接字在尝试连接时接收到 ECONNREFUSED,则某些 *nix 的系统希望等待报告错误。这将被排队以在 挂起的回调 阶段执行。
轮询 阶段有两个重要的功能:
当事件循环进入 轮询 阶段且 没有被调度的计时器时 ,将发生以下两种情况之一:
setImmediate() 调度,则事件循环将结束 轮询 阶段,并继续 检查 阶段以执行那些被调度的脚本。setImmediate()调度,则事件循环将等待回调被添加到队列中,然后立即执行。一旦 轮询 队列为空,事件循环将检查 已达到时间阈值的计时器。如果一个或多个计时器已准备就绪,则事件循环将绕回计时器阶段以执行这些计时器的回调。
此阶段允许人员在轮询阶段完成后立即执行回调。如果轮询阶段变为空闲状态,并且脚本使用 setImmediate() 后被排列在队列中,则事件循环可能继续到 检查 阶段而不是等待。
setImmediate() 实际上是一个在事件循环的单独阶段运行的特殊计时器。它使用一个 libuv API 来安排回调在 轮询 阶段完成后执行。
通常,在执行代码时,事件循环最终会命中轮询阶段,在那等待传入连接、请求等。但是,如果回调已使用 setImmediate()调度过,并且轮询阶段变为空闲状态,则它将结束此阶段,并继续到检查阶段而不是继续等待轮询事件。
如果套接字或处理函数突然关闭(例如 socket.destroy()),则'close' 事件将在这个阶段发出。否则它将通过 process.nextTick() 发出。
您可能已经注意到
process.nextTick()在图示中连接了事件循环的每一个环节,这是因为process.nextTick()从技术上讲不是事件循环的一部分。相反,它都将在当前操作完成后处理nextTickQueue, 而不管事件循环的当前阶段如何。这里的一个操作被视作为一个从底层 C/C++ 处理器开始过渡,并且处理需要执行的 JavaScript 代码。
任何时候在给定的阶段中调用 process.nextTick(),所有传递到 process.nextTick() 的回调将在事件循环继续之前解析。这可能会造成一些糟糕的情况,因为它允许您通过递归 process.nextTick()调用来“饿死”您的 I/O,阻止事件循环到达 轮询 阶段。
使用process.nextTick的两个重要原因:
另外,我们需要了解各种微任务的优先级问题:
优先级 process.nextTick > promise.then = queueMicrotask
显而易见,更为底层的process.nextTick优先级是高于promise.then和原生微任务apiqueueMicrotask。
1、硬核JS:https://juejin.cn/post/6844904050543034376
2、弄懂JS执行机制:https://juejin.cn/post/6844903512845860872
3、动画演示宏微任务:https://juejin.cn/post/6861438433362247694
4、异步事件循环:https://juejin.cn/post/6844904032264257544
5、JavaScript运行机制详解:http://www.ruanyifeng.com/blog/2014/10/event-loop.html
6、NodeJS事件循环:https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/
7、NodeJS事件循环官方中译:https://learnku.com/articles/38802
8、JavaScript中的Event Loop机制:https://zhuanlan.zhihu.com/p/145383822
9、Tasks, microtasks, queues and schedules:https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/?utm_source=html5weekly
10、Chrome的多线程模型:http://www.ha97.com/2908.html
11、令人费解的async/await: https://juejin.cn/post/6844903762478235656
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。