一、JS是单线程的
- JavaScript作为浏览器脚本语言,从设计之初就是单线程,主要用途是与用户互动,以及操作DOM。
假设个场景,若JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?,所以,为了避免复杂性,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
- 为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
任务队列
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。所有任务又可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。
-
同步任务指的是:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
-
异步任务指的是:不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
-
具体来说,异步执行的运行机制如下:
- 1、所有同步任务都在主线程上执行,形成一个执行栈(FILO)
- 2、主线程之外,还存在一个任务队列(FIFO)。只要异步任务有了运行结果,就在任务队列之中放置一个事件(回调函数)。
- 3、一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 4、主线程不断重复上面的第三步。这也就是我们所说的Event Loop(事件循环)
说明:
- 所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
- JS为什么需要异步?
如果js不存在异步,所有代码从上往下执行,如果上一行代码解析很长,那么下面的代码就会被阻塞,对于用户而言,阻塞意味着“卡死”,这样就导致了很差的用户体验
- 队列 和 栈 堆:
堆是指程序运行时申请的动态内存,而栈只是指程序编译时一种使用堆的方法(即先进后出);栈和队列都是线性结构,栈就像一个桶,采用FILO特性(先进后出),而队列采用FIFO特性(先进先出)。
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。这种运行机制又称为Event Loop(事件循环),但是Event Loop在浏览器和Node中运行机制是有一定区别的
浏览器事件环
我们先了解下浏览器模型
- 浏览器的user Interface (用户界面)、Browser engine(浏览器引擎)、Rendering engine(渲染引擎)、Data Persistence(数据存储)都是一个进程
- Rendering engine(渲染引擎)下有三个线程:Net working(网络请求)、JavaScript Interpreter(js解释器)、UI线程,UI线程和JS线程会互斥。
- 一般来说浏览器会有以下几个线程:
- js引擎线程 (解释执行js代码、用户输入、网络请求)
- GUI线程 (绘制用户界面、与js主线程是互斥的)
- http网络请求线程 (处理用户的get、post等请求,等返回结果后将回调函数推入任务队列)
- 定时触发器线程 (setTimeout、setInterval等待时间结束后把执行函数推入任务队列中)
- 浏览器事件处理线程(将click、mouse等交互事件发生后将这些事件放入事件队列中)
浏览器事件环
-
宏任务macrotask:setTimeout、setInterval、MessageChannel、postMessage、ajax、script(整体代码)、onClick系列事件等
-
微任务microtask:Promise.then、MutationObserver、Object.observe(已废弃)
-
js在浏览器中执行机制(见图解):
- 1、当一个任务进入执行栈,同步的进入主线程执行,异步的进入Event Table并注册函数。当指定的事情完成时,Event Table会将这个函数移入Event Queue,这个函数就是该任务指定的回调函数。
- 2、Event Queue分为微任务队列(microtasks queues)和宏任务队列(macrotask queues),当主线程内的任务执行完毕为空时,会先去microtasks queues中读取对应的函数,并放入主线程执行;
- 3、当microtasks queues中所有的任务执行完毕,再去macrotask queues中取一个宏任务放入主线程执行,并且每执行一个宏任务前,都会先将Event Queue中microtasks queues清空,即Event Queue中的微任务优先被读取执行。
- 4、上述过程会不断重复
-
下面我们看一个例子:
console.log('1');
setTimeout(function () { //setTimeout1
console.log('2');
new Promise(function (resolve) {
console.log('3');
resolve();
}).then(function () { //then2
console.log('4')
})
})
new Promise(function (resolve) {
console.log('5');
resolve();
}).then(function () {//then1
console.log('6')
})
setTimeout(function () { //setTimeout2
console.log('7');
new Promise(function (resolve) {
console.log('8');
resolve();
}).then(function () { //then3
console.log('9')
})
})
// 1、5、6、2、3、4、7、8、9 (chrome浏览器)
复制代码
- 解析:
- 当任务进入执行栈,同步任务进入主线程执行(promise执行其中代码为同步执行) --> 1、5,
- 此时,微任务then1和宏任务setTimeout1、setTimeout2进入Event Queue中,微任务优先被读取执行 --> 6
- 当前循环中微任务已被清空,开始执行第一个宏任务setTimeout1 --> 2、3
- 此时setTimeout1中微任务then2被放入Event Queue,每执行一个宏任务前都要清空事件池中微任务,故then2优先于setTimeout2执行 --> 4
- 执行宏任务setTimeout2,以及内部微任务 --> 7、8、9
Node事件环
node的事件环相比浏览器就不一样了,我们先了解下它的工作流程:
Node工作流程
- Node工作流程:
- 1、V8引擎解析JavaScript脚本,若解析后的代码,调用了Node API,Node就会将代码交给libuv库处理,这个libuv库是别人写的,也就是node的事件环。
- 2、libuv库负责Node API的执行,它将不同的任务分配给不同的工作线程(work threads),通过多线程同步阻塞执行,模拟异步处理机制,成功后将回调函数放入Event Queue。
- 3、等到work threads队列中有执行完成的事件,就会通过execute callback回调给Event queue队列,把它放入队列中。
- 4、最后通过事件驱动(发布订阅)的方式,取出EVENT QUEUE队列的事件,再通过V8引擎将结果返回给应用
- node中的event loop是在libuv里面的,libuv里面有个事件环机制,他会在启动node时,初始化事件环
Node事件环
-
timers队列:setTimeout、setInterval、
-
poll队列:异步I/O
-
check队列:setImmediate
-
微任务:Promise.then、process.nextTick
-
js在Node中执行机制(见图解):
- Event Loop中每一个阶段都对应着一个事件队列:时间队列、poll(轮询)队列、check(setimmediate)队列等,以及微任务队列(process.nextTick优先于Promise.then执行)
- 每当其中一个队列执行完毕或者执行数量超过上限,event loop就会执行下一个阶段,即切换到下一个队列
- 每当event loop切换一个执行队列之前,就会先去清空microtasks queues,然后再切换到下个队列去执行,如此反复
-
注意:
- setImmediate是属于check队列的,还有poll队列主要是异步的I/O操作,比如node中的fs.readFile()
- process.nextTick优先于Promise.then执行
- node事件环初始化是有启动时间的
- setTimeout的0毫秒实际上也是达不到的;根据HTML的标准,最低是4毫秒。
上代码,加深下理解:
let fs = require('fs');
setTimeout(function(){
Promise.resolve().then(()=>{
console.log('then2');
})
},0);
Promise.resolve().then(()=>{
console.log('then1');
});
fs.readFile('./gitigore',function(){
setTimeout(function () {
console.log('setTimeout')
}, 0);
setImmediate(()=>{
console.log('setImmediate')
});
});
// then1 then2 setImmediate setTimeout
复制代码
- 说明:
- 微任务优先执行:then1
- 然后setTimeout放入时间队列,fs.readFile放入poll队列,setTimeout先执行,并把then2房屋微任务队列
- 切换任务队列到poll队列,fs.readFile执行之前会先清空微任务队列,Promise.then执行:then2
- fs.readFile执行,setTimeout放入时间队列,等待时间0s,setImmediate放入check队列,poll队列执行完毕后,下一队列是check队列,setImmediate执行:setImmediate
- check队列执行完毕,进入下个循环,时间队列在执行:setTimeout
但是下面这种情况setImmediate和setTimeout谁先谁后就不得而知了:
setImmediate(function(){
console.log('setImmediate')
});
setTimeout(function(){
console.log('setTimeout')
},0); // ->4ms
//setImmediate setTimeout 或 setTimeout setImmediate
复制代码
- 说明:
- node事件环初始化是有时间的
- 正常情况下走完时间队列才会走check队列,但是setTimeout-0会有4ms延迟,如果node启动时间大于4ms,那么OK一切正常,setTimeout先放入执行栈执行,
- 若node启动时间小于4ms。此时setTimeout-0并未启动,所以会先将setImmediate放入执行栈执行。因此才会出现两种结果
我们再看一种情况:
setTimeout(() => {
console.log('timeout1');
process.nextTick(()=>{
console.log('nextTick1');
})
}, 1000);
// 虽然都写1000毫秒 是有误差
process.nextTick(function(){ // nextTick执行的时候用了0.8ms
setTimeout(() => {
console.log('timeout2');
}, 1000); // 1000.02ms
console.log('nextTick2');
})
// nextTick2 timeout1 nextTick1 timeout2 或 nextTick2 timeout1 timeout2 nextTick1
复制代码
- 说明
-
setTimeout1等待1000ms后才放入timers队列,微任务首先执行:nextTick2,假设微任务执行时间m,
-
此时setTimeout2释放,等待1000ms,即(1000+m)ms后放入timers队列,
-
1000ms后,setTimeout1执行,执行时间n,执行完毕后nextTick1放入微任务队列
-
如果n> m,则此时setTimeout2已被放入timers队列,timers队列中setTimeout2继续执行
-
如果n< m,则此时setTimeout2还没有放入timers队列,libuv进入下一次循环,然后进入下次循环中的timers队列前要清空微任务,所以nextTick1先被执行:nextTick1
-
再加上setTimeout本身就是存在误差的,更加不确定timeout2与nextTick1的执行顺序
-
注意node事件环是切换队列时才清空microtasks queues。
-
总之,由于setTimeout的误差或者同步代码和微任务的执行时间不确定性,某些场景下的异步任务可能还未被放入队列中,当队列为空时就会执行下一阶段队列任务,按照node事件环机制继续运行。
-