JS 中的 Event Loop

lxf2023-03-14 11:58:02

JS 中的 Event Loop

什么是Event Loop?

首先,js主要是处理用户的交互,而用户的交互无非就是响应DOM的增删改,这就决定了js只能是单线程的语言。 而对于js主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是我们今天要讲的Event Loop。

通俗的认识:这个问题反映到我们生活中也是一样的道理,你一天要干很多事情,有一些事情又很耗时但是它又没那么重要,你会不会想办法来提高自己的效率好让自己解放出来呢?这样你才能合理利用一天的时间去做更重要的事情。于是人们发明了各种工具,洗衣机、电饭煲等等。你不用关心它是怎么做的,什么时候做完,洗好衣服你去晒,做好饭你去吃,你只需要把任务交给它,处理好了它就会通知你,你什么时候有空了再去处理结果就可以了。

这个例子中,你是一个人,不能同时做很多事情,反映到代码中你是单线程,你把耗时的任务交给这些工具,工具完成后会通知你结果,你就解放出来了可以先去做更重要的事情,这就是Event Loop的作用。

首先聊聊单线程所存在两个基本问题?

问题一:如何处理单线程中的阻塞问题

问题二:如何处理高优先级的任务

同步任务和异步任务

单线程,就是指一次只能完成一件任务,如果在同个时间有多个任务的话,这些任务就需要进行排队,前一个任务执行完,才会执行下一个任务。

但如果有一个任务的执行时间很长,比如文件的读取或者数据的请求等等,那么后面的任务就要一直等待,这就会影响用户的使用体验。

同步模式:就是前一个任务执行完成后,再执行下一个任务,程序的执行顺序与任务的排列顺序是一致的、同步的;

异步模式: 每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行队列上的后一个任务,而是执行回调函数;后一个任务则是不等前一个任务的回调函数的执行而执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。

类似现在的90后带娃,带娃逛街,娃看到了玩具想要,你看到价格之后就拒绝,娃就开始趴在地上撒泼打滚。这时候就不要慌,在他旁边拿起手机打几盘王者,等游戏结束,你的娃就自然而然就趴累了,乖乖跟着你空手回家。实在不济就揍一顿。

异步任务的出现

1). 同步任务都在JS引擎线程上执行,形成一个 执行栈

2). 事件触发线程管理一个 任务队列,异步任务触发条件达成,将回调事件放到任务队列中

3). 执行栈中所有同步任务执行完毕,此时JS引擎线程空闲,系统会读取任务队列,将可运行的异步任务回调事件添加到执行栈中,开始执行。

JS 中的 Event Loop 代码实例
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)、PromiseMutationObserver,这些代码执行便是微任务

注意:

1.微任务是用于插队的

2.微任务的优先级高于宏任务,但是一般是由宏任务开始执行。

注:为什么说微任务是用于插队的呢?因为每次执行宏任务之前都会看有没有微任务,不管微任务是何时加入队列的,都会清空微任务队列之后才会执行下一个宏任务

js中的Event Loop的执行总结

1、主线程开始执行一段代码, 假设开始执行一个 script 标签内的代码,将代码放入执行栈中执行,同步代码优先执行,执行过程中,当遇到任务源时,判断是宏任务还是微任务
2、 如果是宏任务,加入到宏任务队列中,如果是微任务,加入到微任务队列中
3、 同步代码执行完成,执行栈空闲,检查微任务队列中是否有可执行任务,如果有,依次执行所有微任务队列中的任务。如果没有。当前任务执行结束。
4、 渲染UI
5、 检查宏任务队列是否有可执行的宏任务,如果有,取出队列中最前面的那个宏任务,加入到执行栈中开始执行,然后重复 步骤1- 5。直到宏任务队列中所有任务执行结束

JS 中的 Event Loop

实例代码:

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请求不需要句柄而是直接在循环中执行。 JS 中的 Event Loop

上图中,用户输入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);

JS 中的 Event Loop 说明不是单线程的。
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);

JS 中的 Event Loop

libuv默认会分配四个线程处理I/O异步操作 因此线程新增了4个

所以说node是单线程也指的是主线程是单线程的。

总结一下一个node进程创建了哪些线程

  1. Javascript 执行主线程
  2. watchdog 监控线程用于处理调试信息
  3. v8 task scheduler 线程用于调度任务优先级,加速延迟敏感任务执行
  4. 4 个 v8 线程 主要用来执行代码调优与 GC 等后台任务;以及用于异步 I / O 的 libuv 线程池。

node.js中的event loop详解

浏览器的 Event Loop 只分了两层优先级,一层是宏任务,一层是微任务。但是宏任务之间没有再划分优先级,微任务之间也没有再划分优先级。

而 Node.js 宏任务之间也是有优先级的,比如定时器 Timer 的逻辑就比 IO 的逻辑优先级高,因为涉及到时间,越早越准确;而 close 资源的处理逻辑优先级就很低,因为不 close 最多多占点内存等资源,影响不大。

于是就把宏任务队列拆成了五个优先级:Timers、Pending、Poll、Check、Close。

JS 中的 Event Loop 解释一下这五种宏任务:

Timers Callback: 涉及到时间,肯定越早执行越准确,所以这个优先级最高很容易理解。

Pending Callback:处理网络、IO 等异常时的回调,有的系统会等待发生错误的上报,所以得处理下。

Poll Callback:处理 IO 的 data,网络的 connection,服务器主要处理的就是这个。

Check Callback:执行 setImmediate 的回调,特点是刚执行完 IO 之后就能回调这个。

Close Callback:关闭资源的回调,晚点执行影响也不到,优先级最低。

JS 中的 Event Loop 还有一点不同要特别注意:

Node.js 的 Event Loop 并不是浏览器那种一次执行一个宏任务,然后执行所有的微任务,而是执行完一定数量的 Timers 宏任务,再去执行所有微任务,然后再执行一定数量的 Pending 的宏任务,然后再去执行所有微任务,剩余的 Poll、Check、Close 的宏任务也是这样。

其实按照优先级来看很容易理解:

假设浏览器里面的宏任务优先级是 1,所以是按照先后顺序依次执行,也就是一个宏任务,所有的微任务,再一个宏任务,再所有的微任务。

JS 中的 Event Loop 而 Node.js 的 宏任务之间也是有优先级的,所以 Node.js 的 Event Loop 每次都是把当前优先级的所有宏任务跑完再去跑微任务,然后再跑下一个优先级的宏任务。 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

JS 中的 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

  1. 事件队列进入timer,性能好的 小于1ms,则不执行回调继续往下。若此时大于1ms, 则输出 TIMEOUT FIRED 就不输出步骤3了。
  2. poll阶段任务为空,存在setImmediate 直接进入setImmediate 输出1
  3. 然后再次到达timer 输出 TIMEOUT FIRED
  4. 再次进入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

  1. 读取文件,回调进入poll阶段
  2. 当前无任务队列,直接check 输出1 将setImmediate2加入事件队列
  3. 接着timer阶段,输出TIMEOUT FIRED
  4. 再次check阶段,输出2 结论: 

process.nextTick(),效率最高,消费资源小,但会阻塞CPU的后续调用; 

setTimeout(),精确度不高,可能有延迟执行的情况发生,且因为动用了红黑树,所以消耗资源大;

setImmediate(),消耗的资源小,也不会造成阻塞,但效率也是最低的。