js 多个定时器_JS事件循环详解

引言

javascript 是一门单线程的语言,在同一个时间只能做完成一件任务,如果有多个任务,就必须排队,前面一个任务完成,再去执行后面的任务。作为浏览器端的脚本语言,javascript 的主要功能是用来和用户交互以及操作 dom。假设 javascript 不是单线程语言,在一个线程里我们给某个 dom 节点增加内容的时候,另一个线程同时正在删除这个 dom 节点的内容,则会造成混乱。

由于 js 单线程的设计,假设 js 程序的执行都是同步。如果执行一些耗时较长的程序,例如 ajax 请求,在请求开始至请求响应的这段时间内,当前的工作线程一直是空闲状态, ajax 请求后面的 js 代码只能等待请求结束后执行,因此会导致 js 阻塞的问题。

javascript 单线程指的是浏览器中负责解释和执行 javascript 代码的只有一个线程,即为 js 引擎线程,但是浏览器的渲染进程是提供多个线程的,如下:

  1. js 引擎线程
  2. 事件触发线程
  3. 定时器触发线程
  4. 异步 http 请求线程
  5. GUI 渲染线程

一、异步 & 同步

为解决上述类似上述 js 阻塞的问题,js 引入了同步和异步的概念。

1、什么是同步?

“同步”就是后一个任务等待前一个任务结束后再去执行。

2、什么是异步?

“异步”与同步不同,每一个异步任务都有一个或多个回调函数。webapi 会在其相应的时机里将回调函数添加进入消息队列中,不直接执行,然后再去执行后面的任务。直至当前同步任务执行完毕后,再把消息队列中的消息添加进入执行栈进行执行。

异步任务在浏览器中一般是以下:

  1. 网络请求
  2. 计时器
  3. DOM 监听事件
  4. ...

二、什么是执行栈(stack)、堆(heap)、事件队列(task queue)?

c63f0fe65e0478c88ba0147a2ea7d41f.png

1、执行栈

“栈”是一种数据结构,是一种线性表。特点为 LIFO,即先进后出 (last in, first out)。

利用数组的 push 和 shift 可以实现压栈和出栈的操作。

645a06e9c2caadb7c0c6b73835451af6.png

在代码运行的过程中,函数的调用会形成一个由若干帧组成的栈。

function foo(b) {  let a = 10;  return a + b + 11;}function bar(x) {  let y = 3;  return foo(x * y);}console.log(bar(7))

上面代码最终会在控制台打印42,下面梳理一下它的执行顺序。

  1. console.log 函数作为第一帧压入栈中。
  2. 调用 bar,第二帧被压入栈中。帧中包含着 bar 的变量对象。
  3. bar 调用 foo,foo 做一位第三帧被压入栈中,帧中包含着 foo 的变量对象。
  4. foo 执行完毕然后返回。被弹出栈。
  5. bar 执行完毕然后返回,被弹出栈。
  6. log 函数接收到 bar 的返回值。执行完毕后,出栈。此时栈已空。
d28cd9a57500a3287b9080f5c59da093.png

2、堆

对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。

堆和栈的区别

首先,stack 是有结构的,每个区块按照一定次序存放,可以明确知道每个区块的大小;heap 是没有结构的,数据可以任意存放。因此,

stack 的寻址速度要快于 heap。

其次,每个线程分配一个 stack,每个进程分配一个 heap,也就是说,stack 是线程独占的,heap 是线程共用的。

此外,stack 创建的时候,大小是确定的,数据从超过这个大小,就发生 stack overflow 错误,而 heap 的大小是不确定的,

需要的话可以不断增加。

public void Method1(){    int i=4;    int y=2;    class1 cls1 = new class1();}

上面代码这三个变量和一个对象实例在内存中的存放方式如下。

30317452417af8f9e0eb534c9fd2068c.png

从上图可以看到,i、y和cls1都存放在stack,因为它们占用内存空间都是确定的,而且本身也属于局部变量。但是,cls1指向的对象实例存放在heap,因为它的大小不确定。作为一条规则可以记住,所有的对象都存放在heap。

接下来的问题是,当Method1方法运行结束,会发生什么事?

回答是整个stack被清空,i、y和cls1这三个变量消失,因为它们是局部变量,区块一旦运行结束,就没必要再存在了。而heap之中的那个对象实例继续存在,直到系统的垃圾清理机制(garbage collector)将这块内存回收。因此,一般来说,内存泄漏都发生在heap,即某些内存空间不再被使用了,却因为种种原因,没有被系统回收。

3、事件队列和事件循环

队列是一种数据结构,也是一种特殊的线性表。特点为 FIFO,即先进先出(first in, first out)

利用数组的 push 和 pop 可实现入队和出队的操作。

cd4a6dfba719262540613a2865122734.png

事件循环和事件队列的维护是由事件触发线程控制的。

事件触发线程线程同样是由浏览器渲染引擎提供的,它会维护一个事件队列。

js 引擎遇到上文所列的异步任务后,会交个相应的线程去维护异步任务,等待某个时机,然后由事件触发线程将异步任务对应的回调函数加入到事件队列中,事件队列中的函数等待被执行。

js 引擎在执行过程中,遇到同步任务,会将任务直接压入执行栈中执行,当执行栈为空(即 js 引擎线程空闲), 事件触发线程 会从事件队列中取出一个任务(即异步任务的回调函数)放入执行在栈中执行。

执行完了之后,执行栈再次为空,事件触发线程会重复上一步的操作,再从事件队列中取出一个消息,这种机制就被称为 事件循环 (Event Loop)机制。

为了更好地理解Event Loop,请看下图(转引自Philip Roberts的演讲《Help, I'm stuck in an event-loop》)。

dcedb7d4d862541ba86e26a6459f7acc.png

例子代码:

console.log('script start')setTimeout(() => {  console.log('timer 1 over')}, 1000)setTimeout(() => {  console.log('timer 2 over')}, 0)console.log('script end')// script start// script end// timer 2 over// timer 1 over

模拟 js 引擎对其执行过程:

第一轮事件循环:

  1. console.log 为同步任务,入栈,打印“script start”。出栈。
  2. setTimeout 为异步任务,入栈,交给定时器触发线程处理(在1秒后加入将回调加入事件队列)。出栈。
  3. setTimeout 为异步任务,入栈,交给定时器触发线程处理(在4ms之内将回调加入事件队列)。出栈。
  4. console.log 为同步任务,入栈,打印"script end"。出栈。

此时,执行栈为空,js 引擎线程空闲。便从事件队列中读取任务,此时队列如下:

2c5ace5d1b3f97671a1e2696036fb355.png

第二轮事件循环

  1. js 引擎线程从事件队列中读取 cb2 加入执行栈并执行,打印”time 2 over“。出栈。

第三轮事件循环

  1. js 引擎从事件队列中读取 cb1 加入执行栈中并执行,打印”time 1 over“ 。出栈。

注意点:

上面,timer 2 的延时为 0ms,HTML5标准规定 setTimeout 第二个参数不得小于4(不同浏览器最小值会不一样),不足会自动增加,所以 "timer 2 over" 还是会在 "script end" 之后。

就算延时为0ms,只是 time 2 的回调函数会立即加入事件队列而已,回调的执行还是得等到执行栈为空时执行。

四、宏任务 & 微任务

在 ES6 新增 Promise 处理异步后,js 执行引擎的处理过程又发生了新的变化。

看代码:

console.log('script start')setTimeout(function() {    console.log('timer over')}, 0)Promise.resolve().then(function() {    console.log('promise1')}).then(function() {    console.log('promise2')})console.log('script end')// script start// script end// promise1// promise2// timer over

这里又新增了两个新的概念, macrotask (宏任务)和 microtask (微任务)。

所有的任务都划分到宏任务和微任务下:

  • macrotask : script 主代码块、setTimeout、setInterval、requestAnimationFrame、node 中的setimmediate 等。
  • microtask : Promise.then catch finally、MutationObserver、node 中的process.nextTick 等。

js 引擎首先执行主代码块。

执行栈每次执行的代码就是一个宏任务,包括任务队列(宏任务队列)中的。执行栈中的任务执行完毕后,js 引擎会从宏任务队列中去添加任务到执行栈中,即同样是事件循环的机制。

当在执行宏任务遇到微任务 Promise.then 时,会创建一个微任务,并加入到微任务队列中的队尾。

微任务是在宏任务执行的时候创建的,而在下一个宏任务执行之前,浏览器会对页面重新渲染(task >> render >> task(任务队列中读取))。 同时,在上一个宏任务执行完成后,页面渲染之前,会执行当前微任务队列中的所有微任务。

0e0edbff2453e50e42faa8b9f736469f.png

所以上述代码的执行过程就可以解释了。

js 引擎执行 promise.then 时,promise1、promise2 被认为是两个微任务按照代码的先后顺序被加入到微任务队列中,script end执行后,栈空。

此时当前宏任务(script 主代码块)执行完毕,并不从当前宏任务队列中读取任务。而是立马清空当前宏任务所产生的微任务队列。将两个微任务依次放入执行栈中执行。执行完毕,打印 promise1、promise2。栈空。 此时,第一轮事件循环结束。

紧接着,再去读取宏任务队列中的任务,time over 被打印。栈空。

因此,宏任务和微任务的执行机制如下:

  1. 执行一个宏任务(栈中没有就从宏任务队列中获取)
  2. 执行过程中遇到微任务,就将它添加到微任务的任务队列中
  3. 宏任务执行完毕,立即执行当前微任务队列中的所有微任务(依次执行)
  4. 当前所有微任务执行完毕后,开始检查渲染,GUI 线程接管渲染
  5. 渲染完毕后,JS 引擎继续开始下一个宏任务,从宏任务队列中获取

async & await

因为,async 和 await 本质上还是基于 Promise 的封装,而 Promise 是属于微任务的一种。所以使用 await 关键字与 Promise.then 效果类似:

setTimeout(_ => console.log(4))async function main() {  console.log(1)  await Promise.resolve()  console.log(3)}main()console.log(2)// 1// 2// 3// 4

async 函数在 await 之前的代码都是同步执行的, 可以理解为 await 之前的代码都属于 new Promise 时传入的代码,await 之后的所有代码都是 Promise.then 中的回调,即在微任务队列中。

五、总结

  1. js 单线程实际上时解释执行 js 代码的只有一个线程,但是浏览器的渲染是多线程的。
  2. 异步和同步的概念与区别,异步任务有哪些。
  3. 栈、堆、队列的特点和使用场景。
  4. 事件队列以及事件循环机制。
  5. es6 下,宏任务与微任务的执行过程。

参考:

  • JavaScript 异步与事件循环
  • 并发模型与事件循环
  • 微任务、宏任务与Event-Loop
  • JavaScript 运行机制详解:再谈Event Loop
  • JS事件循环
  • [译] 深入理解 JavaScript 事件循环(二)— task and microtask
  • Help, I'm stuck in an event-loop

原文作者:大芒果哇

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值