从Chrome源码看事件循环

我们经常说JS的事件循环有微观队列和宏观队列,所有的异步事件都会放到这两个队列里面等待执行,并且微观任务要先于宏观任务执行。实际上事件循环是多线程的一种工作方式。通常为了提高运行效率会新起一条或多条线程进行并行运算,然后算完了就告知结果并退出,但是有时候并不想每次都新起线程,而是让这些线程变成常驻的,有任务的时候工作,没任务的时候睡眠,这样不用频繁地创建和销毁线程。这种可以让这些线程使用事件循环的工作方式。

1. 常规JS事件循环

我们知道JS是单线程的,当执行一段比较长的JS代码时候,页面会被卡死,无法响应,但是你所有的操作都会被另外的线程记录,例如在卡死的时候点了一个按钮,虽然不会立刻触发回调,但是在JS执行完的时候会触发刚才的点击操作。所以就说有一个队列记录了所有待执行的操作,这个队列又分为宏观和微观,像setTimeout/ajax/用户事件这种属于宏观的,而Promise和MutationObserver属于微观的,微观会比宏观执行得更快,如下代码:

其输出顺序是1, 3, 2, 0,这里setTimeout是宏观任务,所以比Promise的微观任务慢。

2. 宏观任务的本质

实际上在Chrome源码里面没有任何有关宏观任务(MacroTask)字样,所谓的宏观任务其实就是通常意义上的多线程事件循环或消息循环,与其叫宏观队列不如叫消息循环队列。

Chrome的所有常驻多线程,包括浏览器线程和页面的渲染线程都是运行在事件循环里的,我们知道Chrome是多进程结构的,浏览器进程的主线程和IO线程是统一负责地址输入栏响应、网络请求加载资源等功能的浏览器层面的进程,而每个页面都有独立的进程,每个页面进程的主线程是渲染线程,负责构建DOM、渲染、执行JS,还有子IO线程。

这些线程都是常驻线程,它们运行在一个for死循环里面,它们有若干任务队列,不断地执行自己或者其它线程通过PostTask过来的任务,或者是处于睡眠状态直到设定的时间或者是有人PostTask的时候把它们唤醒。

通过源码message_pump_default.cc的Run函数可以知道事件循环的工作模式是这样的:

首先代码在一个for死循环里面执行,第一步先调用DoWork遍历并取出任务队列里所有非delayed的pending_task执行,部分任务可能会被deferred到后面第三步DoIdlWork再执行,第二步是执行那些delayed的任务,如果当前不能立刻执行,那么设置一个等待的时间delayed_work_time_,并且返回did_work是false,执行到最后面代码的TimedWaitUntil等待时间后唤醒执行。

这就是多线程事件循环的基本模型。那么多线程要执行的task是从哪里来的呢?

每个线程都有一个或多个类型的task_runner的对象,每个task_runner都有自己的任务队列,Chrome将task分成了很多种类型,可见task_type.h

消息循环有自己的message_loop_task_runner,这些task_runner对象是共享的,其它线程可以调用这个task_runner的PostTask函数发送任务。在上面的for循环里面也是通过task_runner的TakeTask函数取出pending的task进行执行的。

在post task的时候会把task入队的同时通时唤醒线程:

由于几个线程共享了task_runner对象,所以在给它post task的时候需要上锁。最后一行调用的DidQueueTask会进行通知线程唤醒:

所谓的task是什么呢?一个Task其实就是一个callback回调,如下代码调用的第二个参数:

等等,说了这么多,好像和JS没有半毛钱关系?确实没有半毛钱关系,因为这些都是在JS执行之前的。先不要着急。

上面说的是一个默认的事件循环执行的代码,但是Mac的Chrome的渲染线程并不是执行的那里的,它的事件循环使用了Mac Cocoa sdk的NSRunLoop,根据源码的解释,是因为页面的滚动条、select下拉弹框是用的Cocoa的,所以必须接入Cococa的事件循环机制,如下代码所示:

如果是OS_MACOSX的话,消息循环泵pump就是用的NSRunLoop的,否则的话就用默认的。这个泵pump的意思应该就是指消息的源头。实际上在crbug网站的讨论里面,Chromium源码的提交者们还是希望去掉渲染线程里的Cococa改成用Chrome本身的Skia图形库画滚动条,让渲染线程不要直接响应UI/IO事件,但是没有周期去做这件事件,从更早的讨论可以看到有人尝试做了但是出了bug,最后又给revert回来了。

Cococa的pump和默认的pump都有统一对外的接口,例如都有一个ScheduleWork函数去唤醒线程,只是里面的实现不一样,如唤醒的方式不一样。

Chrome IO线程(包括页面进程的子IO线程)在默认的pump上面又加了一个libevent.c库提供的消息循环。libevent是一个跨平台的事件驱动的网络库,主要是拿来做socket编程的,以事件驱动的方式。接入libevent的pump文件叫message_pump_libevent.cc,它是在默认的pump代码上加了一行:

就是在DoWork之后看一下libevent有没有要做的。所以可以看到它是在自己实现的事件循环里面又套了libevent的事件循环,只不过这个libevent是nonblock,即每次只会执行一次就退出,同时它也具备唤醒的功能。

现在来讨论一些和JS相关的。

(1)用户事件

当我们在页面触发鼠标事件的时候,这个时候是浏览器的进程先收到了,然后再通过Chrome的Mojo多进程通信库传递给页面进程,如下图所示,通过Mojo把消息forward给其它进程:

可以看到这个Mojo的原理是用的本地socket进行的多进程通信,所以最后是用write socket的方式。Socket是多进程通信的一种常用方式。

通过打断点观察页面进程,推测应该是通过页面进程的子IO线程的libevent唤醒,最后调用PostTask给消息循环的task_runner:

这一点没有得到直接的验证,因为不太好验证。不过结合这些库和打断点观察,这样的方式应该是比较合理比较有可能的,引入libevent就能比较方便地实现这一点。

也就是说点击鼠标消息传递是这样的:

Chromium文档也有对这个过程进行描述,但是它那个文档有点老了。

另外一种常见的异步操作是setTimeout。

(2)setTimeout

为了研究setTimeout的行为,我们用以下JS代码运行:

然后在v8/src/runtime/runtime_object.cc这个文件的Runtime_ObjectKeys函数打个断点,就能观察setTimeout的执行时机,如下图所示,这个函数就是执行Object.keys的地方:

我们发现,第一次断点卡住即执行Object.keys的地方,是在DoWork后由HTMLParserScriptParser触发执行的,而第二次setTimeout里的是在DoDelayedWork(最上面提到的事件循环模型)里面执行的。

具体来说,第一次执行Object.keys后就会注册一个DOMTimer,这个DOMTimer会post一个delayed task给主线程即自己(因为当前就是运行在主线程),这个task里注明了delayed时间,这样在事件循环里面这个delayed时间就会做为TimedWaitUntil的休眠时间(渲染线程是用的是Cococa的CFRunLoopTimerSetNextFireDate)。如下代码所示:

由于是一次的setTimeout,所以会调倒数第三行的StartOneShort,这个函数最后会调timer_task_runner的PostTask:

并且可以看到delay的时间就是传进去的2000ms,这里被转为了纳秒。这个timer_task_runner和message_loop_task_runner一样都是运行在渲染线程的,这个timer_task_runner最后是用这个delay时间去post一个delay task给message loop的task runner.

在源码里面可以看到,调用setInterval的最小时间是4ms:

目的是避免对CPU太频繁的调用。实际上这个时间还要取决于操作系统能够提供的时间精度,特别是在Windows上面,通过time_win.cc这个文件我们可以了解到Windows能够提供的普通时间精度误差是10 ~ 15ms,也就是说当你setTimeout 10ms,实际上执行的间隔可能是几毫秒也有可能是20多毫秒。所以Chrome会对delay时间做一个判断:

通过比较,如果delay设置得比较小,就会尝试使用用高精度的时间。但是由于高精度的时间API(QPC)需要操作系统支持,并且非常耗时和耗电,所以笔记本没有插电的情况是不会启用。不过一般情况下我们可以认为JS的setTimeout可以精确到10ms.

另外一个问题,如果setTimeout时间为0会怎么样?也是一样的,它最后也会post task,只是这个task的delayed时间是0,它就会在消息循环的DoWork函数里面执行。

需要注意的是setTimeout是存放在一个sequence_queue里面的,这个是为了严格确保执行先后顺序的(而上面消息循环的队列不能严格保证)。而这个sequence的相关RunTask函数会当作一个task回调抛给事件循环的task runner以执行自己队列里的task.

所以当我们执行setTimeout 0的时候就会post一个task给message loop的队列,然后接着执行当前task的工作,如setTimeout 0后面还未执行的代码。

事件循环就讨论到这里,接下来讨论下微观任务和微观队列。

2. 微观任务和微观队列

微观队列是真实存在的一个队列,是V8里面的一个实现。V8里面的microtask分为以下4种(可见microtask.h):

  1. callback
  2. callable
  3. promiseFullfil
  4. promiseReject

第一个callback是指普通的回调,包括blink过来的一些任务回调,如Mutation Observer是属于这种。第二个callable是内部调试用的一种任务,另外两个是promise的完成和失败。而promise的finally有then_finally和catch_finally内部会当作参数传给then/catch最后执行。

微观任务是在什么时候执行的呢?用以下JS进行调试:

这里我们重点关注promise.then是什么时候执行的。通过打断点的调用栈,我们发现一个比较有趣的事情是,它是在一个解构函数里面运行的:

把主要的代码抽出来是这样的:

这段代码先实例化一个scope对象,是放在栈上的,然后调function.call,这个function.call就是当前要执行的JS代码,等到JS执行完了,离开作用域,这个时候栈对象就会被解构,然后在解构函数里面执行microtask。注意C++除了构造函数之外还有解构函数,解构函数是对象被销毁时执行的,因为C++没有自动垃圾回收,需要有个解构函数让你自己去释放new出来的内存。

也就是说微观任务是在当前JS调用执行完了之后立刻执行的,是同步的,在同一个调用栈里,没有多线程异步,如这里包括promise.then在内的setTimeout回调里的代码都是在DOMTimer.Fired执行的,只是说then被放到了当前要执行的整一个异步回调函数的最后面执行。

所以setTimeout 0是给主线程的消息循环任务队列添加了一个新的task(回调),而promise.then是在当前task的V8里的microtask插入了一个任务。那么肯定是当前正在执行的task执行完了才执行下一个task.

除了Promise,其它常见的能创建微观任务的还有MutationObserver,Vue的$nextTick还有Promise的polyfill基本上都是用这个实现的,它的作用是把callback当作一个微观任务放到当前同步的JS的最后面执行。当我们修改一个vue data属性以更新DOM修改时,实际上vue是重写了Object的setter,当修改属性时就会触发Object的setter,这个时候vue就知道你做了修改进而相应地修改DOM,而这些操作都是同步的JS完成的,可能只是调用栈比较深,当这些调用栈都完成了就意味着DOM修改完了,这个时候再同步地执行之前插入的微观任务,所以nextTick能够在DOM修改生效之后才执行。

另外,当我们在JS触发一个请求的时候也会创建一个微观任务:

我们经常会有困扰,onload是不是应该写在src赋值的前面,避免src加上之后触发了请求,但onload那一行还没执行到。实际上我们可以不用担心,因为执行到src赋值之后,blink会创建一个微观任务,推到微观队列里面,如下代码所示:

这个是ImageLoader做的enqueue操作,接着执行最后一行的Object.keys,执行完了之后再RunMicrotasks,把刚刚入队的任务即加载资源的回调取出来运行。

上面enqueue入队微观队列的代码是给blink使用的,V8自己的enqueue是在builtins-internal-gen.cc这个文件里面的,这种builtins类型的文件是编译的时候直接执行生成汇编代码再编译的,所以在调试的时候源码是显示成汇编代码的。这种不太好调试。目的可能是直接跟据不同平台生成不同的汇编代码,能够加快执行速度。

 

最后,事件循环就是多线程的一种工作方式,Chrome里面是使用了共享的task_runner对象给自己和其它线程post task过来存起来,用一个死循环不断地取出task执行,或者进入休眠等待被唤醒。Mac的Chrome渲染线程和浏览器线程还借助了Mac的sdk Cococa的NSRunLoop来做为UI事件的消息源。Chrome的多进程通信(不同进程的IO线程的本地socket通信)借助了libevent的事件循环,并加入了到了主消息循环里面。

而微观任务是不属于事件循环的,它是V8的一个实现,用来实现Promise的then/reject,以及其它一些需要同步延后的callback,本质上它和当前的V8调用栈是同步执行的,只是放到了最后面。除了Promise/MutationObserver,在JS里面发起的请求也会创建一个微观任务延后执行。

发表评论

电子邮件地址不会被公开。