一个后端眼中的 JS 事件循环
JS 是单线程的。
JS 是一门解释型脚本语言,主要用于处理浏览器与用户的交互。
个人认为设计为单线程的考量在于:
1、绝大多数交互相关的任务都是 CPU 密集型的短任务,任务耗时短、CPU 占用率高。若使用多线程,线程调度开销占总开销的百分比过大,收益不高。
2、要频繁的处理 DOM 元素。在所有层级的 DOM 元素都可能是各线程间的共享变量时,线程间的同步互斥关系会相当繁杂,需要较多的额外空间维护这些关系,开销与增益不成正比。
即使如此,在使用 JS 时,也有一些特殊情况是需要使用多线程来进行处理的。比如定时器的实现,如果在主线程中进行计时,将会阻碍所有其它正常任务的执行;再比如发送网络请求,如果在主线程阻塞等待返回值的到来,也会阻碍其它正常任务的执行。
事件循环就是 JS 中和这两个矛盾的方式,既要单线程处理主要任务,又要有机制支持 IO 密集型任务的异步化。开发者只负责提交任务,不负责管理线程的调度。由 JS 宿主环境管理任务的执行方式以及线程的调度。
JS 有一个 main thread 主进程和 call-stack(回调任务栈),在对一个调用堆栈中的task处理的时候,其他的都要等着。当在执行过程中遇到一些类似于setTimeout等异步操作的时候,会交给浏览器的其他模块(或者说浏览器管理的其它线程)进行处理,当到达setTimeout指定的延时执行的时间之后,task(回调函数)会放入到任务队列之中。一般不同的异步任务的回调函数会放入不同的任务队列之中。等到调用栈中所有task(同步任务)执行完毕之后,接着去执行任务队列之中的task(异步任务的回调函数)。
在上图中,调用栈中遇到DOM操作、ajax请求以及setTimeout等WebAPIs的时候就会交给浏览器内核的其他模块进行处理,webkit内核在Javasctipt执行引擎之外,有一个重要的模块是webcore模块。对于图中WebAPIs提到的三种API,webcore分别提供了DOM Binding、network、timer模块来处理底层实现。等到这些模块处理完这些操作的时候将回调函数放入任务队列中,之后等栈中的task执行完之后再去执行任务队列之中的回调函数。
主任务线程就是一个周而复始的大循环,先去执行完主任务栈中的任务,再执行完回调任务栈中的任务,周而复始。如果用 JAVA 代码表述这个机制,简单表示一下主线程的运作方式:
public class MyJsMain { //主任务栈 private Stack<Runnable> mainStack = new Stack<Runnable>(); //回调任务栈 private Stack<Runnable> callBackStack = new Stack<Runnable>(); //回调任务栈是主线程与其它线程共享的,操作回调任务栈的锁 private Object callBackLock = new Object(); //主任务栈锁 private Object mainLock = new Object(); //解释器线程向主任务栈压任务 public void pushMainTask(Runnable run) { if(run == null){return;} synchronized (mainLock) { mainStack.push(run); } } //主线程从主任务栈取任务 private Runnable getMainTask(){ synchronized (mainLock){ if(!mainStack.empty()){ return mainStack.pop(); } } return null; } //是否有主任务 private boolean haveMainTask(){ synchronized (mainLock){ return !mainStack.empty(); } } //暴露给其它线程,如定时器或者进行 ajax 请求的线程,执行完后将回调函数及执行结果压入回调栈 public void pushCallBackTask(Runnable run) { if(run == null){return;} synchronized (callBackLock) { callBackStack.push(run); } } //主线程获取回调任务 private Runnable getCallBackTask() { synchronized (callBackLock) { if (!callBackStack.empty()) { return callBackStack.pop(); } } return null; } //是否有回调任务 private boolean haveCallBackTask(){ synchronized (callBackLock){ return !callBackStack.empty(); } } //主线程 public static void main(String args) { MyJsMain myJsMain = new MyJsMain(); while (!Thread.currentThread().isInterrupted()) { // 执行主线程中的任务 while (myJsMain.haveMainTask()) { Runnable run = myJsMain.mainStack.pop(); if(run!=null){ run.run(); } } //执行回调线程中的任务 while (myJsMain.haveCallBackTask()) { Runnable run = myJsMain.getCallBackTask(); if(run!=null){ run.run(); } } } }
MyJsMain 对象全局持有,浏览器负责向主任务栈与回调任务栈中压入任务,主线程只管不断的循环执行这些任务。
这样便实现了主线程内,需要多线程执行的任务的异步化。并且异步函数都是 JS 宿主环境提供的,开发者不能随意的异步化任务,保证了操作 DOM 元素等行为的安全。
浏览器维护一个叫 EventLoop 的循环(主线程外的其它线程)执行这些异步任务,并在执行完后将回调函数压入回调任务栈。
console.log() 这类普通的同步函数从主任务栈拿出后会直接被执行。
而 setTimeOut() 这类异步函数,比如:
setTimeOut(function(){console.log('111');},500)
主线程拿到这个任务会直接将任务扔给浏览器对应的模块,在其它线程执行计时 500 ms 的任务。计时完成后,console.log('111') 的任务由浏览器其它线程压入回调任务队列。
主线程处理完所有同步任务,去回调任务队列拿出该任务进行处理。可以看出,计时函数的计时并不完全精确,因为计时完成只是将回调函数压入了回调任务栈,至于什么时候可以被执行,还要看主线程中待处理同步任务的数量以及回调任务栈中排在该任务之前的任务数。
为了更好的交互体验,JS 标准对不同的任务进行了分类,不同类型的任务放在不同的任务栈中。不同类型的任务具有不同的优先级,在最新的标准中,任务被分为宏任务(MacroTask ,HTML 规范中的叫法)与微任务(MicroTask ,Promise/A+规范中的叫法)。
宿主环境提供的异步任务服务为宏任务(比如 Ajax 请求或定时器),语言标准提供的异步任务服务为微任务(比如ES6提供的promise)。
常见的宏任务有:同步任务、setInterval、setTimeout、setImmediate(node.js)、XHR 回调、事件回调(鼠标键盘事件)、indexedDB 数据库等 I/O 操作、UI rendering。
常见的微任务有:Promise.then catch finally、process.nextTick(node.js)、MutationObserver、Object.observe(已被弃用)。
不同的浏览器或其它宿主环境对不同类型异步任务的执行顺序也不同,较常见的执行顺序为:
执行一个宏任务 ---> 执行所有微任务 ----> GUI 模块进行页面渲染 ---> 执行下一个宏任务
也就是检查顺序为:宏任务回调队列(属于宏任务的异步任务) ---> 宏任务队列(同步任务) ---> 微任务异步回调队列(属于微任务的异步任务)
一个典型的执行样例:
console.log('script start'); setTimeout(function(){ console.log("setTimeout"); },0) Promise.resolve().then(function(){ console.log("promise1"); }).then(function(){ console.log("promise2"); }) console.log('script end');
首先执行同步任务,打印 "script start" 与 "script end" , 然后执行所有微任务 promise1 与 promise2 。再执行宏任务 setTimeout 。