JavaScript 系列八: 同步与异步

6,266 阅读15分钟

前言

在开始学习之前,我们想要告诉您的是,本文章是对本文章是对JavaScript语言知识中异步操作部分的总结,如果您已掌握下面知识事项,则可跳过此环节直接进入题目练习

  • 单线程
  • 同步概念
  • 异步概念
  • 异步操作的模式
  • 异步操作的流程控制
  • 定时器的创建和清除

如果您对某些部分有些遗忘,👇🏻 已经为您准备好了!

汇总总结

单线程

单线程指的是,JavaScript 只在一个线程上运行。也就是说,JavaScript 同时只能执行一个任务,其他任务都必须在后面排队等待。

JavaScript 之所以采用单线程,而不是多线程,跟历史有关系。JavaScript 从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。

单线程的好处

  • 实现起来比较简单
  • 执行环境相对单纯

单线程的坏处

  • 坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行

如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 操作(输入输出)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript 语言的设计者意识到,这时 CPU 完全可以不管 IO 操作,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 操作返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是 JavaScript 内部采用的 “事件循环”机制Event Loop)。

单线程虽然对 JavaScript 构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果用得好,JavaScript 程序是不会出现堵塞的,这就是为什么 Node 可以用很少的资源,应付大流量访问的原因。

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

同步

同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。

同步操作的例子可以是执行一次简单的数学计算:

let xhs = 3

xhs = xhs + 4

在程序执行的每一步,都可以推断出程序的状态。这是因为后面的指令总是在前面的指令完成后才会执行。等到最后一条指定执行完毕,存储在 xhs 的值就立即可以使用。

首先,操作系统会在栈内存上分配一个存储浮点数值的空间,然后针对这个值做一次数学计算,再把计算结果写回之前分配的内存中。所有这些指令都是在单个线程中按顺序执行的。在低级指令的层面,有充足的工具可以确定系统状态。

异步

异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。

异步操作的例子可以是在定时回调中执行一次简单的数学计算:

let xhs = 3

setTimeout(() => (xhs = xhs + 4), 1000)

这段程序最终与同步代码执行的任务一样,都是把两个数加在一起,但这一次执行线程不知道 xhs 值何时会改变,因为这取决于回调何时从消息队列出列并执行。

异步代码不容易推断。虽然这个例子对应的低级代码最终跟前面的例子没什么区别,但第二个指令块(加操作及赋值操作)是由系统计时器触发的,这会生成一个入队执行的中断。到底什么时候会触发这个中断,这对 JavaScript 运行时来说是一个黑盒,因此实际上无法预知(尽管可以保证这发生在当前线程的同步代码执行之后,否则回调都没有机会出列被执行)。无论如何,在排定回调以后基本没办法知道系统状态何时变化。

为了让后续代码能够使用 xhs ,异步执行的函数需要在更新 xhs 的值以后通知其他代码。如果程序不需要这个值,那么就只管继续执行,不必等待这个结果了。

任务队列和事件循环

JavaScript 运行时,除了一个正在运行的主线程,引擎还提供一个任务队列(task queue),里面是各种需要当前程序处理的异步任务。(实际上,根据异步任务的类型,存在多个任务队列。为了方便理解,这里假设只存在一个队列。)

首先,主线程会去执行所有的同步任务。等到同步任务全部执行完,就会去看任务队列里面的异步任务。如果满足条件,那么异步任务就重新进入主线程开始执行,这时它就变成同步任务了。等到执行完,下一个异步任务再进入主线程开始执行。一旦任务队列清空,程序就结束执行。

异步任务的写法通常是回调函数。一旦异步任务重新进入主线程,就会执行对应的回调函数。如果一个异步任务没有回调函数,就不会进入任务队列,也就是说,不会重新进入主线程,因为没有用回调函数指定下一步的操作。

JavaScript 引擎怎么知道异步任务有没有结果,能不能进入主线程呢?答案就是引擎在不停地检查,一遍又一遍,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入主线程了。这种循环检查的机制,就叫做事件循环(Event Loop)。

维基百科的定义是:“事件循环是一个程序结构,用于等待和发送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”。

异步操作的模式

回调函数

回调函数是异步操作最基本的方法。

下面是两个函数 f1f2 ,编程的意图是 f2 必须等到 f1 执行完成,才能执行。

function f1() {
  // ...
}

function f2() {
  // ...
}

f1()
f2()

上面代码的问题在于,如果 f1 是异步操作,f2 会立即执行,不会等到 f1 结束再执行。

这时,可以考虑改写 f1 ,把 f2 写成 f1 的回调函数。

function f1(callback) {
  // ...
  callback()
}

function f2() {
  // ...
}

f1(f2)

回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合coupling),使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。

异步操作的流程控制

如果有多个异步操作,就存在一个流程控制的问题:如何确定异步操作执行的顺序,以及如何保证遵守这种顺序。

function async(arg, callback) {
  console.log('参数为 ' + arg + ' , 1秒后返回结果')
  setTimeout(function () {
    callback(arg * 2)
  }, 1000)
}

上面代码的 async 函数是一个异步任务,非常耗时,每次执行需要 1 秒才能完成,然后再调用回调函数。

如果有六个这样的异步任务,需要全部完成后,才能执行最后的 final 函数。请问应该如何安排操作流程?

function final(value) {
  console.log('完成: ', value)
}

async(1, function (value) {
  async(2, function (value) {
    async(3, function (value) {
      async(4, function (value) {
        async(5, function (value) {
          async(6, final)
        })
      })
    })
  })
})
// 参数为 1 , 1秒后返回结果
// 参数为 2 , 1秒后返回结果
// 参数为 3 , 1秒后返回结果
// 参数为 4 , 1秒后返回结果
// 参数为 5 , 1秒后返回结果
// 参数为 6 , 1秒后返回结果
// 完成:  12

上面代码中,六个回调函数的嵌套,不仅写起来麻烦,容易出错,而且难以维护。

串行执行

我们可以编写一个流程控制函数,让它来控制异步任务,一个任务完成以后,再执行另一个。这就叫串行执行。

var items = [1, 2, 3, 4, 5, 6]
var results = []

function async(arg, callback) {
  console.log('参数为 ' + arg + ' , 1秒后返回结果')
  setTimeout(function () {
    callback(arg * 2)
  }, 1000)
}

function final(value) {
  console.log('完成: ', value)
}

function series(item) {
  if (item) {
    async(item, function (result) {
      results.push(result)
      return series(items.shift())
    })
  } else {
    return final(results[results.length - 1])
  }
}

series(items.shift())

上面代码中,函数 series 就是串行函数,它会依次执行异步任务,所有任务都完成后,才会执行 final 函数。items 数组保存每一个异步任务的参数,results 数组保存每一个异步任务的运行结果。

注意,上面的写法需要六秒,才能完成整个脚本。

并行执行

流程控制函数也可以是并行执行,即所有异步任务同时执行,等到全部完成以后,才执行final函数。

var items = [1, 2, 3, 4, 5, 6]
var results = []

function async(arg, callback) {
  console.log('参数为 ' + arg + ' , 1秒后返回结果')
  setTimeout(function () {
    callback(arg * 2)
  }, 1000)
}

function final(value) {
  console.log('完成: ', value)
}

items.forEach(function (item) {
  async(item, function (result) {
    results.push(result)
    if (results.length === items.length) {
      final(results[results.length - 1])
    }
  })
})

上面代码中,forEach 方法会同时发起六个异步任务,等到它们全部完成以后,才会执行 final 函数。

相比而言,上面的写法只要一秒,就能完成整个脚本。这就是说,并行执行的效率较高,比起串行执行一次只能执行一个任务,较为节约时间。但是问题在于如果并行的任务较多,很容易耗尽系统资源,拖慢运行速度。因此有了第三种流程控制方式。

并行与串行的结合

所谓并行与串行的结合,就是设置一个门槛,每次最多只能并行执行 n 个异步任务,这样就避免了过分占用系统资源。

var items = [1, 2, 3, 4, 5, 6]
var results = []
var running = 0
var limit = 2

function async(arg, callback) {
  console.log('参数为 ' + arg + ' , 1秒后返回结果')
  setTimeout(function () {
    callback(arg * 2)
  }, 1000)
}

function final(value) {
  console.log('完成: ', value)
}

function launcher() {
  while (running < limit && items.length > 0) {
    var item = items.shift()
    async(item, function (result) {
      results.push(result)
      running--
      if (items.length > 0) {
        launcher()
      } else if (running == 0) {
        final(results)
      }
    })
    running++
  }
}

launcher()

上面代码中,最多只能同时运行两个异步任务。变量 running 记录当前正在运行的任务数,只要低于门槛值,就再启动一个新的任务,如果等于0,就表示所有任务都执行完了,这时就执行 final 函数。

这段代码需要三秒完成整个脚本,处在串行执行和并行执行之间。通过调节 limit 变量,达到效率和资源的最佳平衡。

定时器的创建和清除

JavaScript 在浏览器中是单线程执行的,但允许使用定时器指定在某个时间之后或每隔一段时间就执行相应的代码。setTimeout() 用于指定在一定时间后执行某些代码,而 setInterval() 用于指定每隔一段时间执行某些代码。

setTimeout() 方法通常接收两个参数:要执行的代码和在执行回调函数前等待的时间(毫秒)。第一个参数可以是包含 JavaScript 代码的字符串(类似于传给 eval() 的字符串)或者一个函数。

// 在 1 秒后显示警告框

setTimeout(() => alert('Hello XHS-Rookies!'), 1000)

第二个参数是要等待的毫秒数,而不是要执行代码的确切时间。 JavaScript 是单线程的,所以每次只能执行一段代码。为了调度不同代码的执行, JavaScript 维护了一个任务队列。其中的任务会按照添加到队列的先后顺序执行。 setTimeout() 的第二个参数只是告诉 JavaScript 引擎在指定的毫秒数过后把任务添加到这个队列。如果队列是空的,则会立即执行该代码。如果队列不是空的,则代码必须等待前面的任务执行完才能执行。

调用 setTimeout() 时,会返回一个表示该超时排期的数值 ID。这个超时 ID 是被排期执行代码的唯一标识符,可用于取消该任务。要取消等待中的排期任务,可以调用 clearTimeout() 方法并传入超时 ID,如下面的例子所示:

// 设置超时任务
let timeoutId = setTimeout(() => alert('Hello XHS-Rookies!'), 1000)

// 取消超时任务
clearTimeout(timeoutId)

只要是在指定时间到达之前调用 clearTimeout() ,就可以取消超时任务。在任务执行后再调用 clearTimeout() 没有效果。

注意 所有超时执行的代码(函数)都会在全局作用域中的一个匿名函数中运行,因此函数中的 this 值在非严格模式下始终指向 window,而在严格模式下是 undefined。如果给 setTimeout() 提供了一个箭头函数,那么 this 会保留为定义它时所在的词汇作用域。

setInterval()setTimeout() 的使用方法类似,只不过指定的任务会每隔指定时间就执行一次,直到取消循环定时或者页面卸载。setInterval() 同样可以接收两个参数:要执行的代码(字符串或函数),以及把下一次执行定时代码的任务添加到队列要等待的时间(毫秒)。下面是一个例子:

setInterval(() => alert('Hello XHS-Rookies!'), 10000)

注意 这里的关键点是,第二个参数,也就是间隔时间,指的是向队列添加新任务之前等待的时间。比如,调用 setInterval() 的时间为 01:00:00,间隔时间为 3000 毫秒。这意味着 01:00:03 时,浏览器会把任务添加到执行队列。浏览器不关心这个任务什么时候执行或者执行要花多长时间。因此,到了 01:00:06,它会再向队列中添加一个任务。由此可看出,执行时间短、非阻塞的回调函数比较适合 setInterval()

setInterval() 方法也会返回一个循环定时 ID,可以用于在未来某个时间点上取消循环定时。要取消循环定时,可以调用 clearInterval() 并传入定时 ID。相对于 setTimeout() 而言,取消定时的能力对 setInterval() 更加重要。毕竟,如果一直不管它,那么定时任务会一直执行到页面卸载。下面是一个常见的例子:

let xhsNum = 0,
  intervalId = null
let xhsMax = 10

let xhsIncrementNumber = function () {
  xhsNum++
  // 如果达到最大值,则取消所有未执行的任务
  if (xhsNum == xhsMax) {
    clearInterval(xhsIntervalId) // 清除定时器
    alert('Done')
  }
}
xhsIntervalId = setInterval(xhsIncrementNumber, 500)

在这个例子中,变量 num 会每半秒递增一次,直至达到最大限制值。此时循环定时会被取消。这个模式也可以使用 setTimeout() 来实现,比如:

let xhsNum = 0
let xhsMax = 10

let xhsIncrementNumber = function () {
  xhsNum++
  // 如果还没有达到最大值,再设置一个超时任务
  if (xhsNum < xhsMax) {
    setTimeout(xhsIncrementNumber, 500)
  } else {
    alert('Done')
  }
}
setTimeout(xhsIncrementNumber, 500)

注意在使用 setTimeout() 时,不一定要记录超时 ID,因为它会在条件满足时自动停止,否则会自动设置另一个超时任务。这个模式是设置循环任务的推荐做法。 setIntervale() 在实践中很少会在生产环境下使用,因为一个任务结束和下一个任务开始之间的时间间隔是无法保证的,有些循环定时任务可能会因此而被跳过。而像前面这个例子中一样使用 setTimeout() 则能确保不会出现这种情况。一般来说,最好不要使用 setInterval()

题目自测

一、以下代码输出是什么?

console.log('first')
setTimeOut(() => {
  console.log('second')
}, 1000)
console.log('third')
Answer
// first
// third
// second

setTimeOut 执行时使里面的内容进入异步队列。所以会先执行下面的 third 输出之后,才输出 setTimeOut 中的内容。

二、制作一个 60s 计时器。

Answer
function XhsTimer() {
  var xhsTime = 60 // 设置倒计时时间 60s
  const xhsTimer = setInterval(() => {
    // 创建定时器
    if (xhsTime > 0) {
      // 大于 0 时,一次次减
      xhsTime--
      console.log(xhsTime) // 输出每一秒
    } else {
      clearInterval(xhsTimer) // 清除定时器
      xhsTime = 60 // 重新设置倒计时时间 60s
    }
  }, 1000) // 1000 为设置的时间,1000毫秒 也就是一秒
}
XhsTimer()

JavaScript 系列的同步与异步,我们到这里结束啦,谢谢各位对作者的支持!你们的关注和点赞,将会是我们前进的最强动力!谢谢大家!