了解JS的同步模式和异步模式

吐槽君 分类:javascript

背景

目前主流的JavaScript环境都是以单线程模式去执行的,JavaScript采用单线程模式工作的原因和最早JavaScript设计的初衷有关,最早JavaScript就是运行在浏览器端的脚本语言,目的是为了实现页面上的动态交互,而实现页面交互的核心是Dom操作,这也就决定了他必须使用单线程的模式,否则就会出现很复杂的线程同步问题。比如我们有多个线程同时操作同一Dom时,其中一个线程修改了Dom元素,另一个线程删除了该Dom元素,这个时候浏览器就无法明确该以哪个线程的工作为准。所以为了避免线程同步的问题,从一开始JavaScript就被设计成了单线程模式,同时这也是JavaScript最为核心的特性之一。

ps: 这里的单线程是指在JS执行环境中负责执行代码的线程只有一个

一个线程在一个时间只能处理一个任务,当有多个任务时就需要排队依次完成。

1.png
上面的图展示了一个线程对多个任务。

这种模式最大的优点就是更安全更简单,缺点也很明显,比如遇到耗时比较大的任务时,后面的任务需要等待上面的任务执行完成才能够执行。

console.log('开始处理任务啦');
for (let i = 1; 1 < 4; i++) {
    console.log('耗时任务!!!');
}
console.log('我什么时候能等到执行..');

// --------控制台输出结果---------

// 开始处理任务啦
// 4 耗时任务!!!(打印四次)
// 我什么时候能等到执行..
 

在上面的代码中,最后一个log需要等待前面的for循环走完之后才能被执行。这就导致我们程序的执行会被拖延,甚至出现假死的现象。

所以为了解决耗时任务阻塞执行的问题,JavaScript将任务的执行模式分成了两种:

  • 同步模式(Synchronous)
  • 异步模式(Asynchronous)

下面就给大家分开详细介绍一下两种不同的模式。

同步模式(Synchronous)

同步模式只得就是我们代码当中的任务依次执行,后一个任务必须等待前一个任务结束才能够开始执行,程序的执行顺序和代码的编写顺序是一致的。在单线程的情况下大多数任务都会以同步模式执行,同步模式总结来说就是排队执行。

console.log('begin');

function bar () {
   console.log('bar');
};

function foo () {
   console.log('foo');
   bar();
};

foo();

console.log('end');

// --------控制台输出结果---------

// begin
// foo
// bar
// end
 

开始执行时 JS执行引擎会把我们的代码全部加载到一个执行栈当中,并在执行栈中压入一个匿名调用,可以把这个匿名调用当做一个匿名函数去执行。上面的代码就是一个纯同步执行的代码。

在这里调用栈只是一个更专业的说法,更通俗一点的解释是JS在执行引擎当中维护了一个正在工作的工作表,或者说正在执行的一个工作表。这里面会记录当前我们正在做的事情。当工作表当中所有的任务被清空过后,这一轮的工作就结束了。

同步模式就像我们上文所描述的一样会遇到阻塞任务的问题。这种阻塞在用户看来就会出现页面加载慢或者卡顿的问题。所以我们就必须要有异步模式去解决我们程序当中无法避免的耗时操作,例如:我们在浏览器端的Ajax操作,或者在Node.js当中的大文件读写。都会需要异步执行,从而避免遇到阻塞的问题。

异步模式(Asynchronous)

不同于同步模式的执行方式,异步模式是不会等待这个任务结束才开始下一个任务,对于耗时操作,异步模式会在开启任务过后就立即往后执行下一个任务,对于耗时操作的后续逻辑一般会通过回调函数的方式定义。耗时任务执行完毕就会自动执行回调函数中的处理。

异步模式就可以解决我们在同步模式中单线程执行任务遇到的任务阻塞的问题,并且可以痛死处理大量的耗时任务,但是同时对于开发者而言单线程下面的异步最大的难点就是,它的代码执行顺序不像同步模式下简单易懂,相对来说比较跳跃。

看一下以下代码的输出顺序

console.log('begin');

setTimeout(() => {
  console.log('time1');
}, 1800);

setTimeout(() => {
  console.log('time2');
  setTimeout(() => {
    console.log('time2 inner');
  }, 800);
}, 1000);

console.log('end');
 

因为有异步调用的过程,执行的过程会相对复杂一些。

开始执行时和同步调用的执行顺序一样会在执行栈中压入一个匿名的全局调用,然后依次执行代码。当遇到 setTimeout 时,也是先将 setTimeout 压入到调用栈,但是这个函数内部是异步调用,内部Api将 setTimeout 中的函数开启了一个倒计时器单独放在一边,这里的倒计时器是单独工作的,并不会受到当前JS工作线程的影响。从开始执行的时候就已经开始倒数了。当整体匿名调用完成之后,调用栈清空后,Event Loop(事件循环)就会发挥作用,Event Loop只做一件事情就是负责监听调用栈消息队列,消息队列也可以叫回调队列,就是用来存放异步后的回调函数的队列,一旦调用栈所有任务都结束了,Event Loop就会从消息队列中取出第一个回调函数压入到调用栈。所以上面的代码输出的结果应该是:

// --------控制台输出结果---------
// begin
// end
// time2
// time1
// time2 inner
 

这里会有一个小的问题,time2 inner 明明是0.8秒后执行为什么会在time1后面打印,其实跟上面的执行顺序是一样的,在time2在1s后执行,time1在1.8s后执行,所以time2先执行的时候time1已经在回调队列中等待执行,而此次time2内部还有一个setTimeout,就会被内部放在一边倒计时,倒计时结束时放入回调队列,此时time1已经在队列中,所以打印出来的顺序就会变成time1在time2 inner的前面。

当调用栈和消息队列都没有需要执行的任务时,整体的代码就结束了。

如果说调用栈是一个正在执行的工作表,那么消息队列就可以理解为一个待办的工作表。JS执行引擎就是先去做完调用栈当中所有的任务再通过事件循环,也就是Event Loop从消息队列当中再取一个任务出来继续执行,以此类推。整个过程中我们随时都可以向消息队列中再放入一些任务,这些任务在消息队列中会排队等待。

2.png

为了方便大家理解画了一个脑图,以上就是异步调用在JavaScript当中的实现过程和原理。

主要注意的是JavaScript是单线程的,而浏览器不是单线程的,更具体来说就是通过JavaScript调用的某些内部Api并不是单线程的,比如上面代码中使用的setTimeout,它内部有一个单独的线程负责倒数,当倒数完成后会将回调放到消息队列中去。我们所说的单线程是只负责我们代码执行的线程是单线程。也就是这些内部的Api内部会用单独的线程去执行等待的操作。

除此之外,这里我们所说的同步、异步不是指写代码的方式,而是说运行环境提供的API是以同步或异步模式的方式工作的。同步模式的API特点就是任务执行完代码才会继续往下走。例如console.log。异步模式的API就是下达任务开启的指令就会继续往下执行,代码不会等待这行执行结束的,例如setTimeout。

回复

我来回复
  • 暂无回复内容