【Javascript】- 异步与同步

1,551 阅读11分钟

异步与同步

有一天,我在开发自己程序的时候遇到了问题:一个取数据的函数,不能立即返回数据(总是过了一会才返回数据),于是我整个页面渲染出来都是空白的。我设置的变量去接受这个函数返回的数据,却一直都是null。于是,我开始思考“为什么变量接受不到数据”,就这样一步步的往前探索,终于我发现了Javascript中另一个重要的机制——同步和异步

概念

编写Javascript程序的时候,毫无疑问的会遇到发送HTTP请求、读取数据库数据、等待用户点击按钮等等情况,它们有一个相同点——都是耗时操作。一个程序中,每一秒都存在很多个操作,有耗时操作,也有不耗时操作,它们都在等待着被执行

只有当前这任务成了,才能去执行下一个任务,这也是程序正常运行的根本所在。试想如果所有的任务都乱来,那么处理器到底该执行哪一个?况且每个任务之间大多存在联系,后一个任务获取到前一个任务执行的结果,才能开始执行,否则就没有意义了

下面,我举一个简单的买票的例子,描述现实生活中类似的情况

当你去火车站买票的时候,如果只有一个售票窗口打开的话,毫无疑问你得排队,等着前面的人买完了,才能轮到你买。有的人做事干净利落,提前把证件都准好了,所以买票非常快;有的人就粗心大意,轮到自己了才想起来翻证件,而且找了半天又找不到,非常浪费时间。通常,售票员遇到这种人就会让他去旁边待着,找到了证件再过来买票。

上面的例子很简单吧?就是你日常生活中的买票——Javascript中同步和异步的概念就藏在其中

同步

当只有一个窗口的时候,排在后面的人必须等待前面的人完成购票,自己才能购票,这就是 同步

同步,要求多个任务必须排队等待被处理器执行,只有等到前一个任务完成,后一个任务才能开始执,必须按照顺序来。

异步

同样是只有一个窗口,你正在排着队,前面有一个人粗心大意,找不到证件,于是售票员说:“你先去旁边,找到了再过来办理,下一个!”,然后,就轮到你了!虽然你前面排着一个人,但是他可能会非常浪费时间,所以售票员跳过了他,直接来给你办理,最重要的是:你不用等待前面的人就可以马上办理购票。

异步,要求有多个任务需要被执行时,“谁快先执行谁”,可以不按顺序来。

上面购买火车票的例子只是粗略的描述了同步和异步的基本概念,接下来我们深入探究Javascript同步和异步的机制是如何运作的。

同步

Javascript是单线程语言,浏览器中负责解释和执行 JavaScript 代码的只有一个线程(主线程),意思就是,同一时刻,同一个Javascript引擎只能执行一个任务。在程序运行时,所有的任务都在Javascript的主线程上排队等待执行。当一个任务被执行时,它就会被推入调用栈,执行完毕后,又被推出调用栈

要理解什么是主线程,我们先要搞懂什么是执行环境调用栈

  • 执行上下文(Excution Context)

执行上下文指的是执行JavaScript代码的环境,它是一个抽象的概念,就像我们每个人都活在时间的长河中,但是时间却看不见摸不着。 只要有代码在JavaScript中运行,它就一定是在执行上下文中运行。函数的代码在函数的执行上下文内执行,而全局环境中的代码则在全局执行上下文内执行,而且每个函数都会创建属于自己的执行上下文

执行上下文是一个值得深入的概念,但不是本文主题,如果要更详细的了解,可以参考我的另一篇博文《一文说清Javascript的作用域》

  • 调用栈(Call Stack)

调用栈顾名思义是一个栈,是一种数据结构。它的作用就是存储代码运行时的执行上下文。下面是一段简单的代码,没有耗时操作,通过这段代码,来演示Javascript中代码的同步执行方式

const second = () => {
  console.log('Hello!')
}

const first = () => {
  console.log('The Start!')
  second()
  console.log('The End!')
}

first()

下面的图片演示了,从开始执行直到执行结束,调用栈中代码的情况

Javascript跟C语言相似,代码都是一行一行执行的。当上面的代码开始执行之前,Javascript引擎会先创建调用栈,然后创建一个全局执行上下文( main 主线程),接着代码开始执行。上面示意图的含义是:

  1. 引擎查询到存在 first()函数,于是将first()函数推入调用栈
  2. fisrt()中的第一行语句是console.log('The Start!'),所以它先入栈
  3. console.log('The Start!')执行完毕,立即出栈
  4. 引擎查询到fisrt()中的第二行语句是second(),入栈
  5. second()中也存在一条语句console.log('Hello!'),将其入栈
  6. 上一条语句执行完毕,出栈。此时栈中还剩下second()first()main()
  7. 引擎检测到second()执行完毕,于是将其弹出栈
  8. fisrt()也被检测到执行完毕,出栈
  9. 引擎检测到了main(),这是主线程,上面并没有可执行的代码,所以也将其出栈
  10. 整个程序运行结束

异步

先来看看这段代码,试着回答出打印的结果吧:

function f1(){
    console.log(1)
}

function f2() {
  setTimeout(() => {
    console.log(2)
  }, 2000)
}

function f3(){
    console.log(3)
}


f1()
f2()
f3()

你猜结果怎样?是不是 1,2,3?好吧,其实结果是 1,3,2。

你可能会不理解,不是说了Javascript代码是一行行按顺序执行吗,f2() 明明排在 f3() 的前面为什么是 f3 先运行然后打印出了结果? 这是因为 f2() 是一个耗时操作,需要耗费 2 秒时间才能运行完毕,所以这触发了Javascript的异步执行机制。

回调函数

如果 f2 是一个网络请求任务(耗时操作),而 f3 则是把请求返回的数据渲染到页面上,它必须等待 f2 执行完毕,然后返回结果,才能执行自己的任务。所以,这并不是我们想要的结果,我们要的是程序按顺序打印出1,2,3(即 f2 在 f3 之前执行完毕),那怎么办呢?

我们可以使用 回调函数来解决这个问题,回调函数并不是什么新的语法或机制,它只是普通函数的一种调用方式

function f1(){
    console.log(1)
}

function f2(callback) {
  setTimeout(() => {
    console.log(2)
    
    // 这就是 f3()
    callback()
  }, 2000)
}

function f3(){
    console.log(3)
}

f1()
f2(f3)

好了,现在去控制台查看,结果就是我们想到要的1,2,3了;上面代码的原理就是:将 f3 作为参数传入 f2 ,f2 在执行完耗时操作以后,再来调用我们传入的 f3 ,这样就保证了我们所要求的代码执行顺序。现在你应该明白了什么是回调函数了吧!

回调函数是Javascript中的一种异步编程方式,它非常容易理解,而且没有引入什么新概念。

事件循环和消息队列

JavaScript 单线程指的是浏览器中负责解释和执行 JavaScript 代码的只有一个线程,即为JS引擎线程(主线程),但是浏览器的渲染进程是提供多个线程的,它们都属于 Web API 的范畴。如下:

  • 事件触发线程(DOM Events)
  • 定时触发器线程(setTimeOut)
  • 异步http请求线程(Fetch API)
  • GUI渲染线程

其实你刚开始接触Javascript的时候你就已经不知不觉的使用到了异步操作了,我们用来给按钮加上事件监听用的是addEventListener(),你不点击按钮,它一定不会触发,其实它就是异步任务;我们通常会向后台请求数据,所以我们要发送Http请求,我们使用的XMLHttpRequestFetch API等等发送请求的方法,它们也都属于异步任务。而且,它们都属于 Web API——用于操作网页和浏览器的接口

注意,Javascript的全局执行上下文是Javascript引擎的一部分,而 Web API 不是,它会创建一个单独的执行上下文,而且不在Javascript引擎中。

Javascript处理异步任务的机制就是:遇到耗时操作(Web API中的方法)就将其耗时的代码放入 WebAPI 的执行上下文栈中运行,运行结束后,将其中包含的回调函数立即放入消息队列中;消息队列会将其中按顺序存放的回调函数,按顺序推入主线程所在的调用栈中执行。

我知道上面的这段话一时难以理解,所以我们还是通过代码来演示:

// 耗时操作
function netWork(callback){
	setTimeOut(()=> {
		console.log('get the network source')
		callback()
	},2000)
}

// 等待耗时操作执行完毕
function end(){
	console.log('Finished!')
}

// 模拟一个网络请求操作
netWork(end)

上面代码的意图是,netWork去执行网络请求的耗时操作,endnetWork执行完毕之后调用,意在模拟一个请求网络资源的操作。于是,我们可以下面一张图来表示,上面代码到底是如何执行的:

下面来解释一下这张图

  1. newWork 被推入Javascript全局执行上下文中的调用栈,然后是 setTimeOut 入栈
  2. 2秒后 setTimeOut运行完毕,立即将绑定其中的 callback() 推入消息队列
  3. 消息队列接受到了 callback() ,于是将 callback() 通过事件循环推入主线程上面执行

Javascript处理异步任务的机制就是这样:当遇到异步任务,就放入 WebAPI的执行上下文中执行,然后继续执行后面的同步任务;WebAPI则会将异步任务所绑定的回调函数放入消息队列;消息队列再将回调函数按顺序推入主线程中执行

注意,消息队列并不是一收到回调函数就立即放入主线程中执行,而是会先检查主线程上是否有别的任务在执行,如果有就等待主线程处理完了再推入回调函数。所谓事件循环机制就是循环地去检查主线程是否空闲,一旦空闲就将回调函数放入其中执行。

联想到我们开头所举的买票的例子,找不到证件的人(异步任务)被窗口售票员(Javascript主线程)安排到一边找自己的证件(WebAPI 执行上下文),当找到了证件就重新回到窗口(事件循环和消息队列)继续办理买票

总结

本文介绍了什么是同步,什么是异步,以及他们之间的区别:

  • 同步就是多个任务排队等待执行,后一个必须等待前一个执行完毕才能开始执行
  • 异步就是多个任务排队等待执行,耗时的任务被放到一边执行,完成后,会将绑定的回调函数推入消息队列,消息队列再通过事件循环的方式将回调函数推入主线程执行

其中:

  • 回调函数就是被别的函数调用的函数,本文中只绑定在异步任务中的函数,等异步任务完成就会调用回调函数
  • Javascript引擎主线程就是浏览器提供的唯一一个执行Javascript代码的线程,而其他的如DOM事件、定时器、网络请求等API,浏览器都提供了单独的线程去处理,不属于主线程的范畴
  • 消息队列存放的是异步任务所绑定的回调函数,它们排队等待被推入主线程执行
  • 事件循环是一种机制,它会循环往复的检查主线程是否空闲,一旦空闲就取出消息队列中的回调函数,放入主线程所在的调用栈中执行

参考资料:

Understanding Asynchronous JavaScript

Javascript异步编程的4种方法

回调函数(callback)是什么?

一篇搞定(Js异步、事件循环与消息队列、微任务与宏任务)