event loop是JS的基础知识,但同时也是一个比较复杂的知识点。这篇文章将分享我学习event loop的一个由浅入深的过程。
1. a Loop with a Queue
我们都知道JS作为一门编程语言最大的特点就是单线程且不阻塞。
单线程很好理解,因为JS引擎只有一个线程在工作(不考虑webworker),所以我们不用担心多线程并发争夺临界资源的问题。
而不阻塞则是体现在,JS处理 I/O操作(比如发起一个网络请求或者读取文件)时,不会等待这些I/O结束再继续执行,而是通过回调函数等其他方式获得异步请求的结果。而这个特性的实现的基础就是event loop。
当JS开始执行,主线程进入事件循环。在当前任务执行过程中发生的所有异步任务(不仅有JS执行过程中发起的异步请求,还包括用户操作引起的事件)都会被加入到待执行队列中。在当前这一轮事件循环完成之后,根据“先进先出”的原则从队列中拿到待执行任务,进入下一轮事件循环。如果队列中没有任务,则线程会同步等待下一个任务。
while (queue.waitForTask()) { queue.processNextTask(); }
从这个模型里我们还可以得到其他一些信息。比如事件循环中每次事件执行都是非抢占的。即除非这个事件执行结束,否则不能从外部中止。这与C语言不同,例如,如果函数在线程中运行,它可能在任何位置被终止,然后在另一个线程中运行其他代码。
这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用就无法处理用户的交互,例如点击或滚动。浏览器用“程序需要过长时间运行”的对话框来缓解这个问题。一个很好的做法是缩短消息处理,并在可能的情况下将一个消息裁剪成多个消息。这也是React Fiber解决性能问题的思路,不过这又是另一个话题了。
2. Macrotask and Microtask
上一段建立的模型很好理解,但是当你开始遇到这样的问题的时候,这个模型似乎就不灵了。
setTimeout(() => {console.log(1)}, 0) Promise.resolve().then(() =>console.log(2)) console.log(3) // 输出顺序: 3, 2, 1
如果还是按照先进先出的队列来理解,显然setTimeout的任务应该先于Promise执行。这个时候就轮到microtask登场了。
简单来说就是event loop用两个队列来处理异步任务。以setTimeout为代表的任务放到被称为macrotask,放到Macrotask queue中,而以Promise 为代表的任务放到Microtask queue中。
macrotasks: script(整体代码),setTimeout,setInterval,setImmediate,I/O,UIrendering,eventlistnermicrotasks: process.nextTick, Promises, Object.observe, MutationObserver
eventloop对这两个队列的处理逻辑也不一样。
执行过程如下:
- JavaScript引擎首先从macrotask queue中取出第一个任务,
- 执行完毕后,将microtask queue中的所有任务取出,按顺序全部执行(全部执行不仅指开始执行时队列里的microtask,在这一步执行过程中产生的新的microtask,也要在这里执行)
- 然后再从macrotask queue中取下一个, 执行完毕后,再次将microtask queue中的全部取出; 循环往复,直到两个queue中的任务都取完。
换句话说,一次eventloop循环会处理一个macrotask和所有这次循环中产生的microtask。
3. Browser or Node
到目前为止,使用这个包括macrotask和microtask的模型已经可以解决很多问题。但是如果你和我一样,看到上面列举的macrotasks和microtasks中出现的process.nextTick和setImmediate一脸懵逼的时候。这也意味着,你的模型需要进化到下一个阶段了。
JS实际上是运行在不同的宿主环境中的,无论是浏览器端(各大浏览器)也好还是服务端(node)。不同的平台有不同的实现,虽然大家都要遵守ES的规范,但是不同实现总归是有些差别的,这也是为什么JS里经常会出现兼容性的问题。
而process.nextTick()和setImmediate就是仅在Node里实现的异步接口(其实微软家的edge和IE也支持setImmediate)。
Node把一个完整的loop分成几个阶段,每个阶段都有一个对应的macrotask队列。
- timers
执行setTimeout和setInterval
- pending callbacks
这个阶段主要执行系统级别的回调函数,比如TCP连接失败的回调
- idle, prepare
node 内部调用,可以忽略
- poll
poll阶段是一个比较复杂而且重要的阶段。几乎所有I/O相关的回调(除了setTimeout,setInterval,setImmediate以及一些因为exception意外关闭产生的回调)都在这个阶段执行。而且
这个阶段的主要流程如下:
D
- check
执行setImmediate
- close callbacks
执行关闭请求的回调函数,比如socket.on('close', ...)。
除了把event loop的macrotask细分到不同阶段外。node还引入了一个新的任务队列——process.nextTick() 队列。根据官方文档的解释:
process.nextTick()is not technically part of the event loop. Instead, thenextTickQueuewill be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.
可以认为process.nextTick()会在上述各个阶段结束时,在进入下一个阶段之前立即执行(优先级甚至超过microtask队列)。
node端和浏览器端macrotask队列的另一个很重要的不同点是,浏览器端task队列每轮事件循环仅出队一个回调函数去执行接着去执行microtask,而node端只要轮到执行某个macrotask队列,则会执行完队列中的所有当前任务,但是当前轮次新添加到队尾的任务则会等到下一轮次才会执行。
4. event loop with render
在学习过event loop在不同平台的实现之后,可能你认为对于event loop已经了解够多了,直到有一天你遇到了requestIdleCallback和requestAnimationFrame。
这两个函数似乎在我们之前的模型里没有出现,这很正常,毕竟异步的api那么多怎么可能都记住。但是当你开始考虑它们在even loop的生命周期的哪一步触发,或者它们的回调会在microtask队列还是macrotask队列的时候,才发现事情没那么简单。
这两个api也并不属于JS运行时,而是浏览器宿主环境提供的,因为它们牵扯到另一个主题——渲染。
我们知道浏览器作为一个复杂的应用是多线程工作的,除了运行JS的线程外,还有渲染线程,定时器触发线程,HTTP 请求线程等等。JS线程可以读取并且修改DOM而渲染线程也需要读取DOM,这是一个典型的多线程竞争临界资源的问题。 所以浏览器就把这两个线程设计成互斥的,即同时只能有一个线程在执行。
渲染原本就不应该出现在event loop相关的知识体系里。因为event loop显然是在讨论JS如何运行的问题,而渲染则是浏览器另外一个线程的工作。但是requestAnimationFrame的出现却把这两件事情给关联起来。
requestAnimationFrame()method tells the browser that you wish to perform an animation and requests that the browser calls a specified function to update an animation before the next repaint.
通过调用requestAnimationFrame我们可以在下次渲染之前执行回调函数。那下次渲染具体是哪个时间点呢?渲染和event loop有什么关系呢?
我在HTML协议对event loop的规范里找到了答案。
简单来说,就是在每一次event loop的末尾,判断当前页面是否处于渲染时机,是就重新渲染。而这个所谓的渲染时机是这样定义的。
Rendering opportunities are determined based on hardware constraints such as display refresh rates and other factors such as page performance or whether the page is in the background. Rendering opportunities typically occur at regular intervals.
有屏幕的硬件限制,比如60Hz刷新率,1s刷新60次,16.6ms刷新一次。这个时候浏览器的渲染间隔时间就没必要小于16.6ms,因为就算渲染了屏幕上也看不到,只是徒增功耗。当然浏览器也不能保证一定会每16.6ms有一次渲染,因为还会受到机器性能,JS执行时间等等其他因素影响。
所以总之浏览器的渲染频率更多是由它自身根据实际情况调整,而不是一个具体固定的数值。
回到requestAnimationFrame,这个api保证在下次浏览器渲染之前一定会被调用,实际上我们完全可以把他看成是一个高级定制版的setTimeout()。它们都是在一段时间后执行回调,但是前者的间隔时间是由浏览器自己不断调整的,而后者只能由用户指定。这样的特性也决定了requestAnimationFrame更适合用来做针对每一帧来修改的动画效果。
当然requestAnimationFrame不是eventloop里的macrotask,或者说它并不在eventloop的生命周期里,只是浏览器又开放的一个在渲染之前发生的新的hook。
所以它对应的animation callback queue的处理方式也是不一样的。它的处理方式和Node中的macrotask queue一样,都是只处理队列中有的所有tasks,但是和microtask queue不同的是执行过程中产生的新task会等到下一轮再处理。
另外需要注意的是microtask的概念也需要更新。在执行animation callback时也有可能产生microtask(比如promise回调),会放到animation queue处理完后再执行。所以microtask并不是像之前说的那样在每一轮event loop后处理,而是在JS的函数调用栈清空后处理。
而requestIdleCallback 是一个更好理解的概念。当macrotask queue中没有任务可以处理时,浏览器可能存在“空闲状态”。这段空闲时间可以被requestIdleCallback利用起来执行一些优先级不高不必要立即执行的任务
当然为了防止浏览器一直处于繁忙状态,导致requestIdleCallback可能永远无法执行回调,它还提供一个额外的timeout参数,为这个任务设置一个截止时间。浏览器就可以根据这个截止时间规划这个任务的执行。
5. 结语
event loop本身并不是一个难理解的概念,但是因为JS不同平台的实现的差异(包括node和各家浏览器平台)让这个知识点很难一下说清楚,也就有了这样一篇文章。
实际上这篇文章的初衷不只是想整理关于event loop的知识,而是想自下而上体现学习一个知识点循序渐进,由浅入深的一个过程。就像学习event loop的各个阶段,每一个阶段的模型可能都是不完整的,片面而且有缺憾的,但也是进入下一个阶段的基础。首先创建一个心智模型,不断的否定和改进旧的模型,学习就是这样一个螺旋式上升的过程。
所以没有必要一开始就追求自己的理解有多完美多深入。这也是为什么很多人不推荐通过wiki或者其他各种百科去学习一个新的知识。的确它够全面和专业,但是这种自上而下的知识整理的形式不适合新手去学习。