细聊 JavaScript 的事件执行机制
线程?
都说 JavaScript 是单线程的 ,那么什么是单线程呢?
单线程就是进程只有一个线程。
那这又扯到进程这个概念了。
进程
进程 (Process): 进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。
简而言之,就是存在内存没有执行的程序叫程序,执行起来叫进程
简单说了一下进程,那么就得看看什么是线程了。
线程
线程(Thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
线程是进程的一个实体,一个进程可以有多个线程。它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
单线程和多线程
那么些微了解了什么是线程,那么就容易理解什么是单线程什么是多线程了。
单线程和多线程都是基于一个进程而言的 。一个进程必须有一个或以上的线程。
单线程进程在执行任务时,只有一个线程可以去执行,并且在同一时刻只能执行一个任务。
而多线程进程在执行任务时是可以同时执行多个任务的。
同步?异步?
同步(sync):也就是同一时间只能做一件事,就拿上面的单线程任务执行过程来看 ,前一个任务完成之前,第二个任务是不会被执行的 。
异步(async) :异步是执行一个任务时,即使这个任务没有结束,也可以继续下去,执行其他任务。
那么根据这两个概念,就可以把任务分成同步任务和异步任务。
同步任务:在任务没有结果前,线程不会继续执行,而是等到当前任务完成后再执行。
异步任务: 执行到此任务时,会把此任务交给其他机制管理 ,而线程去执行其他任务,等到有结果了再返回给线程来继续处理。
对于异步任务的调用,调用的返回并不受调用者控制。
对于通知调用者的三种方式,具体如下:
-
状态:即监听异步任务的状态(轮询),调用者需要每隔一定时间检查一次,效率会很低。
-
通知:当被调用者执行完成后,发出通知告知调用者,无需消耗太多性能。
-
回调:与通知类似,当被调用者执行完成后,会调用调用者提供的回调函数。
同步和异步的区别
同步与异步的区别不在于任务执行的时间。也就是同步任务可能比异步任务执行的时间要长。
主要区别是线程在执行这些任务时,是否需要立即得到结果。需要的是同步,得到结果了才继续执行,而异步是不需要立即得到结果,那么线程可以继续执行也可以等待。
JavaScript 如何做到异步
那么既然 JavaScript 是单线程的,按理说应该是同步的。但同步会遇到一个问题。
就是如果一个JS执行时间太长。就会造成页面不连贯,卡顿。比如我发送了一个异步请求,而请求回来前,我即不能点击其他按钮,也不能滚动页面(也就是我做了这些动作,但是没有响应)。因此体验十分不好。
那么 JS 需要异步,但是执行异步任务,就需要把异步任务交给其他线程。那么如何实现异步呢?
别忘了 JavaScript 代码是在一个 JavaScript 运行时的环境里执行的 。如游览器 ,或者 Node.js 。
他们可以为 JavaScript 提供其他线程 。具体在 JS 执行机制一节会介绍。
堵塞?非堵塞?
堵塞: 是指线程执行任务时,在没有得到结果前,线程会被挂起,不会去执行其他任务了。等得到结果才会继续执行
非堵塞: 是指线程执行任务时,即使不能立即得到结果,该任务也不会影响线程的继续执行其他任务。
同步,异步 与 堵塞,非堵塞的区别
这么看起来 ,堵塞和同步 ,非堵塞和异步似乎很像,有些一模一样了 。但是它们是不一样的。
同步和异步是关于任务的 ,而堵塞和非堵塞是关于线程的。它们有一定的关系,但并不相等。
如何理解呢?
如此理解,就是说同步异步的任务不会直接决定线程的堵塞和非堵塞 。
因此我们就有以下4中情况 :
- 同步堵塞 : 执行一个同步任务,没有结果,不执行其他,线程被挂起。等有结果了再继续执行其他任务
我去买东西,发现没钱了,叫同学发红包,同学还没回,我就付不了。
- 同步非堵塞 : 任务是同步的 ,但我过程中,暂时停止的这个同步任务,去执行了其他任务,同时又可能时不时回来继续执行。
我不能同时抄作业,又同时打游戏,但我可以打一会,又玩一会。只要我的速度快,你就可能以为我是同时进行的
- 异步堵塞 : 执行到异步任务时 ,任务不占用当前线程,但是线程的设置机制就是等到任务有结果后再进行执行其他任务
我在打游戏,外卖到了,让舍友拿,但是我好饿,不打了,等外卖
- 异步非堵塞 : 执行到异步任务时 ,任务交给其他线程执行,当前线程继续执行其他任务。
我在打游戏,让舍友去给我拿外卖,我继续打游戏!
显然异步堵塞几乎没什么用 ,而同步非堵塞也不是很符合实际需求或解决大部分的问题。
JavaScript 的执行机制
先体验一波 JavaScript 的同异步任务的执行结果吧!
console.log(1)
setTimeout(() => {
console.log(2)
}, 0);
console.log(3);
// 1 3 2
在了解同异步前,我会认为答案 是 1 2 3
,因为定时器是 0 秒的 。应该会被立即执行的吧 。
但实际不是如此的 。这就得说到一种运行机制叫做事件循环机制。
事件循环 (Event Loop)
事件循环机制是计算机系统的一种运行机制 。而 JavaScript 是单线程的 ,因此采用了这种机制来解决运行中存在的一些问题。
**先粗浅的看看什么是事件循环 **
单线程无法同一时间执行多个事件 ,必须一件一件的完成 。但如果遇到异步的任务,就比方说点击事件 ,
but.onclick = (){}
当线程执行到这段代码时 给节点添加点击事件后,发现 but 节点的点击还没发生 ,也是就等着。那么页面就会出现 “假死” 状态 ,页面操作不了 ,接下来的代码也不执行了 。
而如果使用事件循环机制 ,把异步任务交给其他线程完成 ,那么当执行完成后有结果了再返回主线程。那么在这段本来应该等待的时间里就可以做其他的事情了 。
那么这于多线程有什么区别呢 ? 输入事件回调也会使用到其他线程 ,但其他线程是相当于辅助作用的。主线程遇到异步了就交给它,其他情况下它并不活跃 ,不会占据太多的资源。
而相比多线程 ,多条线程是同地位的 ,这意味着占用的资源更多 ,并且遇到异步任务需要等待时 ,都没有相互帮忙,而是等着 。这显然不合理。
JavaScript 中的事件循环
先看一张图 :
如何理解呢 ? 我们来看下面一段代码 :
console.log('a') // 函数 1
setTimeout(() => { // 函数2 , 回调函数3
console.log('b') // 函数4
}, 1000);
console.log('c'); // 函数5
// a c b
那这个重复循环的过程就是事件循环 。
任务队列(Event Queue)
任务队列有的也叫(消息队列) ,里面的任务 可分为 宏任务(Macro-task) 和微任务 ( Micro-task ) 。
游览器和 Node.js 在任务队列的不同
游览器和 Node 在宏任务和微任务上是有区别的。主要原因是使用场景不同,提供的 API 也不同 。
游览器 :
macro-task大概包括:
- setTimeout
- setInterval
- I/O
- UI render
micro-task大概包括:
- Promise.then(回调)
- Async/Await(实际就是promise)
- MutationObserver(html5新特性)
**Node.js **
macro-task 大概包括:
- setTimeout
- setInterval
- setImmediate
- I/O
micro-task 大概包括:
- process.nextTick(与普通微任务有区别,在微任务队列执行之前执行)
- Promise().then(回调)等。
ES6 作业队列
ECMAScript 2015 引入了作业队列的概念,Promise 使用了该队列(也在 ES6/ES2015 中引入)。 这种方式会尽快地执行异步函数的结果,而不是放在调用堆栈的末尾
也就是异步任务也需要排队的 ,也就是如果前面有一个宏任务,就必须等前面一个先执行完。微任务也需要等它前面的微任务先执行完才能被执行。
而 作业队列我们可以理解成微队列 ,因为作业队列必须在同步任务执行完后,但它无需等待宏任务 。因为无论在游览器还是 Node.js ,都会先执行完微任务再执行宏任务 。
console.log(1)
setTimeout(() => {
console.log(2); // 宏任务
}, 0);
new Promise((resolve, reject) =>{
console.log(3)
resolve(4)
}).then(resolve => console.log(resolve)) // 微任务
console.log(5);
// 1 3 4 2
注意 , 传入Promise的回调不是异步的,是同步执行的!
游览器和 Node.js 在事件循环中的不同
游览器和 Node.js 中都有事件循环 。但它们的机制是有区别的 。
但整体上是一样的, 也是同步先执行 ,异步分开执行 ,然后将回调放入任务队列 ,并且任务队列也区分宏任务和微任务 。等函数执行栈空了再监听任务队列 。
但不同的是在对任务队列里的任务执行上的区别 。
游览器
游览器会先执行微任务队列里的任务 ,然后再按顺序执行宏任务,执行过程中,如果有异步任务会被移到异步任务处理器中, 如果是有微任务最后会被添加到微任务处理器中 。执行结束后 ,会查看微任务列表,如果有会把任务依次全部执行完。再进行下一个宏任务,如果没有就直接下一个宏任务。
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
})
new Promise((resolve) => {
console.log('4');
resolve();
}).then(() => {
console.log('5')
})
})
new Promise((resolve) => {
console.log('7');
resolve();
}).then(() => {
console.log('8')
})
setTimeout(() => {
console.log('9');
Promise.resolve().then(() => {
console.log('10');
})
new Promise((resolve,reject) => {
console.log('11');
reject();
}).then(() => {
console.log('12')
})
})
new Promise((resolve) => {
console.log('13');
resolve();
}).then(() => {
console.log('14')
})
// 1 7 13 8 14 2 4 3 5 9 11 10
// 没有 12 , 因为 reject 了。
Node.js
当 Node.js 启动后,它会初始化事件循环,处理已提供的输入脚本。当所有同步任务完成后,会先开始执行微任务 。然后开始处理事件循环。
在大部分情况下 , Node.js 的事件循环结果与 游览器结果相同 ,但细化后 ,是不同的 。
先看看 Node.js 事件循环的 循环图
这看起来很复杂 ,在游览器里是微任务-宏任务的切换着执行的机制。而 Node.js 就根据回调的类型,设置了不同的阶段 ,不同阶段执行当前可以执行的,且对应的回调。
也就是 回调还没可以执行不会执行 ,回调不是这个阶段该执行的也不会执行。
那接下来看看这些阶段分别是什么 :
- timers 阶段 ( 定时器 ) : 这个阶段执行setTimeout(callback) and setInterval(callback)预定的callback;
- pending callbacks 阶段 (待定回调): 执行延迟到下一个循环迭代的 I/O 回调。
- idle, prepare 阶段 : 仅node内部使用;
- poll 阶段 (轮询): 检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和
setImmediate()
调度的之外),其余情况 node 将在适当的时候在此阻塞。 - check 阶段 (检测): 执行setImmediate() 设定的callbacks;
- close callbacks 阶段 (关闭的回调): 比如socket.on(‘close’, callback)的callback会在这个阶段执行.
注意:
注意上面六个阶段都不包括 process.nextTick()
每个阶段的回调执行完毕或者达到执行的限度后才会进入下一阶段。
但其实我看到这里,还不是很理解 。在多方搜索后 ,我才明白了。于是我再形象的补充些图形 。
也就是我们可以理解为 Node.js 的循环过程中 ,在一个循环里是划分了不同的阶段的 ,每个阶段都有对应的一个任务队列。不同节点执行的回调的类型是不同的。每个阶段都必须等当前需要执行的回调执行完或者达到回调执行的限制时才会进行下一阶段。
但每次执行宏任务后也会检查是否有微任务需要执行。这点与游览器是一样的。
通俗的讲,就是游览器不管什么时候,你按照规矩来,早上吃饭也好,半夜吃饭也好,必须付钱。
而 node.js 就是,白天吃饭,必须按规矩来,必须付钱,晚上不是吃饭的时间,晚上饿了也不能吃,等第二天。
案例
const fs = require('fs')
// timers阶段
const startTime = Date.now();
setTimeout(() => {
Promise.resolve('pro1').then(res => {
console.log(res);
})
setImmediate(() => {
console.log('check1')
})
setTimeout(() => {
console.log('timer1');
}, 0);
const endTime = Date.now()
console.log(`timer2: ${endTime - startTime}ms`)
}, 1000)
// poll阶段(等待新的事件出现)
const readFileStart = Date.now();
fs.readFile('./index.html', (err, data) => {
if (err) throw err
let endTime = Date.now()
// 获取文件读取的时间
console.log(`read time: ${endTime - readFileStart}ms`)
// 通过while循环将fs回调强制阻塞5000ms
while (endTime - readFileStart < 5000) {
endTime = Date.now()
}
setImmediate(() => {
console.log('check2')
})
setTimeout(() => {
console.log('timer3');
}, 0)
})
// check阶段
setImmediate(() => {
console.log('check3')
})
Promise.resolve('pro2').then(res => {
console.log(res);
})
// 结果 :
// pro2
// check3
// read time: 30ms
// check2
// timer2: 5004ms
// pro1
// timer3
// check1
// timer1
分析 :
第一次循环
在 Node 执行完代码中的同步任务后 ,会先执行微任务队列 ,所以先输出 pro2
然后进入事件循环 ,先进入 timers阶段 此时其他线程执行的定时器还没有结束 ,不会进入任务队列 ,因此进入下一阶段。
一直到 poll 阶段 ,此时会监听 I/O 事件 ,也就是文件读取。但此时文件读取还没有结果 ,也就是 I/O 回调列队为空 ,因此进入下一阶段
此时进入 check 阶段 , setImmediate 的回调会再此执行,于是输出 check3
然后下一阶段 ,没有关闭的回调,因此此次循环结束,进入下一次的循环。
此时我们删除已经执行完的代码。得到如下代码:
// timers阶段
setTimeout(() => {
Promise.resolve('pro1').then(res => {
console.log(res);
})
setImmediate(() => {
console.log('check1')
})
setTimeout(() => {
console.log('timer1');
}, 0);
const endTime = Date.now()
console.log(`timer2: ${endTime - startTime}ms`)
}, 1000)
// poll阶段(等待新的事件出现)
fs.readFile('./index.html', (err, data) => {
if (err) throw err
let endTime = Date.now()
// 获取文件读取的时间
console.log(`read time: ${endTime - readFileStart}ms`)
// 通过while循环将fs回调强制阻塞5000ms
while (endTime - readFileStart < 5000) {
endTime = Date.now()
}
setImmediate(() => {
console.log('check2')
})
setTimeout(() => {
console.log('timer3');
}, 0)
})
第二次循环
此时一个循环结束后,第一个定时器的一秒还没有结束 ,因此没有任务可执行,就进入下一个节点,一直到
poll 阶段 ,文件读取已经成功 ,于是执行 I/O 的回调。输出进入循环到读取成功大概耗时 read time: 30ms
然后 执行 while 语句 ,耗时 5s 。这段时间里不会进入下一阶段 。然后跳出 while 语句 ,执行 setImmediate 和 setTimeout 。它们是异步的 ,会移出执行栈 。(交给其他线程执行。执行完成后放入任务队列。)然后此时 I/O 的回调任务队列为空,进入下一个阶段 。
进入 check 阶段 ,上一阶段的 setImmediate 的回调已经可以执行了,于是输出 check2
…
第三次循环
Promise.resolve('pro1').then(res => {
console.log(res);
})
setImmediate(() => {
console.log('check1')
})
setTimeout(() => {
console.log('timer1');
}, 0);
const endTime = Date.now()
console.log(`timer2: ${endTime - startTime}ms`)
进入新循环的 timers 阶段 此时已经消耗了 5s 以上,第一次的定时器的回调已经加入任务队列 ,不过只有在 timers 阶段才能执行 ,于是执行Promise.resolve(‘pro1’).then() , setImmediate() ,setTimeout() , 输出 timer2: 5004ms
。 然后检查到有微任务 ,就执行输出 pro1
。 然后执行下一个宏任务 ,也就是上一循环输出 timer3 的回调 ,于是输出 timer3
一直到 check 阶段
输出 check1
…
第四次循环
输出 timer1
process.nextTick
process.nextTick 是一个异步方法 ,但独立于eventLoop 的任务队列 。
于 node11 版本后 ,它成为的微任务的一员 ,但它会优先于微任务先执行 。
setImmediate(() => {
console.log('timeout1')
Promise.resolve().then(() => console.log('promise resolve'))
process.nextTick(() => console.log('next tick1'))
});
setImmediate(() => {
console.log('timeout2')
process.nextTick(() => console.log('next tick2'))
});
setImmediate(() => console.log('timeout3'));
setImmediate(() => console.log('timeout4'));
// timeout1=>next tick1=>promise resolve=>timeout2=>next tick2=>timeout3=>timeout4
node 11 版本前的事件循环
Node11 版本前的事件循环与 11 版本后的有些不一样 。主要体现在微任务处理上 。
11 前的微任务不是在宏任务执行后执行的 ,而是在阶段切换前执行。
用代码来说话就是 :
console.log(1)
setTimeout(() => {
console.log(2)
Promise.resolve().then(function () {
console.log('3')
})
}, 0)
setTimeout(() => {
console.log(4)
Promise.resolve().then(function () {
console.log(5)
})
process.nextTick(() => console.log('tick'))
}, 0)
// 输出 : 1 2 4 tick 3 5
输出 1 2 4 tick 3 5
, 可见微任务都是 timers 阶段 的宏任务都执行完后再执行的 。
process.nextTick 也是在循环阶段切换时执行,并且在微任务前执行。
练习题
1. node.js
async function async1(){
console.log('async1 start') //2
await async2()
console.log('async1 end') // 8
}
async function async2(){
console.log('async2') // 3
}
console.log('script start') // 1
setTimeout(function(){
console.log('setTimeout0') // 10
},0)
setTimeout(function(){
console.log('setTimeout3') // 11
},3)
setImmediate(() => console.log('setImmediate')); // 12
process.nextTick(() => console.log('nextTick')); // 7
async1();
new Promise(function(resolve){
console.log('promise1') // 4
resolve();
console.log('promise2') // 5
}).then(function(){
console.log('promise3') // 9
})
console.log('script end') // 6
// script start
// async1 start
// async2
// promise1
// promise2
// script end
// nextTick
// async1 end
// promise3
// setTimeout0
// setImmediate
// setTimeout3
2 .游览器
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2') // 3
console.log('script start')
setTimeout(function () {
console.log('setTimeout0')
}, 0)
setTimeout(function () {
console.log('setTimeout3')
}, 3)
async1();
new Promise(function (resolve) {
console.log('promise1')
resolve();
console.log('promise2')
}).then(function () {
console.log('promise3')
})
console.log('script end')
// script start
// async1 start
// async2
// promise1
// promise2
// script end
// async1 end
// promise3
// setTimeout0
// setTimeout3
同步异步,堵塞非堵塞
同步异步,堵塞非堵塞
进程-百度百科
线程-百度百科
Event-loop
Node官网-事件循环
深入浅出浏览器事件循环
什么是 Event Loop?
再谈Event Loop- 阮一峰
Node.js Event Loop 的理解 Timers,process.nextTick()
说说事件循环机制
浏览器与Node的事件循环(Event Loop)有何区别?
深入理解NodeJS事件循环机制