JS 中的 Event Loop
什么是Event Loop?
首先,js主要是处理用户的交互,而用户的交互无非就是响应DOM的增删改,这就决定了js只能是单线程的语言。 而对于js主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是我们今天要讲的Event Loop。
通俗的认识:这个问题反映到我们生活中也是一样的道理,你一天要干很多事情,有一些事情又很耗时但是它又没那么重要,你会不会想办法来提高自己的效率好让自己解放出来呢?这样你才能合理利用一天的时间去做更重要的事情。于是人们发明了各种工具,洗衣机、电饭煲等等。你不用关心它是怎么做的,什么时候做完,洗好衣服你去晒,做好饭你去吃,你只需要把任务交给它,处理好了它就会通知你,你什么时候有空了再去处理结果就可以了。
这个例子中,你是一个人,不能同时做很多事情,反映到代码中你是单线程,你把耗时的任务交给这些工具,工具完成后会通知你结果,你就解放出来了可以先去做更重要的事情,这就是Event Loop的作用。
首先聊聊单线程所存在两个基本问题?
问题一:如何处理单线程中的阻塞问题
问题二:如何处理高优先级的任务
同步任务和异步任务
单线程,就是指一次只能完成一件任务,如果在同个时间有多个任务的话,这些任务就需要进行排队,前一个任务执行完,才会执行下一个任务。
但如果有一个任务的执行时间很长,比如文件的读取或者数据的请求等等,那么后面的任务就要一直等待,这就会影响用户的使用体验。
同步模式:就是前一个任务执行完成后,再执行下一个任务,程序的执行顺序与任务的排列顺序是一致的、同步的;
异步模式: 每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行队列上的后一个任务,而是执行回调函数;后一个任务则是不等前一个任务的回调函数的执行而执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。
类似现在的90后带娃,带娃逛街,娃看到了玩具想要,你看到价格之后就拒绝,娃就开始趴在地上撒泼打滚。这时候就不要慌,在他旁边拿起手机打几盘王者,等游戏结束,你的娃就自然而然就趴累了,乖乖跟着你空手回家。实在不济就揍一顿。
异步任务的出现
1). 同步任务都在JS引擎线程上执行,形成一个 执行栈
2). 事件触发线程管理一个 任务队列,异步任务触发条件达成,将回调事件放到任务队列中
3). 执行栈中所有同步任务执行完毕,此时JS引擎线程空闲,系统会读取任务队列,将可运行的异步任务回调事件添加到执行栈中,开始执行。
代码实例:setTimeout(function () {
console.log(1);
},0);
for (var i = 0; i < 100; i++){
console.log(2)
}
宏任务与微任务的诞生
为了解决问题二,异步任务又划分了宏任务和微任务。
宿主环境提供的叫宏任务,由语言标准提供的叫微任务,这是算比较标准也算比较好记忆的区分宏任务和微任务了。
宿主环境:简单来说就是能使javascript完美运行的环境,只要能完美运行javascript的载体就是javascript的宿主环境。目前我们常见的两种宿主环境有浏览器和node。
语言标准:我们都知道JavaScript是一种编程语言,但其实JavaScript由ECMA制定标准,称之为ECMAScript,所以由语言标准提供的就是微任务,比如ES6提供的promise。
常见的宏任务和微任务
- 宏任务:包括 script 全部代码、setTimeout、setInterval、setImmediate(Node.js)、requestAnimationFrame(浏览器)、I/O 操作、UI 渲染(浏览器),这些代码执行便是宏任务。
- 微任务:包括
process.nextTick
(Node.js)、Promise
、MutationObserver
,这些代码执行便是微任务
注意:
1.微任务是用于插队的
2.微任务的优先级高于宏任务,但是一般是由宏任务开始执行。
注:为什么说微任务是用于插队的呢?因为每次执行宏任务之前都会看有没有微任务,不管微任务是何时加入队列的,都会清空微任务队列之后才会执行下一个宏任务
js中的Event Loop的执行总结
1、主线程开始执行一段代码, 假设开始执行一个 script
标签内的代码,将代码放入执行栈中执行,同步代码优先执行,执行过程中,当遇到任务源时,判断是宏任务还是微任务
2、 如果是宏任务,加入到宏任务队列中,如果是微任务,加入到微任务队列中
3、 同步代码执行完成,执行栈空闲,检查微任务队列中是否有可执行任务,如果有,依次执行所有微任务队列中的任务。如果没有。当前任务执行结束。
4、 渲染UI
5、 检查宏任务队列是否有可执行的宏任务,如果有,取出队列中最前面的那个宏任务,加入到执行栈中开始执行,然后重复 步骤1- 5。直到宏任务队列中所有任务执行结束
实例代码:
const promise = new Promise((resolve, reject) => {
console.log(1);
resolve('success')
console.log(2);
});
promise.then(() => {
console.log(3);
});
console.log(4);
async await执行规则,await后续代码都会放到微任务队列中
综合练习
实例代码1:
const first = () => (new Promise((resolve, reject) => {
console.log(3);
let p = new Promise((resolve, reject) => {
console.log(7);
setTimeout(() => {
console.log(5);
resolve(6);
console.log(p)
}, 0)
resolve(1);
});
resolve(2);
p.then((arg) => {
console.log(arg);
});
}));
first().then((arg) => {
console.log(arg);
});
console.log(4);
实例代码2:
const async1 = async () => {
console.log('async1');
setTimeout(() => {
console.log('timer1')
}, 2000)
await new Promise(resolve => {
console.log('promise1')
})
console.log('async1 end')
return 'async1 success'
}
console.log('script start');
async1().then(res => console.log(res));
console.log('script end');
Promise.resolve(1)
.then(2)
.then(Promise.resolve(3))
.catch(4)
.then(res => console.log(res))
setTimeout(() => {
console.log('timer2')
}, 1000)
实例代码3:
const p1 = new Promise((resolve) => {
setTimeout(() => {
resolve('resolve3');
console.log('timer1')
}, 0)
resolve('resovle1');
resolve('resolve2');
}).then(res => {
console.log(res)
setTimeout(() => {
console.log(p1)
}, 1000)
}).finally(res => {
console.log('finally', res)
})
Promise 的 .then 或者 .catch 可以被调用多次, 当如果Promise内部的状态一经改变,并且有了一个值,那么后续每次调用.then或者.catch的时候都会直接拿到该值
.then 或者 .catch 的参数期望是函数,传入非函数则会发生值穿透
衍生问题
为什么settimeout有时候不准时?
而setTimeout() 的第二个参数(延时时间)只是告诉 JavaScript 再过多长时间把当前任务添加到任务队列中。如果任务队列是空的,并且主线程任务执行完毕了。那么添加的代码会立即执行;如果任务队列不是空的,那么它就要等前面的代码执行完了以后再执行。
HTML5标准规定了setTimeout()的第二个参数的最小值不得小于4毫秒,如果低于这个值,则默认是4毫秒。
node.js中的event loop
英文文档:nodejs.org/en/docs/gui…
Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。node.js实现了单线程高效的异步IO
什么是I/O模型?
一般而言,IO模型可以分为四种:同步阻塞、同步非阻塞、异步阻塞、异步非阻塞
1). 同步阻塞IO是指用户进程在发起一个IO操作后必须等待IO操作完成,只有当真正完成了IO操作后用户进程才能运行。
2). 同步非阻塞IO是指用户进程发起一个IO操作后立即返回,程序也就可以做其他事情。但是用户进程需要不时的询问IO操作是否就绪,这就要求用户进程不停的去询问,从而引入不必要的CPU资源浪费。
3). 异步阻塞IO是指应用发起一个IO操作后不必等待内核IO操作的完成,内核完成IO操作后会通知应用程序。这其实是同步和异步最关键的区别,同步必须等待或主动询问IO操作是否完成。
4). 异步非阻塞IO是指用户进程只需要发起一个IO操作后立即返回,等IO操作真正完成后,应用系统会得到IO操作完成的通知,此时用户进程只需要对数据进行处理即可。
简单来说Event Loop就是一种处理非阻塞I/O操作的机制,借助内核多线程的特点,在后台处理各种各样的操作,处理完成后内核会通知Node.js来进行处理。
libuv
我们都知道node能够运行在不同的平台上,由于在不同操作系统平台上支持所有类型的非阻塞I/O非常困难和复杂,就需要有一个抽象层来管理这些复杂的,跨平台的东西,这个抽象层就是——libuv。
Event Loop就是由libuv提供的。
以下引用自libuv官方文档
libuv 是一个跨平台的支持库,最初是为Node.js 编写的。它是围绕事件驱动的异步 I/O 模型设计的。
libuv给用户提供了两种方式与event loop一起协同工作,一个是句柄(handle) 一个是请求(request)。
句柄代表了一个长期存在的对象,这些对象当处于活跃状态的时候能够执行特定的操作。例如:一个准备(prepare)句柄在活跃的时候可以在每个循环中调用它的回调一次。一个TCP服务器的句柄在每次有新的连接的时候都会调用它的连接回调函数。
请求(request)一般代表短时操作。这些操作能用作用于句柄之上。写请求用于在句柄上写数据;还有一些例外,比如说getaddrinfo请求不需要句柄而是直接在循环中执行。
上图中,用户输入JavaScript代码,由V8引擎进行解析,V8调用Node API然后由libuv进行处理,libuv提供Event Loop来处理各类任务,处理完成后将结果返回给V8,V8再将结果返回给用户。
Node多进程
Node.js中的JavaScript代码执行在单线程中,非常脆弱,一旦出现了未捕获的异常,那么整个应用就会崩溃。这在许多场景下,尤其是web应用中,是无法忍受的。因此,现在手机或者计算机都是多核cpu的,因此我们编程的时候要考虑如何利用多进程多线程来充分利用多核cpu的优势。
const http = require('http');
http.createServer(function(req, res) {
res.writeHead(200, {'content-type': 'text/html'});
res.end(fs.readFileSync(__dirname + '/index.html', 'utf-8'));
}).listen(3000, () => {
console.log('listened 3000');
})
console.log('process id', process.pid);
说明不是单线程的。
const fs = require('fs');
const http = require('http');
http.createServer(function(req, res) {
res.writeHead(200, {'content-type': 'text/html'});
res.end(fs.readFileSync(__dirname + '/index.html', 'utf-8'));
}).listen(3000, () => {
console.log('listened 3000');
})
fs.readFile('./index.html', err => {
if (err) {
console.log(err);
process.exit();
} else {
console.log(Date.now(), 'Read File I/O');
}
});
console.log('process id', process.pid);
libuv默认会分配四个线程处理I/O异步操作 因此线程新增了4个
所以说node是单线程也指的是主线程是单线程的。
总结一下一个node进程创建了哪些线程
- Javascript 执行主线程
- watchdog 监控线程用于处理调试信息
- v8 task scheduler 线程用于调度任务优先级,加速延迟敏感任务执行
- 4 个 v8 线程 主要用来执行代码调优与 GC 等后台任务;以及用于异步 I / O 的 libuv 线程池。
node.js中的event loop详解
浏览器的 Event Loop 只分了两层优先级,一层是宏任务,一层是微任务。但是宏任务之间没有再划分优先级,微任务之间也没有再划分优先级。
而 Node.js 宏任务之间也是有优先级的,比如定时器 Timer 的逻辑就比 IO 的逻辑优先级高,因为涉及到时间,越早越准确;而 close 资源的处理逻辑优先级就很低,因为不 close 最多多占点内存等资源,影响不大。
于是就把宏任务队列拆成了五个优先级:Timers、Pending、Poll、Check、Close。
解释一下这五种宏任务:Timers Callback: 涉及到时间,肯定越早执行越准确,所以这个优先级最高很容易理解。
Pending Callback:处理网络、IO 等异常时的回调,有的系统会等待发生错误的上报,所以得处理下。
Poll Callback:处理 IO 的 data,网络的 connection,服务器主要处理的就是这个。
Check Callback:执行 setImmediate 的回调,特点是刚执行完 IO 之后就能回调这个。
Close Callback:关闭资源的回调,晚点执行影响也不到,优先级最低。
还有一点不同要特别注意:Node.js 的 Event Loop 并不是浏览器那种一次执行一个宏任务,然后执行所有的微任务,而是执行完一定数量的 Timers 宏任务,再去执行所有微任务,然后再执行一定数量的 Pending 的宏任务,然后再去执行所有微任务,剩余的 Poll、Check、Close 的宏任务也是这样。
其实按照优先级来看很容易理解:
假设浏览器里面的宏任务优先级是 1,所以是按照先后顺序依次执行,也就是一个宏任务,所有的微任务,再一个宏任务,再所有的微任务。
而 Node.js 的 宏任务之间也是有优先级的,所以 Node.js 的 Event Loop 每次都是把当前优先级的所有宏任务跑完再去跑微任务,然后再跑下一个优先级的宏任务。也就是是一定数量的 Timers 宏任务,再所有微任务,再一定数量的 Pending Callback 宏任务,再所有微任务这样。
为什么说是一定数量呢?
因为如果某个阶段宏任务太多,下个阶段就一直执行不到了,所以有个上限的限制,剩余的下个 Event Loop 再继续执行。
除了宏任务有优先级,微任务也划分了优先级,多了一个 process.nextTick 的高优先级微任务,在所有的普通微任务之前来跑。 所以,Node.js 的 Event Loop 的完整流程就是这样的:
- Timers 阶段:执行一定数量的定时器,也就是 setTimeout、setInterval 的 callback,太多的话留到下次执行
- 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
- Pending 阶段:执行一定数量的 IO 和网络的异常回调,太多的话留到下次执行
- 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
- Idle/Prepare 阶段:内部用的一个阶段
- 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
- Poll 阶段:执行一定数量的文件的 data 回调、网络的 connection 回调,太多的话留到下次执行。如果没有 IO 回调并且也没有 timers、check 阶段的回调要处理,就阻塞在这里等待 IO 事件
- 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
- Check 阶段:执行一定数量的 setImmediate 的 callback,太多的话留到下次执行。
- 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
- Close 阶段:执行一定数量的 close 事件的 callback,太多的话留到下次执行。
- 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
还有一个特别要注意的点,就是 poll 阶段:如果执行到 poll 阶段,发现 poll 队列为空并且 timers 队列、check 队列都没有任务要执行,那么就阻塞的等在这里等 IO 事件,而不是空转。 这点设计也是因为服务器主要是处理 IO 的,阻塞在这里可以更早的响应 IO。
完整的event loop
process.nextTick、setImmediate、setTimeout对比
process.nextTick
setTimeout(()=>{
console.log('TIMEOUT FIRED');
}, 0)
new Promise((resolve=>{
resolve(2)
})).then((res)=>{
console.log('promise',res);
})
process.nextTick(()=>{
console.log(1);
process.nextTick(()=>{console.log(3);});
});
/*
1
3
promise 2
TIMEOUT FIRED
*/
setImmediate
setImmediate(()=> {
console.log(1);
setImmediate(()=>{console.log(2);});
});
setTimeout(()=> {
console.log('TIMEOUT FIRED');
}, 0);
这个结果不固定,同一台机器测试结果也有两种:
// TIMEOUT FIRED =>1 =>2
或者
// 1=>TIMEOUT FIRED=>2
- 事件队列进入timer,性能好的 小于1ms,则不执行回调继续往下。若此时大于1ms, 则输出 TIMEOUT FIRED 就不输出步骤3了。
- poll阶段任务为空,存在setImmediate 直接进入setImmediate 输出1
- 然后再次到达timer 输出 TIMEOUT FIRED
- 再次进入check 阶段 输出 2
原因在于setTimeout 0 node 中至少为1ms,也就是取决于机器执行至timer时是否到了可执行的时机。
做个对比就比较清楚了:
setImmediate(()=> {
console.log(1);
setImmediate(()=>{console.log(2);});
});
setImmediate(()=>{console.log(4);});
setTimeout(()=> {
console.log('TIMEOUT FIRED');
}, 20);
// 1=>2=>TIMEOUT FIRED
此时间隔时间较长,timer阶段最后才会执行,所以会先执行两次check,出处1,2 下面再看个例子 poll阶段任务队列
var fs = require('fs')
fs.readFile('./yarn.lock', () => {
setImmediate(() => {
console.log('1')
setImmediate(() => {
console.log('2')
})
})
setTimeout(() => {
console.log('TIMEOUT FIRED')
}, 0)
})
// 结果确定:
// 输出始终为1=>TIMEOUT FIRED=>2
- 读取文件,回调进入poll阶段
- 当前无任务队列,直接check 输出1 将setImmediate2加入事件队列
- 接着timer阶段,输出TIMEOUT FIRED
- 再次check阶段,输出2 结论:
process.nextTick(),效率最高,消费资源小,但会阻塞CPU的后续调用;
setTimeout(),精确度不高,可能有延迟执行的情况发生,且因为动用了红黑树,所以消耗资源大;
setImmediate(),消耗的资源小,也不会造成阻塞,但效率也是最低的。