阅读 828

浏览器专题系列 - 事件循环机制

浏览器专题系列 - 事件循环机制

前言

先简单聊聊JavaScript与此主题有关的其它内容,加深读者对内容的吸收与理解

解释性语言

脚本语言是为了缩短传统的编写-编译-链接-运行(edit-compile-link-run)过程而创建的计算机编程语言

脚本语言编写的代码通常是逐行解释执行而非编译(c/c++,java),所以通常又叫做解释性语言

所以javascriptpython,shell一样,也是一门优秀的解释性语言

制约解释性语言的性能瓶颈之一就是解释器,好在javascript有著名的V8(Android,Chrome)与JSCore(IOS,Safari)等优秀的解释器引擎,js得以大范围推广,它们是必不可少的功臣

单线程模型

javascript语言的一大特点就是单线程,即同一时间只能做一件事

为什么是单线程?

作为浏览器脚本语言,javascript的主要用途是与用户互动,以及操作DOM,这决定了它只能是单线程,否则会带来很复杂的同步问题

例如:假定javascript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器不知道应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,javascript就是单线程的

单线程的优势

  • 不会出现因线程之间争夺资源导致的死锁现象
  • 所有代码都是同步执行的
  • 没有线程切换的资源开销

单线程的缺点

  • 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着

任务队列

浏览器中存在有很多耗时的任务的场景,网路请求(ajax),监听事件的传递,定时器等等

javascript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去

于是对所有任务进行了划分,分为 同步任务异步任务

同步任务

在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务

即顺序执行

异步任务

不进入主线程、而进入任务队列的任务

只有当任务队列通知主线程,某个异步任务可以执行了的时候,该任务才会进入主线程执行

当解释器引擎遇到异步任务时,会将其挂起,在时机成熟时,将其回调函数放入到任务队列中

打个比方,就像炒菜一样,锅里的东西一直在翻炒着(主线程),各种调料(不同的异步任务)只会在需要的时候被加入锅里,然后完成其使命

关于引擎的构成可参阅浏览器专题系列 - 浏览器内核

异步机制

  1. 所有同步任务都在主线程上执行,形成一个执行栈
  2. 主线程之外,还存在一个任务队列,只要异步任务有了运行结果,就在"任务队列"之中放置一个事件
  3. 一旦执行栈中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。哪些对应的异步任务,于是结束等待状态,进入执行栈,开始执行

只要主线程空了,就会去读取"任务队列",这就是javascript的运行机制。主线程不断重复上面的第3步

事件与回调

"任务队列"是一个事件的队列,IO设备(鼠标,键盘等)完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件

只要指定过回调函数,这些事件(鼠标点击,键盘按键,页面滚动等)发生时就会进入"任务队列",等待主线程读取

所谓"回调函数",就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数

队列有先进先出的特性,主线程会优先读取任务队列最前面的事件

主线程的读取过程基本上是自动的,只要”执行栈“一清空,"任务队列"上第1位的事件就会自动进入主线程

针对于定时器事件,主线程会先检查一下执行时间,只有到了规定的时间,才能返回给主线程,即到了一定时间后才把事件对应的回调函数放入执行栈中

Event loop

什么是Event Loop

也就是通常说的事件循环

Event Loop是一个执行模型,在不同的地方(不同的语言)有不同的实现

js的事件循环负责执行代码、收集和处理事件以及执行队列中的子任务,与其它语言的模型截然不同

js的事件循环模型与许多其他语言相比有一个非常有趣的特性是,它永不阻塞,处理 I/O 通常通过事件和回调来执行

因此当一个应用正等待一个 AJAX 请求返回时,它仍然可以处理其它事情,如用户输入,鼠标点击/滚动等

什么是执行栈

执行栈可以认为是一个存储函数调用的栈结构,遵循先进后出的原则

js开始执行代码的时候会首先创建一个main函数,然后根据执行的代码,根据先进后出的原则,后执行的函数先弹出栈

这里有一个可视化执行栈的在线工具 -> Loupe

示例

function a(v){
    return v*4
}
function b(v){
    return a(v*3)
}
console.log(b(2))
复制代码

进栈顺序

1. main()
2. console.log(b(2))
3. b(2)
4. a(6)
复制代码

出栈顺序

1. a(6)  // 24
2. b(2)  // 24
3. console.log(b(2)) // 24
4. main()
复制代码

使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈(如下图所示)

图片

浏览器中的 Event Loop

通过上面的阐述,大概也是知道了js是如何执行的,了解了如何以单线程同步执行的方式处理异步任务的机制,下面开始详细描述一下执行的过程

执行 js 代码的时候其实就是往执行栈中放入函数/回调函数,当遇到异步的代码时,会被挂起并在需要执行的时候加入到 任务队列(有多种)中,一旦执行栈为空,时间循环机制 就会从 任务队列中拿出需要执行的代码并放入执行栈中执行

所以本质上来说 js 中的异步依旧还是同步的行为

任务源

不同任务源会被分到不同的任务队列中

微任务

微任务(microtask):jobs

  • promise
  • MutationObserver
  • ...

宏任务

宏任务(macrotask):tasks

  • script
  • xhr
  • setTimeout
  • setInterval
  • requestAnimationFrame
  • I/O
  • UI rendering
  • ...

Event Loop执行顺序

这里先抛出浏览器中js的事件循环的不同任务的执行顺序

在事件循环中,每进行一次循环操作称为 tick

  1. 执行所有的同步代码
  2. 执行完所有同步代码后,执行栈为空,查询是否有异步任务需要执行
  3. 执行 微任务,如果在执行 微任务 的过程中,又产生了 微任务,那么会加入到 微任务的队列 的末尾,也会在这个周期被调用执行
  4. 执行完所有微任务后,如有必要会渲染页面:
    • 判断document是否需要更新
      • 大部分显示设备还是 60Hz 的刷新率,所以 16.6ms 才会更新一次渲染
    • 判断是否有 resize 或者 scroll 事件,有的话会去触发事件
      • 所以 resize 和 scroll 事件也是至少 16.6ms 才会触发一次,即自带节流功能。
    • 判断是否触发了 media query(媒体查询)
    • 更新动画并且发送事件
    • 判断是否有全屏操作事件
    • 执行 requestAnimationFrame 回调
    • 执行 IntersectionObserver 回调,该方法用于判断元素是否可见,可以用于懒加载上
    • 更新界面
  5. 开始下一轮 Event Loop ,从宏任务中取出一个执行,再然后微任务...

小结归纳

  • 宏任务一次只从宏任务队列中取一个任务执行,执行完后就去执行微任务队列中的任务
  • 微任务队列中所有的任务都会被依次取出来执行,直到微任务队列为空;
  • 执行UI rendering,它的时间节点是在执行完所有的微任务之后,下一个宏任务之前
  • 定时器不是绝对准确的
    • SetTimeout/SetInterval只是在指定时间后将其回调函数放入到宏任务队列中

图片

示例

下面通过一个示例来阐述一下代码的执行顺序

console.log('script start')

async function async1() {
  await async2()

  console.log('async1 end')
}
async function async2() {
  console.log('async2 end')
}

async1()

setTimeout(function() {
  console.log('setTimeout')
}, 0)

new Promise(resolve => {
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')
  })
  .then(function() {
    console.log('promise2')
  })

console.log('script end')
复制代码
  1. 执行同步代码 输出 script start
    • console.log('script start')
  2. 执行async1中的同步代码输出async2 end
    • async1()
    • async2()
      • 因为函数有async标志,所以返回的是Promise,我们记为P1
    • console.log('async2 end')
    • await
      • await为让出线程的标志,即执行await async2()后就回到 async1外部
      • 而await 就是 generator 加上 Promise 的语法糖,且内部实现了自动执行 generator,所以这里又套上了一层Promise,我们记为P2,其包裹着P1
  3. 遇到异步任务定时器,我们先给它挂起,记为 S1
  4. 执行new Promise 的构造函数,输出 Promise
    1. console.log('Promise')
    2. resolve()
    • 生成新的微任务记为 P3
  5. 执行同步代码输出 script end
  6. 此时所有的同步代码执行完成,微任务与宏任务队列情况分别是
    1. 微任务:[P2,P3]
    2. 宏任务:[S1]
  7. 执行所有的微任务
    1. 取出P2执行,产生新的微任务 P1加入到队列中 [P3,P1]
    2. 取出P3执行, 打印输出promise1,产生新的微任务 P4 加入队列中 [P1,P4]
    3. 取出P1执行, 无输出,产生新的微任务 P5 加入队列中 [P4,P5]
    4. 取出P4执行, 输出promise2,无新的任务产生 [P5]
    5. 取出P5执行, 输出async1 end,无新的任务产生 []

低版本浏览器的结果为

// script start --> async2 end --> Promise --> script end --> promise1 --> promise2
// async1 end --> setTimeout
复制代码

因为 await 后面跟着 Promise 的话,async1 end 需要等待3个 microtick 才能执行到

async1 其等价的v8优化前的旧版代码为

function async1(){
  new Promise((resolve)=>{
    const p = new Promise(res=>res(async2()))
    p.then(()=>{
      console.log('async1 end')
      resolve()
    })
  })
}
复制代码

新版浏览器的结果为

// script start --> async2 end --> Promise --> script end --> async1 end 
//  promise1 --> promise2 --> setTimeout
复制代码

在本例中上述的 P2包裹P1 合并成了一个,即await后面如果是Promise将不会再进行一次Promise包装

async1 其等价的v8优化后的代码为

function async1(){
  new Promise((resolve)=>{
    const p = Promise.resolve(async2())
    p.then(()=>{
      console.log('async1 end')
      resolve()
    })
  })
}
复制代码

小结

  1. 在新版浏览器中,await promiseFun,3个 microtick 被优化为了 2个 microtick
    • new Promise 替换为了 Promise.resolve
    • Promise.resolve的参数如果是Promise则直接返回这个Promise

补充

问题追溯可查看

自测

自测试1

console.log(1);

setTimeout(() => {
  console.log(2);
  Promise.resolve().then(() => {
    console.log(3)
  });
});

new Promise((resolve, reject) => {
  console.log(4)
  resolve(5)
}).then((data) => {
  console.log(data);
})

setTimeout(() => {
  console.log(6);
})

console.log(7)
复制代码
点击查看答案

输出结果

  
  // 1 4 7 5 2 3 6
  复制代码

自测2

console.log(1);

setTimeout(() => {
  console.log(2);
  Promise.resolve().then(() => {
    console.log(3)
  });
});

new Promise((resolve, reject) => {
  console.log(4)
  resolve(5)
}).then((data) => {
  console.log(data);
  
  Promise.resolve().then(() => {
    console.log(6)
  }).then(() => {
    console.log(7)
    
    setTimeout(() => {
      console.log(8)
    }, 0);
  });
})

setTimeout(() => {
  console.log(9);
})

console.log(10);
复制代码
点击查看答案

输出结果

  
  // 1 4 10 5 6 7 2 3 9 8
  复制代码

参考

原文首发于个人博客,专题系列会整理多篇,与大家一起学习,共同进步,如有错误,还请斧正

浏览器专题系列文章

文章分类
前端
文章标签