理解javascript中event loop, 了解其背后的原理

问题


今天看到一个问题,什么是 event loop , setTimeoutPromise.resolve().then() 的表现为何又不一样,想了想,决定梳理一下相关知识,这里只说浏览器环境,先不考虑 Node 环境中的 process.nextTick()之类的


先做个简单测试

    console.log(1)
    setTimeout(() => console.log(2), 0)
    new Promise((resolve, reject) => {
        console.log(3)
        resolve()
    }).then(() => {
        console.log(4)
    })

答案是

    1 3 4 2

如果你知道上面的答案,并了解其原理,那你就不用看了,如果你不知道,那就接着看,另外,如果你不了解 Promise, 建议先去看看阮一峰的 promise,了解 promise 最基本的概念

javascript


javascript 是单线程语言,至于其为什么是单线程呢,而不采用多线程。原因就在于,javascipt 是面向用户端的一门语言,其主要作用是与用户交互,渲染数据,操作dom,如果是多线程,就会出现一个问题,比如说,一个线程删除了一个dom节点,另外一个线程添加了一个dom节点,以那个线程为主呢,就会出现混乱的情况。当然,我们可以在操作一个dom之后,加上锁,只允许一个线程操作,但这样,无形之中,程序又平添了复杂程度,未必是一个好的办法。另外,HTML5 中提供了 web workerapi,用来处理例如因大量计算而占用主线程的情况,但按照规定,其也受制于主线程,而且不能操作dom。所以,javascript 是一门单线程语言,也只可能是单线程语言

任务队列


为什么会有任务队列呢,还是因为 javascript 单线程的原因,单线程,就意味着一个任务一个任务的执行,执行完当前任务,执行下一个任务,这样也会遇到一个问题,就比如说,要向服务端通信,加载大量数据,如果是同步执行,js 主线程就得等着这个通信完成,然后才能渲染数据,为了高效率的利用cpu, 就有了 同步任务异步任务 之分。

- 同步任务,进入主线程,一个一个执行
- 异步任务, 进入  `event table ` , 注册回调函数 ` callback `, 任务完成之后,
  将 `callback` 移入  `event queue`, 等待主线程调用

流程图

这里写图片描述

任务队列是一中先进先出的数据结构,排在最前面的优先被主线程读取执行,只要当前执行栈一清空,主线程马上会读取任务队列中的第一任务执行。但当遇到定时器时,需要先检查当前时间是否满足定时器需求,满足执行,不满足自动执行下一个

看一段代码就知道什么意思了

console.log("主线程开始执行任务")
const data = {}
console.log("发现异步任务,执行异步任务,注册回调函数 success 和 fail")
$.ajax({
    method: 'post',
    url: 'https://localhost:8080/user/new',
    data: data,
    success: res => {
        console.log('异步任务执行完成,服务器响应成功,向event queue 推入 success') 
    },
    fail: err => {
        console.log('异步任务执行,服务器响应失败,向event queue 推入 fail')  
    }
})

console.log("同步任务执行完成,主线程检查 event queue ")
console.log("ajax 请求成功响应, 检测到 event queue 中的 success ,执行success")

上面的代码,我已经对 event loop 有了初步了解,那么接下来梳理一下,同样是异步任务,为何不同类型的异步任务表现却不一样

setTimeout

setTimeout 想必每个人都很熟悉,一次性定时器,我们一般都这么用它

    setTimeout(() => {
        console.log("五秒了,我要执行了")
    }, 5000)

一般情况下,我们设置一个定时器,如果不去特意的测试,是发现不了,定时器时间并不准确这个问题的,我们做个测试

    console.time('timer')
    setTimeout(() => console.timeEnd('timer'), 5000)
    sleep(1000000) // 或者执行一大堆复杂的逻辑

我们这个时候发现,输出的时间,并不是 精确的 5000 ,而是根据你后面同步任务执行的时间会有所影响

我们来分析一下,为什么

- 主线程执行console.time('timer'), 开始及时
- 执行到setTimeout(), 进入到 `event table` 中并注册回调函数
- 开始执行sleep(), sleep 执行的很慢,这个时候,5 秒到了,定时器时间到了之后向
  `event queue` 中推送了回调函数,但主线程一直在忙,没有时间去检查 
  `event queue`, 一直等到 `sleep()` 执行完成,主线程才检查 `event queue`,
  并执行回调函数

上述的流程走完,我们知道 setTimeout 这个函数,是经过指定时间后,把要执行的任务加入到 event queue 中,又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于3秒。

那么,我们还会遇到一种情况

    setTimeout(fn, 0)

那么这种情况,假设主线程并不繁忙,那么 fn 一定就会在 不延迟执行吗,并不会,
即便主线程为空,0ms 实际上也是达不到的。根据HTML的标准,最低是 4ms。所以不可能做到 0ms

setInterval

前面说了setTimeout, 那么 setInterval 自然也要说一下。 setInterval 的表现和 setTimeout 区别不大,区别之处在于,setInterval 是重复执行,每隔 ms 秒之后,将已经注册好的回调函数推入 event queue 中,等待主线程调用。同样,如果主线程繁忙,那回调函数的执行自然也就会有延迟

promise

回到最开始的例子

    console.log(1)
    setTimeout(() => console.log(2), 0)
    new Promise((resolve, reject) => {
        console.log(3)
        resolve()
    }).then(() => {
        console.log(4)
    })

如果同样按照异步任务的先后执行顺序去考虑的话,答案应该是 1 3 2 4, 但为什么会是 1 3 4 2 呢, 这里就要说到 微任务宏任务 了, 除了广义的 同步任务异步任务 之分,对 异步任务 还有更细致的区分,就是 micro Task (微任务)macro Task (宏任务)

  • micor Task 包括 promise, 当然还有 Node 环境中的,但这里先不梳理

  • macro Task 包括 scriptsetTimeoutsetInterval

不同类型的任务,会进入不同的 event queue, 同是相同类型的任务,进入相同的 event queue, 例如: setTimeoutsetInterval 会进入相同的 event queue

再来分析一下第一个例子

<script>
    console.log(1)
    setTimeout(() => console.log(2), 0)
    new Promise((resolve, reject) => {
        console.log(3)
        resolve()
    }).then(() => {
        console.log(4)
    })
</script>
  1. 整个 script 就是一个 宏任务,主线程开始执行任务
  2. 同步执行 console.log(1), 然后到 setTimeout , 主线程发现 setTimeout 是一个 异步的宏任务, 执行 setTimeout , 并将注册的回调函数分发到 macro Task(宏任务)event queue 中,等待执行
  3. 然后是 promise , new Pormise 马上执行,同理, 主线程发现有一个 异步的微任务 promise.then, 注册回调函数,并将回调函数 分发到 micro Task(微任务) 中的 event queue 中,等待调用
  4. 好了, 第一轮事件循环结束,主线程开始检查 异步任务, 它会优先检查micro Task(微任务)event queue , 结果发现了 promise.then, 执行 promise.then
  5. 微任务执行结束了,开始检查 macro Taskevent queue, 检查到 setTimeout ,执行。
  6. 这也就是为什么 后注册的 promise.then 会优先 先注册 的 setTimeout 执行的原因

用一张图来表示

这里写图片描述

测试

最后,来一段比较复杂的代码,来测试一下

console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise((resolve, reject) => {
        console.log(3)
        resolve()
    }).then(() => {
        console.log(4)
    })
}, 0)

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

setTimeout(() => {
    console.log(7)
    new Promise((resolve, reject) => {
        console.log(8)
        resolve()
    }).then(() => {
        console.log(9)
    })
}, 0)

console.log(10)

第一轮事件循环开始
1. 整块代码作为一个宏任务, 进入主线程开始执行, 遇到 console.log(1), 输出 1
2. 遇到一个 setTimeout 宏任务, 将其回调函数推入 macro Taskevent queue 中,macro Taskevent queue 中记一个任务 setTimeout1
3. 然后碰到 promise 微任务, 直接执行 new Promise 输出 5, 并将 then 函数的回调函数推入 micro Taskevent queue 中, micro Taskevent queue 中记 一个 微任务 promise1
4. 又遇到了 setTimeout 宏任务, 同理,将其回调函数推入 macro Taskevent queue 中,macro Taskevent queue 中记一个任务 setTimeout2
5. 最后,执行 console.log(10), 输出 10
上一轮事件循环结束,我们发现,已经输出 1 5 10 了, 按照我们之前所说,这个时候,主线程会去检查 是否存在微任务,不难发现,这个时候的 event queue 是这个样子的

micro Task (微任务)macro Task(宏任务)
promise1setTimeout1
setTimeout2

执行 promise1,输出 6 , 微任务执行完成,第一轮事件循环结束

第二轮事件循环开始
1. 首先执行 setTimeout1, 输出 2,
2. 然后碰到 promise 微任务, 直接执行 new Promise 输出 3, 同理将 回调函数推入 micro Taskevent queue 中, 记为 promise2
第二轮事件循环结束,输出 2 3, 这个时候的 event queue

micro Task (微任务)macro Task(宏任务)
promise2setTimeout2

执行 微任务 promise2, 输出 4, 微任务执行完成, 开始第三轮宏任务事件循环

第三轮宏任务事件循环开始
1. 执行 setTimeout2 , 输出 7,
2. 碰到 promise 微任务, 直接执行 new Promise 输出 8, 同理将 回调函数推入 micro Taskevent queue 中, 记为 promise3
3. 宏任务执行结束
分析 event queue

micro Task (微任务)macro Task(宏任务)
promise3

执行 微任务 promise3, 输出 9, 微任务执行完成

最后,代码的输出为 1 5 10 6 2 3 4 7 8 9, 共进行了三次事件循环

总结

总的来说, js 是一门单线程语言, 只要把握住这一点,配合 event loop 执行机制,学习 js 会轻松很多

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值