剖析JS执行机制

进程与线程

进程

进程是CPU资源分配的最小单位,通俗的说就是一个可以独立运行且拥有自己的资源空间的任务程序。

单进程

计算机中的程序关于某数据集合上的一次活动,是系统进行资源分配和调度的基本单元。

在早期电脑是单核CPU,通过轮转时间片的算法(非常快的时间内进行分段切片),可以模拟出多进程的效果。

多进程

启动多个进程,多个进程可以同时间执行多个任务。

线程

单线程

进程内一个相对独立的,可调度的执行单元,和同属的一个进程共享进程中的资源(统一时间,只能做一件事情)。

多线程

启动一个进程,在一个线程内启动多个线程,可以使多个线程执行多个任务(也是通过调度实现的,如多进程就能做多任务一样,多线程也能做任务)。

举一个例子:

工厂的资源 -> 系统分配的内存(独立的一块内存)
工厂之间的相互独立 -> 进程之间相互独立
工厂内有一个或多个工人 -> 一个进程由一个或多个线程组成
多个工人协作完成任务 -> 多个线程在进程中协作完成任务

浏览器

基本所有的浏览器都是多线程的,可以打开任务管理器查看。

渲染进程的常驻线程

1. JS引擎线程
   用来解析和执行JavaScript代码,负责处理JavaScript脚本程序(例如V8引擎)。
2. GUI渲染线程
   用来绘制用户界面,执行渲染操作。GUI渲染线程和JS引擎是互斥的,JS引擎线程会阻塞GUI线程。
3. http网络请求线程
4. 定时器触发线程
5. 浏览器事件处理线程

异步请求以网络请求为主,所以有人把http网络请求线程、定时器触发线程、浏览器事件处理线程统一叫做WebAPIs。

为什么JS引擎是单线程的?

JS引擎是为用户交互所设计,主要用作用户交互,用户交互会操作DOM,多线程操作会导致DOM冲突。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

JS引擎是单线程的,同一时间只要做一件事情,如果数据量很大怎么办?

这里提供两种类型的解决方案。SSR和WebWorkers。

1. SSR

SSR(服务端渲染技术),React和Vue都提供了SSR的功能。
主要功能是后台处理数据,计算相关的交给后端,前端用来渲染页面。

2. Web Workers

HTML5中新增的API,可以向JS引擎申请开子线程,归属于JS引擎下的子线程,由浏览器开辟,不能访问和操作DOM元素。并没有改变JS引擎单线程的本质。

计算量不是很大,使用WebWorkers不划算,SSR感觉没必要怎么办?

单线程的解决方案:异步操作

JS引擎是单线程的,同时可以执行异步操作(基于事件驱动)。

JS是通过事件驱动的方式来模拟异步操作的。
JS引擎异步的机制是依赖不同的线程,线程本身是由浏览器提供的。
同时也意味着环境发生变化,哪怕JS引擎不变,对应操作异步的方式也会发生变化。
比如NodeJS虽然同样是V8引擎,但是由于执行环境不同,不是浏览器提供的事件处理线程,
对应的基于JS引擎的异步处理方式发生变化。不同的环境,基于异步的事件驱动模型可能会不同。

事件驱动模型

任务队列

所有的JavaScript代码都是在执行栈中执行的,先进后出。

loop.png

任务进入执行栈,会判断当前任务是同步任务还是异步任务。
如果是同步任务,进入主线程执行。
如果是异步任务,会在EventTable注册回调函数。
当对应的事件处理函数被触发后,对应的回调函数会放到任务队列中。
当主线程的任务执行完毕,会检查任务队列,有任务回调,就进入主线程执行。
上述过程会不断重复,这就是EventLoop,事件循环。

宏任务与微任务

宏任务

宏任务(macrotask),也被称为task。

宏任务在执行时,渲染是不会进行的。浏览器为了能够让宏任务和DOM任务有序的进行,
会在一个宏任务执行完毕,在下一个宏任务执行之前,对页面进行渲染。

宏任务 -> 渲染 -> 宏任务 -> ....

常见的宏任务:

1. 主代码块
2. setTimeout
2. setInterval
3. setImmediate 
4. requestAnimationFrame
微任务

ES6引入Promise标准,同时也多了一个微任务的概念。
微任务(microtask),也被称为jobs。

宏任务在结束后,会执行渲染,然后执行下一个宏任务,微任务可以理解为在当前宏任务执行完成后,立刻执行的任务。当一个宏任务执行完毕,会在页面渲染前,将执行期间产生的所有的微任务都执行完。

宏任务 -> 微任务 -> 渲染 -> 宏任务 -> ....

常见的微任务:

1. process.nextTick
2. Promise.then
3. catch
4. finally
宏任务、微任务注意点
  1. 浏览器会先执行一个宏任务,紧接着执行当前执行栈产生的微任务,再进行渲染,然后再执行下一次宏任务。

  2. 宏任务和微任务不在一个任务队列

  3. 事件循环时,每次在循环时,都只会读取一个任务。

事件循环

主线程从任务队列中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为EventLoop(事件循环)。下面有一张图来展示整个过程:

loop2.png

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在任务队列中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。

定时器

除了放置异步事件,任务队列还会放置定时事件。
定时器功能主要由setTimeout()和setInterval()这两个函数来完成。

处理异步函数,函数执行添加到调用栈(Call Stack),发现是定时器,会调用相应异步线程注册回调函数到webAPIs中,把当前函数挂起,等待事件被触发。
注册的回调函数等待事件被触发后,推入到任务队列(TaskQueue)。
事件循环(EventLoop)在事件队列中获取回调函数,添加到调用栈中,执行回调函数。

任务队列(TaskQueue)也叫事件队列(EventQueue)或者回调队列(CallbackQueue)。

HTML5标准规定setTimeout的第二个参数的最小值,不得低于4ms。
人眼视觉极限大概是30ms,做JS动画时,通常给30ms或者20ms的值。

设置的延时时间通常不是实际执行的时间,异步代码一定会存在阻塞的,
即使时间到达指定时间也需要等待主线程任务执行完毕,才会执行。

注意点

并不是所有的回调函数都是异步代码,例如sort函数。
异步在ES6以前都是以回调的方式出现的。

大量使用回调函数嵌套,可能会导致回调地狱,解决方案是Promise。
Promise解决不了的问题,可以使用Async/Await解决。

setTimeout、setInterval调用的是定时器触发线程,ajax请求调用的是网络请求线程。
onclick、addEventListener使用浏览器事件处理线程。这些都是异步操作。

简单说一下NodeJS的执行机制

虽然NodeJS的JS引擎也是V8引擎,但是与浏览器相比,事件驱动模型是不同的。

在NodeJS中,宏任务是分为好几种类型的,这几种类型又分为不同的任务队列,不同的任务队列又有顺序区别,微任务是穿插在每一种宏任务之间的。

node环境下,process.nextTick的优先级高于Promise,可以简单理解为宏任务结束后,会先执行微任务中的nextTickQueue,然后再执行微任务的Promise部分。

loop3.png

事件循环原理

1. node初始化
1. 初始化node环境
2. 执行输入代码
3. 执行process.nextTick回调
4. 执行微任务
2. 进入EventLoop
A. 进入timers阶段
1. 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行;
2. 检查是否有 process.nextTick 任务,如果有,全部执行;
3. 检查是否有microtask,如果有,全部执行;
4. 退出该阶段;
B. 进入IO callbacks阶段
1. 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段;
2. 检查是否有 process.nextTick 任务,如果有,全部执行;
3. 检查是否有microtask,如果有,全部执行;
4. 退出该阶段
C. 进入 idle,prepare 阶段

这两个阶段与编程关系不大。

D. 进入 poll 阶段

首先检查是否存在尚未完成的回调,如果存在,那么分两种情况.

第一种情况:

1. 如果有可用回调(可用回调包含到期的定时器还有一些IO事件等),执行所有可用回调;
2. 检查是否有 process.nextTick 回调,如果有,全部执行;
3. 检查是否有 microtaks,如果有,全部执行;
4. 退出该阶段;

第二种情况:

1. 如果没有可用回调;
2. 检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知

如果不存在尚未完成的回调,退出poll阶段。

E. 进入 check 阶段
1. 如果有immediate回调,则执行所有immediate回调;
2. 如果有immediate回调,则执行所有immediate回调;
3. 检查是否有 microtaks,如果有,全部执行;
4. 退出 check 阶段;
F. 进入 closing 阶段
1. 如果有immediate回调,则执行所有immediate回调;
2. 检查是否有 process.nextTick 回调,如果有,全部执行;
3. 检查是否有 microtaks,如果有,全部执行;
4. 退出 closing 阶段;
G. 检查是否有活跃的 handles(定时器、IO等事件句柄)
1. 如果有,继续下一轮循环;
2. 如果没有,结束事件循环,退出程序;

在事件循环的每一个子阶段退出之前都会按顺序执行如下过程

1. 检查是否有 process.nextTick 回调,如果有,全部执行;
2. 检查是否有 microtaks,如果有,全部执行;
3. 退出当前阶段;

参考

JavaScript 运行机制详解
一次搞懂JS运行机制
剖析nodejs的事件循环