javascript 并发模型与事件循环

字数 1570阅读 111

并发(concurrency)和并行(parallelism)区别

  • 并发是宏观概念,我分别有任务 A 和任务 B,在一段时间内通过任务间的切换完成了这两个任务,这种情况就可以称之为并发。

  • 并行是微观概念,假设 CPU 中存在两个核心,那么我就可以同时完成任务 A、B。同时完成多个任务的情况就可以称之为并行。

JavaScript 的并发模型基于“事件循环”。

一、运行时概念

可视化描述

Javascript执行引擎的主线程运行的时候,产生堆(heap)和栈(stack),程序中代码依次进入栈中等待执行,若执行时遇到异步方法,该异步方法会被添加到用于回调的队列(queue)中【即JavaScript执行引擎的主线程拥有一个执行栈/堆和一个任务队列

  • 栈: 函数调用形成了一个栈帧
function foo(b) {
  var a = 10;
  return a + b + 11;
}

function bar(x) {
  var y = 3;
  return foo(x * y);
}

console.log(bar(7)); // 返回 42

当调用 bar 时,创建了第一个帧 ,帧中包含了 bar 的参数和局部变量。当 bar 调用 foo 时,第二个帧就被创建,并被压到第一个帧之上,帧中包含了 foo 的参数和局部变量。当 foo 返回时,最上层的帧就被弹出栈(剩下 bar 函数的调用帧 )。当 bar 返回的时候,栈就空了。

  • 堆: 对象被分配在一个堆中,即用以表示一大块非结构化的内存区域。
  • 队列: 一个 JavaScript 运行时包含了一个待处理的消息队列。每一个消息都关联着一个用以处理这个消息的函数。
    事件循环期间的某个时刻,运行时从最先进入队列的消息开始处理队列中的消息。为此,这个消息会被移出队列,并作为输入参数调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。
    函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

二、事件循环(Event Loop)

Event Loop
  • queue : 如上文的解释,值得注意的是,除了IO设备的事件(如load)会被添加到queue中,用户操作产生 的事件(如click,touchmove)同样也会被添加到queue中。队列中的这些事件会在主线程的执行栈被清空时被依次读取(队列先进先出,即先被压入队列中的事件会被先执行)。
  • callback: 被主线程挂起来的代码,等主线程执行队列中的事件时,事件对应的callback代码就会被执行

【注:因为主线程从”任务队列”中读取事件的过程是循环不断的,因此这种运行机制又称为Event Loop(事件循环)

console.log(1);
setTimeout(function() {
    console.log(2);
},5000);
console.log(3);
//输出结果:
//1
//3
//2
解释:
    1. JavaScript执行引擎主线程运行,产生heap和stack
    1. 从上往下执行同步代码,log(1)被压入执行栈,因为log是webkit内核支持的普通方法而非WebAPIs的方法,因此立即出栈被引擎执行,输出1
    1. JavaScript执行引擎继续往下,遇到setTimeout()t异步方法(如图,setTimeout属于WebAPIs),将setTimeout(callback,5000)添加到执行栈
    1. 因为setTimeout()属于WebAPIs中的方法,JavaScript执行引擎在将setTimeout()出栈执行时,注册setTimeout()延时方法交由浏览器内核其他模块(以webkit为例,是webcore模块)处理
    1. 继续运行setTimeout()下面的log(3)代码,原理同步骤2
    1. 当延时方法到达触发条件,即到达设置的延时时间时(5秒后),该延时方法就会被添加至任务队列里。这一过程由浏览器内核其他模块处理,与执行引擎主线程独立
    1. JavaScript执行引擎在主线程方法执行完毕,到达空闲状态时,会从任务队列中顺序获取任务来执行。
    1. 将队列的第一个回调函数重新压入执行栈,执行回调函数中的代码log(2),原理同步骤2,回调函数的代码执行完毕,清空执行栈
    1. JavaScript执行引擎继续轮循队列,直到队列为空
    1. 执行完毕

三、微任务(Macrotask) 和 宏任务(Microtask)

Event Loop 2

不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask)宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。

Event Loop 执行顺序如下所示:
    1. 首先执行同步代码,这属于宏任务
    1. 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
    1. 执行所有微任务
    1. 当执行完所有微任务后,如有必要会渲染页面
    1. 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数
实例代码
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')
// script start 
// async2 end 
// Promise 
// script end 
// promise1 
// promise2 
// async1 end 
// setTimeout
微任务包括: promiseMutationObserver
宏任务包括: scriptsetTimeoutsetIntervalsetImmediateI/OUI rendering

这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。

四、永不阻塞

事件循环模型的一个非常有趣的特性是,与许多其他语言不同,JavaScript 永不阻塞。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR请求返回时,它仍然可以处理其它事情,比如用户输入。

遗留的例外是存在的,如 alert 或者同步 XHR,但应该尽量避免使用它们。

参考: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Using_promises
前端面试之道
https://blog.kaolafed.com/2017/04/21/JavaScript%E5%B9%B6%E5%8F%91%E6%A8%A1%E5%9E%8B%E4%B8%8EEvent%20Loop/

推荐阅读更多精彩内容