彻底搞懂JavaScript单线程异步执行机制

背景

看过 Chrome浏览器进程工作原理和机制 一文我们知道,浏览器每个渲染进程都有一个主线程,而且主线程既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件(在渲染主线程渲染过程中频繁收到来自 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理。比如接收到资源加载完成消息后渲染进程就要着手进行 DOM 解析;接收到鼠标点击消息后渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件)。要让这么多不同类型任务在主线程中有条不紊地执行,就需要一个系统来统筹调度这些任务,这个统筹调度系统就是今天要讲的消息队列和事件循环。

消息队列

消息队列是一种数据结构,可以存放要执行的任务,要添加任务的话,添加到队列尾部,要取出任务的话,从队列头部取出,它符合“先进先出”的特点。

在这里插入图片描述

主线程执行的任务都从消息队列中获取,其他线程要想发送任务让主线程去执行,只需要将任务添加到该消息队列中即可。如果其他进程想要发送任务给页面主线程,先通过 IPC 把任务发送给渲染进程专门一个用来接收其他进程消息的 IO 线程,它会将这些消息封装成任务发送给渲染主进程,后续就像其他线程给主线程发送任务一样将其添加到消息队列。

由于是多个线程操作同一个消息队列,因此在添加任务和取出任务时还会加上一个同步锁。

消息队列中的任务类型也非常多,包含很多内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等。以及跟页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等

消息队列中的任务是通过事件循环机制来执行的。

关于JavaScript异步

JavaScript是一门单线程语言 一切JavaScript中说的多线程都是纸老虎 本质都是用单线程模拟出来的

先来一张来自大神(mr.z 大佬)的图
在这里插入图片描述

本文的目的就是彻底弄懂JavaScript的事件循环的执行机制。

事件循环(Event Loop)是一个程序结构,用于等待和发送消息和事件。

首先,不论你是面试求职,还是日常的开发工作,给定的几行代码,我们需要准确的知道代码执行输出的结果,也就是代码执行的顺序。因为JavaScript是单线程语言,所以它的语句都是顺序执行的,也就是同步任务。但是,如果加载一个包含了很多图片的页面,如果都是同步任务,要是图片资源巨大加载不出来,岂不是后面的内容都显示不了。所以就要了解JavaScript的事件执行机制。
其实,当打开页面时,页面的渲染过程涉及到很多同步任务(页面骨架,页面元素的渲染),也有一些异步任务(图片音乐之类占用资源大耗时比较久的任务)。

  • 同步任务
  • 异步任务

同步任务和异步任务会在不同的执行场所等待被执行,同步任务会在主线程中执行,碰到异步任务会先放到异步队列中等待被执行。

下图用来说明每当js遇到事件时会如何执行
在这里插入图片描述

ajax

AJAX(Asynchronous Javascript And XML)异步JavaScript和XML,相信我们在日常工作中ajax已经用过很多了,通过和后端进行少量数据交互,实现网页异步更新。
ajax出现之前,如果网页需要更新部分数据,比如我们获取一个表单数据,就必须重载整个网页页面。
有了ajax技术,我们就可以在不重载整个网页的情况下,对网页的某部分进行更新,比如只更新表单数据,而不用对表单整个页面进行重载。
看一个例子:

$.ajax({
	url: 'xxx.com/list',
	data: xxx,
	success: () => {
		console.log('接收数据了')
	}
})
console.log('代码执行结束')

这一段简单的ajax请求代码就是个简单的异步任务:

  1. ajax进入事件列表,注册回调函数success
  2. 执行主线程同步任务console.log('代码执行结束')
  3. ajax事件完成,回调函数success进入事件队列(Event Queue)
  4. 主线程从事件队列读取回调函数success并执行

setTimeout

setTimeout也是异步任务。那到底是如何异步执行的呢?

通常我们延时执行时会想到用setTimeout

setTimeout(() => {
	console.log('延时1s')
}, 1000)

逐渐用的多了,问题也多了,有时候延时1秒但是实际却过了3,4秒才执行。比如下面的例子

setTimeout(() => {
	task()
}, 1000)
console.log('主流程console')
sleep(100000) // 执行了很久的函数

我们知道setTimeout是个异步任务,这里我们想要task在1秒后执行,但是实际等待的时间却远远不止1秒,这是为什么?我们弄懂这里的setTimeout异步任务是如何执行的就明白了:

  1. 首先遇到setTimeout,发现是个异步任务时,会注册其回调函数task
  2. 执行主流程console
  3. 执行主流程任务sleep函数,执行了很久很久…
  4. 3秒后,回调task进入到事件队列中(如果这时候主线程任务都执行完了是空闲的,下一步主线程就会从事件队列中取到这个函数去主线程中执行了)。此时应该开始执行事件队列中注册的task了,但是主线程还在执行sleep,因为js是单线程,所以只能继续等待
  5. sleep终于执行完了,task从事件队列中取出进入到主线程中执行

上述流程了解完,我们就知道setTimeout的延时时间,是指定经过多长时间把要执行的任务加入到事件队列中去,所以真实等待的时长就不仅仅是根据setTimeout的延时时间参数来决定的了,还依赖主线程中任务执行的时长。

setInterval

setIntervalsetTimeout很像,它的含义是指定多长时间将注册的函数放入事件队列中,如果前面的任务耗时很长,同样也会延长这个时间。通过对setTimeout的说明,知道对setInterval(fn, ms)的理解需要注意的是:不是每隔ms执行一次fn,而是每隔ms,会将fn加入到事件队列中一次。
所以,细品这句话:如果fn执行时间超过了ms,用户是看不出这个时间间隔的。因为,如果你每隔1秒加入一个fn,而这个fn每次执行都需要5秒才能结束,也就是主线程第一个执行完的时候去查队列时,发现队列中还有4个没执行呢,此时主线程就又会去队列中拿一个新的fn到主线程中去执行,也就没有什么休息时间了。

Promise

ES6为我们提供了Promise来处理异步操作。

先看一个例子:

setTimeout(() => {
  console.log('setTimeout')
})

new Promise((resolve) => {
  console.log('promise')
  resolve()
}).then(() => {
  console.log('then')
})

console.log('console')

根据之前的介绍,如果promisesetTimeout的事件循环机制相同的话,结果应该是

// promise
// console
// setTimeout
// then

但是正确的执行结果却是:

// promise
// console
// then
// setTimeout

宏任务vs微任务

宏任务

前面介绍,页面中的大部分任务都是在主线程上执行的,这些任务包括:

  • 渲染事件(如解析 DOM、计算布局、绘制)
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
  • JavaScript 脚本执行事件
  • 文件读写

渲染进程内部会维护多个消息队列,比如延迟执行队列和普通消息队列。主线程采用 for 循环的方式,不断从这些任务队列中取出任务并执行,我们把这些消息队列中的任务都称为宏任务
宏任务可以满足我们大部分的日常需求,不过如果对时间精度要求较高的需求,宏任务就难以胜任了。例如页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,所以很难控制任务执行的开始和结束时间。比如下面这段示例代码:

function timerCallback2() {
  console.log(2)
}
function timerCallback() {
  console.log(1)
  setTimeout(() => {
    timerCallback2
  }, 0)
}
setTimeout(timerCallback, 0)

这段代码中目的是通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,如果这两个任务之间插入了其他系统任务,就很有可能会影响到第二个定时器的执行时间,而这个是否穿插任务是代码不可控的。

scriptsetTimeoutsetInterval、UI渲染,I/OpostMessage 等都是宏任务

微任务

微任务的执行时机是在主函数执行结束之后、当前宏任务结束之前。每个宏任务都关联了一个微任务队列,在当前宏任务执行过程中有可能会产生多个微任务,这时候需要一个微任务队列来保存这些微任务,这个队列是给 V8 引擎内部使用的,无法通过 JavaScript 直接访问。

在现代浏览器里面,产生微任务有两种方式

  • 使用 MutationObserver 监控某个 DOM 节点
  • 使用 Promise

微任务的工作流程有几下几个特点:

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列
  • 微任务执行时长影响到宏任务的时长,在写代码时要注意控制微任务的执行时长
  • 在一个宏任务重,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行
宏任务微任务案例

所以,虽然都是异步任务,但是因为 promisesetTimeout 是不同的异步任务,所以执行时机不同,循环任务执行时也会不同,很多时候输出结果可能会跟你想象的不大一样。

上述的例子具体的执行步骤:

  1. 因为整段代码在script中,所以首先整段代码作为一个宏任务,进入主线程
  2. 遇到setTimeout,此时将其回调函数注册后发布到事件队列中
  3. 接下来遇到promise,因为我们知道promise是立即执行的(可参考Promise原理和使用),而then回调会分发到微任务队列中
  4. 立即执行console
  5. 到此为止第一轮宏任务完成。开始检查微任务队列中是否有回调,发现了then,执行
  6. 到此第一个事件循环结束了。
  7. 第二轮事件循环开始,从宏任务队列中查看,发现了setTimeout的回调,执行。
  8. 没任务了,结束。

所以,宏微任务的关系如下:
在这里插入图片描述
最后一个复杂的例子,检查你是否掌握了这个执行机制:

console.log(1)

setTimeout(() => {
  // setTimeout_1
  console.log(2)
  new Promise((resolve) => {
    console.log(3)
    resolve()
  }).then(() => {
    // promise_3
    console.log(4)
  })
}, 1000)

new Promise((resolve) => {
  console.log(5)
  resolve()
}).then(() => {
  // promise_1
  setTimeout(() => {
    // setTimeout_3
    console.log(6)
  })
  console.log(7)
})

setTimeout(() => {
  // setTimeout_2
  console.log(8)
  new Promise((resolve) => {
    console.log(9)
    resolve()
  }).then(() => {
    // promise_2
    console.log(10)
  })
})

第一轮事件循环:

  1. 整体代码作为script宏任务,遇到console.log(1)
  2. 遇到setTimeout回调1秒后放入宏事件队列,记为setTimeout_1
  3. promise立即执行,console.log(5)同时then放入微任务中,记为promise_1
  4. 又遇到setTimeout,立即放入宏任务队列中,记为setTimeout_2
  5. 此时宏任务完成,去微任务队列中看看这一轮宏任务产生的微任务有哪些,发现只有一个promise_1,执行时候发现又遇到一个宏任务,将其放入宏任务队列中,记为setTimeout_3,继续执行console.log(7)
  6. 过了1秒了,setTimeout_1也被加入到宏队列中了
  7. 第一轮循环结束,结果:1,5,7,此时任务队列
宏任务
setTimeout_2
setTimeout_3
setTimeout_1

第二轮事件循环:

  1. 拿到宏任务队列中setTimeout_2,执行console.log(8),立即执行promiseconsole.log(9),同时将其then加入到微任务中,记为promise_2
  2. 宏任务完成,去微任务队列中发现promise_2,执行console.log(10)
  3. 此时第二轮循环结束,结果为:1,5,7,8,9,10

第三轮事件循环:

  1. 拿到宏任务setTimeout_3 console.log(6),无微任务,结束
  2. 此时结果为:1,5,7,8,9,10,6

第四轮事件循环:

  1. setTimeout_1 立即执行console.log(2),遇到promise立即执行console.log(3),同时将then加入到微任务中,记为promise_3
  2. 宏任务结束,查看微任务队列,执行promise_3,执行console.log(4)
  3. 本轮循环结束,结果为:1,5,7,8,9,10,6,2,3,4

结束语

从头到尾都在强调JavaScript是一门单线程语言,不管是什么新框架还是新语法糖实现的所谓的异步,其实都是根据JavaScript的事件循环机制原理用同步的方式来模拟的,事件循环是JavaScript实现异步的一种方法,也是它的执行机制。
牢牢记住这两点:

  • JavaScript是一门单线程语音
  • 事件循环是它的执行机制
  • 微任务还是宏任务针对的是回调函数,当前的new Promise或者定时器是同步立即执行的,异步的是他们的then和回调函数方法
  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
JavaScript 是一门单线程的编程语言,意味着它在任意给定的时刻只能执行一个任务。这是因为 JavaScript 在最初设计时是作为浏览器脚本语言而诞生的,用于操作网页的 DOM(文档对象模型)。 在 JavaScript 中,任务按照它们被调用的顺序执行,这种方式称为同步执行。当一个任务执行时,其他任务必须等待它的完成才能继续执行。这种同步执行的特性可以确保数据的一致性,但也可能导致阻塞,特别是在执行耗时较长的任务时。 为了解决阻塞问题,JavaScript 引入了异步执行的概念。通过异步执行,可以让某些任务在后台执行,而不会阻塞其他任务的执行。常见的异步操作包括网络请求、文件读写和定时器等。在 JavaScript 中,通常使用回调函数、Promise、async/await 等方式来处理异步操作。 回调函数是最早被广泛使用的异步处理方式。通过将一个函数作为参数传递给异步操作,在操作完成后调用该函数来处理结果。然而,使用回调函数嵌套多层会导致代码可读性和维护性的降低,这就是所谓的"回调地狱"问题。 为了解决回调地狱问题,Promise 和 async/await 出现了。Promise 是一种用于处理异步操作的对象,它可以链式调用,避免了回调函数嵌套的问题。而 async/await 是基于 Promise 的语法糖,使异步代码看起来更像同步代码,更易于理解和编写。 总结起来,JavaScript单线程的,但通过异步执行可以提高程序的性能和响应速度。同步执行保证了数据的一致性,而异步执行允许在后台处理耗时操作,提高了用户体验。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值