JavaScript 的并发处理 -- Event Loop 微任务、宏任务的理解

前言: 本文是我通过阅读文章和视频得到一些个人理解;(链接:信息来源以供参考)。如果我有误读的地方, 非常欢迎指出。

首先我们看一下下面的代码:

    setTimeout(() => {
        console.log('第1个定时器回调')
    }, 100)
    setTimeout(() => {
        console.log('第2个定时器回调')
    }, 0)
    
    console.log('打印3')

先预测一下,打印结果。然后复制代码运行验证一下。

如果你的答案是依次打印 3 、2 、1 那么恭喜你答对了。

但是需要问 2 个为什么:

1. js 不是单线程吗?为什么可以一边执行代码, 一边执行定时器?

其实 JS 确实是单线程,也就是说 JS 代码只能一行行压入执行栈中去执行;完成后执行下一行。而定时器,其实不是 js 在执行; 它是 window 也就是浏览器提供的 API;浏览器负责定时器的执行; 完成后再由 JS 的执行栈去完成回调。这里就引出第二个问题, 回调在什么时候执行呢?

2. 为什么延迟为 0ms 的定时器不是最先打印?到底执行顺序是怎么定的?

第一个问题我们知道了, 不论定时器设定了多久的延迟; 都会交给浏览器去处理;然后再把回调函数传递回来。但是,不是直接传递到 JS 的执行栈; 而是传到 "任务对列" 中去排列; 等 JS 执行栈里事情都做完了,才会从 "任务对列" 中依次选出排在最前面的回调,加入只能栈去执行。

event-loop.gif

以上面的代码为例, 其过程如下:

  1. JS 运行, 从上往下执行代码;
  2. 执行到第一个定时器;把执行过程交给浏览器;浏览器开始到计时了;
  3. 执行栈不等它、继续向下走;发现第二个定时器,还是交给浏览器; 0ms 后就完成了; 浏览器把回调函数放到任务对列里排队;
  4. 执行栈不会立刻去管任务对列,因为它还要继续忙着往下走,执行 '打印3'
  5. 此时,执行栈忙完了,才会去任务对列里找事情做;发现队列里有一个回调函数(第二个定时器的); 执行它 '第2个定时器回调'
  6. 此时执行栈、任务对列都空了; 这点事可能几微秒就处理完了;就开始等着;
  7. 浏览器终于完成了倒计时 100ms, 才把第一个定时器的回调放入任务对列;
  8. 执行栈正闲着,于是从任务对列里拿出这个回调, 执行 '第1个定时器回调'
  9. 最终执行栈、任务对列、浏览器都没事做了。执行结束。

除了定时器, promise, Ajax 请求, 事件监听,所有异步操作都是同样的道理。一旦 JS 执行到异步操作, 都一律抛给浏览器;等浏览器响应之后,将回调函数加入"任务对列"。需要注意的是:任务队列又分为宏任务(marco task queue)微任务(mirco task queue)。 promise.then, mutationObserver, process.nextTick 都属于微任务。当执行栈为空时, 会优先完成微任务(mirco task queue);再去完成宏任务(marco task queue).

下面是一个相对复杂的例子,练习把这个执行过程再捋一遍


    setTimeout(() => {
        console.log('第1个定时器回调')
    }, 0)

    setTimeout(() => {
        console.log('第2个定时器回调')
    }, 2000)
    
    console.log(1)
    
    document.addEventListener('click', () => {
        console.log('click')
    })
    
    console.log(2)
    
    Promise.resolve(10).then((val) => {
        console.log(val)
    })
    console.log(3)

其过程如下:

  1. JS 运行, 从上往下执行代码;
  2. 执行到第一个定时器,交给浏览器处理; 倒计时0ms完成, 回调函数进入宏任务队列(marco task queue);执行到第二个定时器; 交给浏览器处理; 开始 2000ms 的倒计时;
  3. 继续执行,打印 1
  4. 继续执行到事件绑定; 交给浏览器;
  5. 继续执行,打印 2
  6. 继续执行到 Promise, 交给浏览器;立刻resolve了10, 将回调函数加入微任务队列(mirco task queue)
  7. 继续执行,打印 3
  8. 此时执行栈也许只用了几微秒,就把事情都做完了;你还没来得及做任何一次点击;此时任务对列里有 Promise.then 的回调和第一个定时器的回调;此时已经闲下来的执行栈,优先执行微任务队列(mirco task queue)里的Promise.then 的回调,先执行打印 10;完成后再去执行宏任务队列(mirco task queue)的定时器回调('第1个定时器回调');
  9. 此时,你开始不断点击页面;浏览器的事件监听将每次点击的回调加入任务对列;执行栈是如此之快,它不断从任务对列拿出点击事件的回调执行,打印 click;执行后执行栈空了,它又去任务对列寻找;如此循环;
  10. 在你点击第三次和第四次之间,第二个定时器倒计时结束,把回调加入到任务对列;你的第四次点击,因此,排在执行栈处理了定时器回调之后('第2个定时器回调');
  11. 等你停止点击之后,执行栈将任务对列都执行空了。浏览器仍旧在监听点击事件; 如果你继续点击,浏览器会往任务对列添加回调; 并触发执行栈继续工作,执行一个个回调。

从这里可以发现, 为什么当单线程的 JS 进行异步操作时,并不会阻断整个页面,因为浏览器帮助你把异步的操作都完成,并把回调函数排列在任务队列。JS 只需要关注主线程中的工作; 然后在闲下来之后,不断执行任务队列即可。

但实际上,也可以发现并非完全不影响; 当某一个回调函数的任务太复杂,它会一直拖住执行栈;让消息队列中排队的其他回调函数无法执行;此时就会出现诸如页面渲染卡顿、交互事件响应慢;定时器超时等现象。

写在最后

浏览器运行 Js 和在 Node 中,event-loop 的机制会有区别;如果对 Node 的 event-loop 有兴趣,可以查看官网的介绍和这两篇文章《浏览器与Node的事件循环(Event Loop)有何区别?》《Nodejs探秘:深入理解单线程实现高并发原理》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容