函数调用栈与任务队列
Javascript有一个main thread 主进程和call-stack(一个调用堆栈),在对一个调用堆栈中的task处理的时候,其他的都要等着。当在执行过程中遇到一些类似于setTimeout等异步操作的时候,会交给浏览器的其他模块(以webkit为例,是webcore模块)进行处理,当到达setTimeout指定的延时执行的时间之后,task(回调函数)会放入到任务队列之中。一般不同的异步任务的回调函数会放入不同的任务队列之中。等到调用栈中所有task执行完毕之后,接着去执行任务队列之中的task(回调函数)。
用Philip Roberts的演讲《Help, I’m stuck in an event-loop》之中的一张图表示就是
在上图中,调用栈中遇到DOM操作、ajax请求以及setTimeout等WebAPIs的时候就会交给浏览器内核的其他模块进行处理,webkit内核在Javasctipt执行引擎之外,有一个重要的模块是webcore模块。对于图中WebAPIs提到的三种API,webcore分别提供了DOM Binding、network、timer模块来处理底层实现。等到这些模块处理完这些操作的时候将回调函数放入任务队列中,之后等栈中的task执行完之后再去执行任务队列之中的回调函数。
简单说:JS把代码分为两种,就是遇到无关紧要的初始化代码,会先进入回调栈执行;一些涉及特殊webApi的时候,则先进入回调队列等待,等候入栈回调操作。
从setTimeout看事件循环机制
下面用Philip Roberts的演讲中的一个栗子来说明事件循环机制究竟是怎么执行setTimeout的。
首先main()函数的执行上下文入栈(对执行上下文还不了解的可以看我的上一篇博客)。
代码接着执行,遇到console.log(‘Hi’),此时log(‘Hi’)入栈,console.log方法只是一个webkit内核支持的普通的方法,所以log(‘Hi’)方法立即被执行。此时输出’Hi’。
当遇到setTimeout的时候,执行引擎将其添加到栈中。
调用栈发现setTimeout是之前提到的WebAPIs中的API,因此将其出栈之后将延时执行的函数交给浏览器的timer模块进行处理。
timer模块去处理延时执行的函数,此时执行引擎接着执行将log(‘SJS’)添加到栈中,此时输出’SJS’。
当timer模块中延时方法规定的时间到了之后就将其放入到任务队列之中,此时调用栈中的task已经全部执行完毕。
调用栈中的task执行完毕之后,执行引擎会接着看执行任务队列中是否有需要执行的回调函数。这里的cb函数被执行引擎添加到调用栈中,接着执行里面的代码,输出’there’。等到执行结束之后再出栈。
小结
上面的这一个流程解释了当浏览器遇到setTimeout之后究竟是怎么执行的,相类似的还有前面图中提到的另外的API以及另外一些异步的操作。
总结上文说的,主要就是以下几点:
- 所有的代码都要通过函数调用栈中调用执行。
- 当遇到前文中提到的APIs的时候,会交给浏览器内核的其他模块进行处理。
- 任务队列中存放的是回调函数。
- 等到调用栈中的task执行完之后再回去执行任务队列之中的task。
测试
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
}
console.log(new Date, i);
这段代码是我从网上前不久的一篇文章80%应聘者都不及格的 JS 面试题中找到的,现在我们就分析一下这段代码究竟是怎么输出最后文章中所说的最后的执行状态:
40% 的人会描述为:5 -> 5,5,5,5,5,即第 1 个 5 直接输出,1 秒之后,输出 5 个 5;
-
首先i=0时,满足条件,执行栈执行循环体里面的代码,发现是setTimeout,将其出栈之后把延时执行的函数交给Timer模块进行处理。
-
当i=1,2,3,4时,均满足条件,情况和i=0时相同,因此timer模块里面有5个相同的延时执行的函数。
-
当i=5的时候,不满足条件,因此for循环结束,console.log(new Date, i)入栈,此时的i已经变成了5。因此输出5。
-
此时1s已经过去,timer模块将5个回调函数按照注册的顺序返回给任务队列。
-
执行引擎去执行任务队列中的函数,5个function依次入栈执行之后再出栈,此时的i已经变成了5。因此几乎同时输出5个5。
-
因此等待的1s的时间其实只有输出第一个5之后需要等待1s,这1s的时间是timer模块需要等到的规定的1s时间之后才将回调函数交给任务队列。等执行栈执行完毕之后再去执行任务对列中的5个回调函数。这期间是不需要等待1s的。因此输出的状态就是:5 -> 5,5,5,5,5,即第1个 5 直接输出,1s之后,输出 5个5;
问题
看到这里,对事件循环机制有了一个大概的了解了,可是细想,其中还有一些另外值得深入的问题。
下面通过一个栗子来说明:
(function test() {
setTimeout(function() {console.log(4)}, 0);
new Promise(function executor(resolve) {
console.log(1);
for( var i=0 ; i<10000 ; i++ ) {
i == 9999 && resolve();
}
console.log(2);
}).then(function() {
console.log(5);
});
console.log(3);
})()
在这段代码里面,多了一个promise,那么我们可以思考下面这个问题:
-
promise的task会放在不同的任务队列里面,那么setTimeout的任务队列和promise的任务队列的执行顺序又是怎么的呢?
-
到这里大家看了我说了这么多的task,那么上文中一直提到的task究竟包括了什么?具体是怎么分的?
如果到这里大家还是没太懂的话,那么接下来我会接着深入再细说不同task的事件循环机制。
当然,以上都是我自己鄙陋的见解,欢迎大家批评指正。
下篇:
那我们先从这段代码开始看哇
(function test() {
setTimeout(function() {console.log(4)}, 0);
new Promise(function executor(resolve) {
console.log(1);
for( var i=0 ; i<10000 ; i++ ) {
i == 9999 && resolve();
}
console.log(2);
}).then(function() {
console.log(5);
});
console.log(3);
})()
在这段代码里面,setTimeout和Promise都被称之为任务源,来自不同任务源的回调函数会被放进不同的任务队列里面。
setTimeout的回调函数被放进setTimeout的任务队列之中。而对于Promise,它的回调函数并不是传进去的executer函数,而是其异步执行的then方法里面的参数,被放进Promise的任务队列之中。也就是说Promise的第一个参数并不会被放进Promise的任务队列之中,而会在当前队列就执行。
其中setTimeout和Promise的任务队列叫做macro-task(宏任务),当然如我们所想,还有micro-task(微任务)。
-
macro-task包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。
-
micro-task包括:process.nextTick, Promises, Object.observe, MutationObserver
其中上面的setImmediate和process.nextTick是Node.JS里面的API,浏览器里面并没有,这里就当举例,不必纠结具体是怎么实现的。
事件循环的顺序是从script开始第一次循环,随后全局上下文进入函数调用栈,碰到macro-task就将其交给处理它的模块处理完之后将回调函数放进macro-task的队列之中,碰到micro-task也是将其回调函数放进micro-task的队列之中。直到函数调用栈清空只剩全局执行上下文,然后开始执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次执行macro-task中的一个任务队列,执行完之后再执行所有的micro-task,就这样一直循环。
分析执行过程
下面分析的思路按照波同学之前所写的深入核心,详解事件循环机制中的思路进行分析。
以之前的栗子作为分析的对象,来分析事件循环机制究竟是怎么执行代码的
(function test() {
setTimeout(function() {console.log(4)}, 0);
new Promise(function executor(resolve) {
console.log(1);
for( var i=0 ; i<10000 ; i++ ) {
i == 9999 && resolve();
}
console.log(2);
}).then(function() {
console.log(5);
});
console.log(3);
})()
注意下面所有图中的setTimeout任务队和最后的函数调用栈中存放的都是setTimeout的回调函数,并不是整个setTimeout定时器。

1.首先,script任务源先执行,全局上下文入栈。





5.接着执行,代码遇到then方法,其回调函数作为micro-task入栈,进入Promise的任务队列之中。




总结
总的来说就是:
-
不同的任务会放进不同的任务队列之中。
-
先执行macro-task,等到函数调用栈清空之后再执行所有在队列之中的micro-task。
-
等到所有micro-task执行完之后再从macro-task中的一个任务队列开始执行,就这样一直循环。
-
当有多个macro-task(micro-task)队列时,事件循环的顺序是按上文macro-task(micro-task)的分类中书写的顺序执行的。
测试
说到这里,我们应该都明白了,下面是一个复杂的代码段(改自深入核心,详解事件循环机制),里面有混杂着的micro-task和macro-task,自己画图试试流程哇,然后再用node执行看看输出的顺序是否一致。
console.log('golb1');
setImmediate(function() {
console.log('immediate1');
process.nextTick(function() {
console.log('immediate1_nextTick');
})
new Promise(function(resolve) {
console.log('immediate1_promise');
resolve();
}).then(function() {
console.log('immediate1_then')
})
})
setTimeout(function() {
console.log('timeout1');
process.nextTick(function() {
console.log('timeout1_nextTick');
})
new Promise(function(resolve) {
console.log('timeout1_promise');
resolve();
}).then(function() {
console.log('timeout1_then')
})
setTimeout(function() {
console.log('timeout1_timeout1');
process.nextTick(function() {
console.log('timeout1_timeout1_nextTick');
})
setImmediate(function() {
console.log('timeout1_setImmediate1');
})
});
})
new Promise(function(resolve) {
console.log('glob1_promise');
resolve();
}).then(function() {
console.log('glob1_then')
})
process.nextTick(function() {
console.log('glob1_nextTick');
})