说在前头:事件循环和js语言本身没有关系,和js引擎也没有关系。
先看一个题目:
setTimeout(()=>{ console.log(1); },10) for (let i=0; i<100000000; i++) {} console.log(2); setTimeout(()=>{ console.log(3); },0) Promise.resolve().then(function(){ console.log(4); }) console.log(5);
答案是 2 5 4 1 3 或 2 5 4 3 1。 这取决于这个for循环执行耗时。
先思考一个问题,我们的javascript代码是如何在浏览器或者是Node环境跑起来的。真正执行javascript代码的是谁?
保证我们代码有序执行的都有哪些参与者?
执行javascript代码的是javascript引擎(Engine),比如chrome里是V8,safari里是javascriptCore。要想执行javascript代码只有引擎是不行的,还得有宿主环境,也叫javascript运行时环境(Runtime),比如浏览器或者Node。宿主给引擎一段js代码,引擎才开始执行js代码。
在浏览器里js代码的执行是多个角色参与,看图:
- Call stack (js引擎内)
- Web API (浏览器提供)
- Callback queue (事件队列对应的回调队列)
事件循环
所有的宿主环境都有一个共同点就是都实现了一个叫 Event Loop(事件循环)
的内置机制。它来通过多次调用javascript引擎来调度程序中多个模块的执行顺序。
Event Loop有一个简单的工作机制——就是去监视Call Stack和Callback Queue。 如果调用栈为空,它将从Callback队列中取出第一个事件回调,并将其推送到调用栈,调用栈开始顺序执行代码。
而Callback Queue里的回调是宿主环境在一定的时机推入的。
现在我们看开头那断代码,这里我们先不考虑Promise:
// 标号1 setTimeout(()=>{ console.log(1); },10) //标号5 for (let i=0; i<100000000; i++) {} //标号2 console.log(2); //标号3 setTimeout(()=>{ console.log(3); },0) //标号4 console.log(5);
当宿主环境将这段代码给到js引擎时,引擎创建一个常驻内存的调用栈开始顺序执行代码。
这段代码可能是一段script内的代码也可能是一个模块代码,我们暂且将其用一个名为main函数指代。
最初调用栈的栈里只有一个main。随着代码顺序执行:
- 标号1的setTimeout函数入栈,执行完出栈
- 标号2的console.log函数入栈,执行完出栈
- 标号3的setTimeout函数入栈,执行完出栈
- 标号4console.log函数入栈,执行完出栈
- 此时main也执行完了,出栈,调用栈空了
注:这里说的函数入栈即所得函数上下文入栈。
接下来关键Event Loop该登场了,Event Loop检测到调用栈空了,说明当前迭代已经执行完毕,就会去Callback Queue 取队首的事件回调,并将其推入调用栈,下一轮迭代就开始了。
到这里我们好像忽略了一件事情,Callback Queue 里有东西吗?有的话是什么时候有的呢?
我们再看第一次迭代中的步骤1,setTimeout函数是浏览器提供的api, js引擎告诉浏览器10ms后给我做点事(传入的回调),此时浏览器就开启了一个定时器,并保存下这个回调函数,定时器时间到了后,将这个回调推入Callback Queue。
所以如果说标号5(示例代码中, 下同)的for循环执行耗时,会影响标号1和标号3两个setTimeout的回调执行顺序。
如果在执行标号3的setTimeout时,标号1的setTimeout时间已到,则标号1的回调会先于标号3的回调被推入Callback Queue。所以在第二次迭代时,从Callback Queue队首取到的是
标号1的回调,接下来的执行过程:
- console.log(1) 入栈,执行完出栈
- 调用栈为空,Even 大专栏 事件循环t Loop从Callback Queue取出此时队首的事件回调并推入调用栈
- console.log(3) 入栈,执行完出栈
- 此时调用栈为空,Callback Queue也为空,js引擎就可以先休息会了。
我在mbp的chrome 运行上面代码打印顺序是:
2 5 1 3
我们可以把for循环的次数调小至100,再次查看打印顺序,来验证上面的论证。此时的打印顺序是:
2 5 3 1
宏任务和微任务
在上面例子中的setTimeout是由浏览器这个宿主提供的api,这个异步任务也是有其发起的。
宿主环境发起的任务叫宏任务。
在ES5及更早版本,javascript本身是没有异步执行代码的能力的。宿主环境把一段代码给js引擎,引擎就把这段代码顺次执行了,而这个任务就是宿主发起的任务。
ES6引入了Promise,这使得不用依赖宿主环境,javascript引擎自己也可以发起任务了。
javascript引擎发起的任务叫微任务(对应ES规范里的job)
微任务通常来说就是需要在当前宏任务执行结束后立即执行的任务。所有的微任务会在下一个宏任务执行之前执行完毕。
我们再看开头的带有promise的例子,这里我们简化一下:
// 标号1 setTimeout(()=>{ console.log(3); },0) // 标号2 Promise.resolve().then(function(){ console.log(4); }) // 标号3 console.log(5);
- 标号1的timeout入栈,执行完毕出栈;同时浏览器开一个定时器,立即将该回调函数推入Callback Queue
- 标号2的promise入栈,执行完毕出栈;同时javascript引擎发起了一个微任务,promise的then回调会被推入本轮迭代的队尾。
- 标号3的console.log(5)入栈,执行完毕出栈
- 关键的一步来了,正常是本轮迭代已经执行完毕,栈也为空了,但是本轮迭代还有微任务的回调队列即标号2的promise的then回调,上面我们说了微任务永远会在本轮迭代完成,故先执行console.log(4);
- 此时本轮迭代的调用栈真的为空了,Event Loop从Callback Queue队首去取回调:console.log(3);
故打印顺序:
5 4 3
可以看到一个宏观任务可能会包含一个或多个微观任务,而我们的任务队列可以看做是一个二维数组:第一纬是宏观任务,第二维则是每一个宏观任务包含的微观任务。