单线程JavaScript
最近在阅读《你不知道的JavaScript中卷》,当我看到第二部分介绍异步和回调函数的一些知识时,由于该书在第二部分1、2章对线程、事件循环的概念介绍的并非详细,因此引发了我的一系列思考。于是写下这篇小文章,记录自己对该知识点的学习和思考。
javascript单线程
由于JavaScript是单线程语言,因此,在一个进程上,只能运行一个线程,而不能多个线程同时运行。也就是说JavaScript不允许多个线程共享内存空间。因此,如果有多个线程想同时运行,则需采取排队的方式,即只有当前一个任务执行完毕,后一个任务才开始执行。
任务队列
在JavaScript中,所有任务可以分为两种,一种是同步任务,一种是异步任务。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,后一个任务才会执行;异步任务指的是不进入主线程、而进入任务队列的任务,只有当主线程上的所有同步任务执行完毕之后,主线程才会读取任务队列,开始执行异步任务。
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务不得不一直等待。那么这样的JavaScript的执行不是很慢吗?特别是对于长时间任务执行的时候,那么其他的任务就得不到执行。考虑这种原因,JS中将这些耗时的I/O等操作封装成了异步的方法,也就是通过回调函数的方式。等到同步任务执行完毕,主线程才会读取任务队列上的异步任务(回调函数的方式)。
任务队列是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。
“任务队列”中的事件,除了IO设备(ajax获取服务器数据)的事件以外,还包括一些用户产生的事件(mouseover、click、scroll、keyup等)和定时器等。只要在事件中指定了事件处理程序(回调函数),这些事件发生时就会进入“任务队列”,等待主线程读取。而主线程读取任务队列中的异步任务,主要就是读取回调函数。
当主线程的所有同步任务执行(排队执行)完毕之后,就会读取任务队列中的异步任务,将异步任务推入执行栈中执行。任务队列是一个先进先出的数据结构,即排在前面的事件,优先被主线程读取。如果存在定时器,时间越短的越先进入执行栈。
因此,可以做一个简单的总结:
- JS将任务分为两种,同步任务和异步任务。
- 当主线程开始执行同步任务时,会创建一个“执行栈”,每一个同步任务排队执行,只有前一个任务执行完毕,才会执行下一个任务。
- 当主线程上的所有同步任务执行完毕之后,也就是当“执行栈”为空时,主线程会去读取任务队列上的异步任务(回调函数),并将异步任务推入执行栈中开始执行。
- 主线程不断重复第二、第三个步骤。
Event Loop(事件循环)
主线程中的所有同步任务执行完毕,再读取任务队列中的异步任务,这个过程是循环不断的。所以,整个的这种运行机制称为Event Loop(事件循环)。
在异步任务中,又可以分为两种,macrotask(宏任务)和micro(微任务)。在挂起任务时,JS 引擎会将所有任务按照类别分到这两个队列中,在当前执行栈为空的时候,主线程会查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈...如此反复,进入循环。
我们只需记住当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。
两个类别的具体分类如下:
- macrotask: script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering
- microtask: process.nextTick, Promise, MutationObserver
上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种webAPIs,它们在任务队列中加入各种事件(click,load,keyup等)。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。
对于setTimeout定时器而言,setTimeout会在指定时间内任务队列添加一个回调函数,如果任务队列中没有其他任务,则这条任务会立即执行;否则,这个任务会不得不等到其他异步任务执行完毕之后,才会得到执行。因此,setTimeout指定的执行时间,只是一个最早可能发生的时间,并不能保证一定会在那个时间发生。
setTimeout
明白了主线程执行相关任务的思路后,来看看定时器。上面介绍到,定时器是属于任务队列中的异步任务。因此会等待“执行栈”上的所有同步任务执行完毕之后,主线程计算定时器的执行时间,再将事件推入“执行栈”。看一个简单的例子。
function foo() {
setTimeout(function() {
console.log(1);
}, 0)
console.log(2);
}
function bar() {
setTimeout(function() {
console.log(3);
}, 0);
console.log(4);
}
foo();
bar();
这段函数的输出结果为2, 4, 1, 3。做一个简单的分析。
foo、bar函数的内部有相同的结构,都有一个定时器和console.log()函数。当foo、bar函数调用时,会形成一个“执行栈”,主线程会先执行“执行栈”中的同步任务,即console.log(2), console.log(4)
,而两个定时器会被推入任务队列中,等待执行。当主线程上的同步任务执行完毕之后,结束定时器的等待,将任务队列中的两个异步任务推入“执行栈”中执行,因此输出的顺序为2, 4, 1, 3。
定时器的第一个参数是一个函数,第二个参数是推迟执行的毫秒数。从函数的定义上看,如果将时间设定为0,此时应该是立即执行定时器才对,为什么输出顺序会不同呢?
需要注意的是,setTimeout()只是将回调函数插入到“任务队列”中,因此必须等到主线程上的同步任务全部执行完毕,并且任务队列中不存在其他异步任务时,才会开始执行。setTimeout的第二个参数只能确保任务在指定的时间之后执行,而不能保证一定就在该时间之后立即执行,是否能够立即执行,取决于任务队列中是否存在其他异步任务。
看一段代码。
function foo() {
setTimeout(function() {
console.log(1);
}, 2000)
console.log(2);
}
function bar() {
setTimeout(function() {
console.log(3);
}, 1000);
console.log(4);
}
function baz() {
setTimeout(function() {
console.log(5);
}, 0)
console.log(6);
}
foo();
bar();
baz();
//结果: 2, 4, 6, 5, 3, 1;
主线程上的同步任务按照执行栈排队执行,任务队列上的定时器按照时间长短排队执行。时间越短,越早进入“执行栈”,越早被主线程执行。也就是说,先进入任务队列的任务先执行。
如果换一种函数的调用位置
baz();
foo();
bar();
//此时的结果: 6, 2, 4, 5, 3, 1
从上面的两种运行结果可以看出
同步任务取决于函数的调用位置,不同的调用位置,进入执行栈的位置就不同,主线程执行的顺序就不同
异步任务的执行与函数的调用位置无关,只取决于执行栈的任务数量,当同步任务执行完毕之后,才会开始执行异步任务,并且遵循先进入任务队列的事件先执行的原则。