1、前言
众所周知Javascript是⼀⻔单线程语⾔,这也就决定了在Javascript中默认情况下同一个时间只能做一件事情,这个特性就造成了js的一些局限性,比如我们的页面需要发送HTTP请求从服务器获取数据时,就会出现等待数据返回之前页面假死的效果。由于js在同一个时间只能做一件事,就导致了页面的渲染和事件的执行在这个过程中无法同时进行。实际上,我们在开发中并没有遇见过这种情况,这是为什么呢?
为什么js是单线程的呢?这主要取决于它的用途,js作为浏览器的脚本语言,主要用途是处理与用户交互和操作DOM,这就决定了它只能是单线程。试想,如果js是多线程,在一个线程上对DOM节点进行修改,另一个线程对该DOM节点进行删除,此时浏览器一脸懵圈,该以哪个进程为准呢?于是为了避免这种情况,js从诞生起就决定了它是一门单线程的语言,这是它的核心特征。
2、同步(阻塞)和异步(非阻塞)
综上所述,在js中应该存在一种解决方案来处理单线程的不足。单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队仅仅是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是空闲的,因为IO设备(输入输出设备)很慢(比如Ajax操作从服务器读取数据),不得不等着结果出来,再往下执行。
JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(阻塞),另一种是异步任务(非阻塞)。
同步(阻塞)
同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务,js会严格按照单线程从上到下、从左到右的方式执行代码逻辑,进行代码的解释和运行,所以不会出现先执行后面的代码在回头执行前面代码的情况。当我们打开网站时,网站的渲染过程,比如元素的渲染,其实就是一个同步任务。
// 同步代码
const a = 1;
const b = 2;function fun1() {console.log(3);
}
function fun2() {console.log(4);
}
console.log(a);
fun1();
console.log(b);
fun2();// 输出
// 1
// 3
// 2
// 4
很容易可以看出,输出会依次输入1,3,2,4,因为代码是从上到下依次执行,先执行console.log(a),再执行fun1(),接着执行console.log(b),最后才执行fun2()。
再看下面的例子:
const x = 1;
const y = 2;
const t1 = new Date().getTime();
let t2 = new Date().getTime();
while(t2 - t1 < 3000){t2 = new Date().getTime();
}
//这段代码在输出结果之前网页会有一段时间的空白
console.log(x+y)
解释:上面的代码按照顺序从上往下执行时,当代码执行到第5行while语句时,就会进入一个持续的循环中。因为t1和t2的时间差很微小仅在毫秒内,所以while语句的t2-t1的值一定比3000小。每循环一次t2就会获取当前的时间戳,值就会发生变化,直到t2-t1>=3000时才会跳出当前的循环,也就是正好过了3秒的时间,最后在输出x+y的结果。那么这段代码实际上至少要执行3秒的时间,while循环不执行完,就不会执行后面的代码,这就导致了程序的阻塞,这也就是为什么将同步也称为阻塞的原因。
阻塞式运行代码,当遇到消耗时间的程序代码时,后面的代码都要等前面的代码执行完毕后才能继续执行,程序的执行顺序与任务的排列顺序是一致的、同步的。
异步(非阻塞)
单线程异步,异步是和同步对立的,异步模式的代码不会按照默认的顺序执行。js仍会严格按照单线程从上到下、从左到右的方式执行代码逻辑,进行代码的解释和运行,在解释代码时,遇到异步任务模式的代码,引擎会将当前的任务加入任务队列中(也叫做挂起),先不执行这段代码,继续向下执行后面的非异步代码。那么什么时候执行这些异步任务呢?直到全部的同步代码执行完毕后,进入任务队列的异步任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程。所以,异步代码不会阻塞同步代码的运行,并且异步代码加入任务队列并不是进入新的线程同时执行,而是等待同步代码执行完毕后再执行。
异步的机制简单的概括为以下四步:
(1)所有同步任务都在主线程上执行,形成一个执行栈。
(2)主线程之外,还存在一个任务队列,只要异步任务有了结果,就会在任务队列中放置一个事件。
(3)一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面还有哪些事件,哪些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断地重复上面的第三步。
function fun1() {console.log(1);
}
function fun2() {console.log(2);
}
function fun3() {console.log(3);
}
fun1();
setTimeout(function(){fun2();
},0);
fun3();// 输出
//1
//3
//2
这段代码运行时,当执行到setTimeout时,并不会直接执行函数内部的回调函数fun2(),而是会先将内部的函数加入任务队列,然后继续执行下面的fun3(),fun3()执行完毕后,任务队列通知主线程执行fun2()。
⾮阻塞式运⾏的代码,程序运⾏到该代码⽚段时,执⾏引擎会将程序保存到⼀个任务队列,等待所有同步代码全部执⾏完毕后,⾮阻塞式的代码会按照特定的执⾏顺序,分步执⾏。这就是单线程异步的特点。
3、总结
JavaScript的运⾏顺序就是完全单线程的异步模型:同步在前,异步在后。所有的异步任务都要等待当前所有的同步任务执⾏完毕之后才能执⾏。
我们把上面的例子改造如下:
const x = 1;
const y = 2;const t1 = new Date().getTime();
let t2 = new Date().getTime();setTimeout(() => {console.log('我是一个异步任务')
}, 1000)while(t2 - t1 < 3000){t2 = new Date().getTime();
}
console.log(x+y)// 输出
// 3
// 我是一个异步任务
运行这段代码可以看出,虽然我们给setTimeout的时间是1000毫秒,但是在while的阻塞3000毫秒的循环之后并没有等待1秒钟而是直接输出了结果“我是一个异步任务”,因为setTimout的时间计算是从setTimeout()这个函数执⾏时开始计算的。