谈一谈浏览器与Node.js中的JavaScript事件循环,宏任务与微任务机制

JavaScript中的异步代码

JavaScript是一个单线程非阻塞的脚本语言。这代表代码是执行在一个主线程上面的。但是JavaScript中有很多耗时的异步操作,例如AJAX,setTimeout等等;也有很多事件,例如用户触发的点击事件,鼠标事件等等。这些异步操作并不会阻塞我们代码的执行。例如:

js 复制代码
let a = 1;
setTimeout(() => {
  console.log('->', a)
}, 10);
a = 2;
// 输出 -> 2

可以看到,上述代码在浏览器中执行时,遇到setTimeout操作,并没有阻塞等待异步操作的结束再继续执行代码,而是先继续执行后面的代码。等异步操作结束后,浏览器再回来执行异步回调中的代码。因此,上述代码的console.log输出时,a的值已经变为了2。

这些异步非阻塞的实现,就是靠Javascript中的事件循环机制。

JavaScript中的线程

上面说到JavaScript是一个单线程的语言,这句话并不完全对。单线程指的是代码在一个主线程中运行,但是代码所触发的任务不一定在主线程运行。除了执行代码的线程之外,执行JavaScript的环境中还包含其他很多线程。其中浏览器的线程与Node.js中的线程也不相同。

浏览器中的线程

注意,这里对于浏览器线程进行了抽象和总结。实际上浏览器的线程和进程要更复杂,而且有时候会根据浏览器版本的不同而变化,因此仅供参考。

  • JS主线程
    负责运行JavaScript代码,解析HTML,CSS,构建DOM树,布局和绘制页面等等。
  • 事件监听线程
    负责监听触发的各种事件,放入事件循环中。
  • HTTP请求线程
    负责处理各类网络请求。
  • 定时触发器线程
    为setInterval,setTimeout定时触发操作等操作进行定时计数的线程。

浏览器中的进程

上面的线程实际上都在浏览器中的渲染进程中包含。一个浏览器要想正常运行,只做上述的操作是不够的。我们以Chrome为例,列举一个浏览器运行所需要的进程。

  • 浏览器进程
    负责网页外的界面功能,例如地址栏,书签等等。
  • GPU进程
    负责使用GPU渲染界面。
  • 网络进程
    负责网络相关的请求处理。
  • 插件进程
    负责浏览器插件运行。
  • 渲染进程
    负责网页内页面展示相关的操作,即上一节浏览器中的线程包含的所有线程都在这个进程中执行。

一个浏览器可以拥有多个标签页,在不同的标签页中,除了渲染进行之外,都是共享的。即我们打开一个新的标签页时,会产生一个新的渲染进程。(当在原标签页中打开新标签页,且属于同一个域则共享一个渲染进程)

进程与线程的关系

上面我们了解了浏览器中的进程和线程,有些同学就会有疑问,为什么要设立这么多的进程和线程?

进程是操作系统分配资源的基本单位,而线程是CPU任务调度和执行的基本单位。

简单理解下就是一个完整的应用程序是以进程为单位的,即至少有一个进程。而一段程序/代码在CPU的独立执行则至少以线程为单位。不同的进程和不同的线程都可以并行运行。

一个进程可以包含很多个线程,多个线程共享一个进程的资源(比如内存)。当一个进程崩溃后不会影响其他进程,但是当一个线程崩溃,它所在的整个进程都会崩溃掉,这个进程内的其他线程也会崩溃。

因此,为了同时并行执行代码和异步请求,浏览器中的渲染进程包含很多线程来并行运行任务。而为了让不同标签页的网页不互相影响,不同标签页拥有独立的渲染进程。这样即使某个网页崩溃,也不会影响其他标签页。

Node.js中的线程

  • JS主线程
    负责运行JavaScript代码。
  • libuv的异步I/O线程池
    负责实现事件循环和异步IO等操作,在不同操作系统的具体实现方式不同。
  • 用户创建的线程

上述这些进程和线程的说明也仅仅是进行了抽象和简化,事实上浏览器和Node.js中的进程和线程数要更多,处理也更复杂。

宏任务与微任务

Javascript中的异步任务大致可以分为两种:宏任务和微任务。宏任务和微任务的执行顺序和优先级是不同的,具体的执行顺序问题我们在事件循环中描述,这里先来看一下,哪些操作属于宏任务,哪些属于微任务。这里仅仅是简单介绍,更详细的要在了解事件循环之后说明。

宏任务

任务 浏览器 Node.js 描述
setTimeout 在指定的毫秒数后调用函数
setInterval 定时调用函数
script标签 整体代码块
I/O请求 例如文件请求,网络请求等
DOM事件 例如点击事件,hover事件等
requestAnimationFrame 浏览器重绘前更新动画
postMessage iframe跨域通信
MessageChannel 管道通信
setImmediate 一次事件循环执行完毕调用

微任务

任务 浏览器 Node.js 描述
Promise中resolve和reject回调
async函数中的await异步函数
MutationObserver 监听DOM变动触发
process.nextTick 当前任务结束后执行

事件循环

与上面进程与线程的介绍一样,在浏览器中与Node.js中实现循环的方式也并不相同。下面我们来分别简单介绍一下。注意,这仅仅是对执行逻辑的抽象和总结,实际上浏览器和Node.js中的实现要更复杂。

浏览器中的事件循环

浏览器中的事件循环可以分为两个队列,宏任务队列和微任务队列。具体的任务执行顺序如下:

  1. 解析HTML中遇到script标签,开始执行第一个宏任务。
  2. 在宏任务执行中遇到宏任务,执行其中的请求(例如网络请求,定时器),在请求完成后将回调放入宏任务队列中。
  3. 在宏任务执行中遇到微任务,暂不执行回调,而是放入微任务队列中。
  4. 宏任务执行完成。开始依次执行微任务队列中的任务。
  5. 微任务执行中遇到宏任务或者微任务,处理方式同上,分别放入各自的队列中。
  6. 微任务队列清空后,开始执行宏任务队列中的下一个任务。

在事件循环的流程中,微任务的优先级实际上更高,执行完一个宏任务之后,要执行微任务队列中的所有任务。

为什么要区分宏任务和宏任务,优先级也不同

因为不同任务的开销不同,有的任务需要调用不同的线程甚至进程,有的任务需要等待请求返回甚至定时。

  1. 如果将全部的任务同步执行,那些耗时较久的任务会阻塞,造成整个页面加载缓慢。假设有请求A耗时10秒,请求B耗时20秒,如果同步执行,需要耗费30秒。如果将请求由其它线程实现,回调放入宏任务,则执行流程变为:执行代码->碰到A请求,其他线程异步等待返回->继续执行代码->碰到b请求,其他线程异步等待返回。A和B就实现了异步请求,回调被分别放入宏任务,等待下次事件循环。耗时间为20秒。
  2. 为什么微任务的优先级更高?因为微任务大部分是耗时不太久,不需要等待其他线程/进程等待完成通知的。因此,微任务相当于在宏任务的基础上进行了"插队",拥有更高的优先级,也提高了页面的响应速度。

为什么script标签是宏任务呢?

  1. script标签可能需要异步请求获取,例如<script src="myscripts.js"></script>
  2. script标签是嵌入在HTML中的,浏览器需要将HTML中的script标签解析出来供执行,这个步骤需要耗费一定的时间。

浏览器事件循环的更多说明

WHATWG(网页超文本应用技术工作小组)在官网对事件循环和任务队列做出了更详细的说明和解释,可以作为参考:说明文档。在新的说明中,任务的分类和事件循环已经有了部分区别,这里简要说一下,更多还请直接查看文档:

  1. 事件循环不一定对应于多线程。例如多个事件循环可以在单个线程中协作调度。
  2. 任务队列并不是一个严格的队列,而是一个集合。每次从队列中取出一个可以被执行的任务,而不是选取第一个任务(可能该任务还在阻塞中)。
  3. 宏任务队列有多个,不同类型的任务(任务源)放置在不同的任务队列中。具体的选取规则浏览器根据实际情况确定。

Node.js中的宏任务队列

Node.js的官网给出了事件循环的文档。它的事件循环要比浏览器的看起来复杂一些。下面是Node.js的宏任务队列。

复制代码
   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

Node.js的宏任务队列并不是一整个队列,而是根据事件类型做出了区分,分为了六个队列,依次执行:

  1. timers 定时器队列,执行定时器的回调
  2. pending callbacks 挂起的回调函数,用于某些系统回调
  3. idle, prepare 仅在内部使用
  4. poll 执行I/O事件回调
  5. check setImmediate回调
  6. close callbacks close事件的回调,例如 socket.on('close', ...)

其中我们的大部分宏任务回调都会在poll阶段执行,除了timerscheckclose callbacks阶段的特殊回调。每个宏任务队列都有自己的微任务队列。

Node.js事件循环的流程

  1. 首先执行主线代码,遇到宏任务就分配到对应的宏任务队列中,微任务也划分到主线的微任务队列中,直到执行完毕。
  2. 执行主线代码的微任务队列中的所有任务。
  3. 没有宏任务则执行结束,有则开始事件循环。在事件循环中,按照上述的6个宏任务队列依次执行。下面的步骤是单个队列中的流程。
  4. 在单个宏任务队列中,选择一个宏任务执行。如果执行中遇到新的宏任务就分配到对应的宏任务队列中。遇到微任务就放到该宏任务的微任务队列中。
  5. 一个宏任务执行完毕后,执行process.nextTick中的回调(如果有)。
  6. 执行当前宏任务的微任务队列中的任务,直到微任务队列清空。
  7. 在上面的单个宏任务队列中,再选择一个宏任务执行。直到当前宏任务队列清空或者到达上限。
  8. 选择下一个宏任务队列执行。

6个宏任务队列都执行完毕,才叫做一次事件循环执行完毕。

Node.js的11版本之前的区别

其中,在Node.js的11版本之前,宏任务和微任务的执行关系与上述流程不同:

每个宏任务队列有一个微任务队列。在单个宏任务队列中,首先执行完所有的宏任务,如果遇到微任务就放到微任务队列中。当单个宏任务队列中的所有宏任务执行完毕后,再执行该宏任务队列的微任务队列。

对比执行流程的区别,可以看到Node.js的11版本提高了微任务队列中的优先级,让Node.js中微任务队列的优先级和浏览器中的表现类似。而process.nextTick可以看做是一个比微任务更高优先级的钩子。

注意

  • setTimeout的时间即使设置为0,也会有一个最小时间,因此它与setImmediate谁更早执行不一定。
  • 并不是所有回调函数都是异步的。例如new Promise(fun)中的回调是同步执行,在回调中遇到resolve(), reject()等才是微任务异步执行的。

参考

相关推荐
zhougl9962 小时前
html处理Base文件流
linux·前端·html
花花鱼2 小时前
node-modules-inspector 可视化node_modules
前端·javascript·vue.js
HBR666_2 小时前
marked库(高效将 Markdown 转换为 HTML 的利器)
前端·markdown
careybobo3 小时前
海康摄像头通过Web插件进行预览播放和控制
前端
TDengine (老段)4 小时前
TDengine 中的关联查询
大数据·javascript·网络·物联网·时序数据库·tdengine·iotdb
杉之5 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
喝拿铁写前端5 小时前
字段聚类,到底有什么用?——从系统混乱到结构认知的第一步
前端
再学一点就睡5 小时前
大文件上传之切片上传以及开发全流程之前端篇
前端·javascript
木木黄木木6 小时前
html5炫酷图片悬停效果实现详解
前端·html·html5
请来次降维打击!!!6 小时前
优选算法系列(5.位运算)
java·前端·c++·算法