咱们常常说JS的事件循环有微观队列和宏观队列,全部的异步事件都会放到这两个队列里面等待执行,而且微观任务要先于宏观任务执行。实际上事件循环是多线程的一种工做方式。一般为了提升运行效率会新起一条或多条线程进行并行运算,而后算完了就告知结果并退出,可是有时候并不想每次都新起线程,而是让这些线程变成常驻的,有任务的时候工做,没任务的时候睡眠,这样不用频繁地建立和销毁线程。这种可让这些线程使用事件循环的工做方式。javascript
咱们知道JS是单线程的,当执行一段比较长的JS代码时候,页面会被卡死,没法响应,可是你全部的操做都会被另外的线程记录,例如在卡死的时候点了一个按钮,虽然不会马上触发回调,可是在JS执行完的时候会触发刚才的点击操做。因此就说有一个队列记录了全部待执行的操做,这个队列又分为宏观和微观,像setTimeout/ajax/用户事件这种属于宏观的,而Promise和MutationObserver属于微观的,微观会比宏观执行得更快,以下代码:vue
setTimeout(() => console.log(0), 0);
new Promise(resolve => {
resolve();
console.log(1)
}).then(res => {
console.log(2);
});
console.log(3);复制代码
其输出顺序是1, 3, 2, 0,这里setTimeout是宏观任务,因此比Promise的微观任务慢。java
实际上在Chrome源码里面没有任何有关宏观任务(MacroTask)字样,所谓的宏观任务其实就是一般意义上的多线程事件循环或消息循环,与其叫宏观队列不如叫消息循环队列。web
Chrome的全部常驻多线程,包括浏览器线程和页面的渲染线程都是运行在事件循环里的,咱们知道Chrome是多进程结构的,浏览器进程的主线程和IO线程是统一负责地址输入栏响应、网络请求加载资源等功能的浏览器层面的进程,而每一个页面都有独立的进程,每一个页面进程的主线程是渲染线程,负责构建DOM、渲染、执行JS,还有子IO线程。ajax
这些线程都是常驻线程,它们运行在一个for死循环里面,它们有若干任务队列,不断地执行本身或者其它线程经过PostTask过来的任务,或者是处于睡眠状态直到设定的时间或者是有人PostTask的时候把它们唤醒。编程
经过源码message_pump_default.cc的Run函数能够知道事件循环的工做模式是这样的:promise
void MessagePumpDefault::Run(Delegate* delegate) {
// 在一个死循环里面跑着
for (;;) {
// DoWork会去执行当前全部的pending_task(放一个队列里面)
bool did_work = delegate->DoWork();
if (!keep_running_)
break;
// 上面的pending_task可能会建立一些delay的task,如定时器
// 获取到delayed的时间
did_work |= delegate->DoDelayedWork(&delayed_work_time_);
if (!keep_running_)
break;
if (did_work)
continue;
// idl的任务是在第一步没有执行被deferred的任务
did_work = delegate->DoIdleWork();
if (!keep_running_)
break;
if (did_work)
continue;
ThreadRestrictions::ScopedAllowWait allow_wait;
if (delayed_work_time_.is_null()) {
// 没有delay时间就一直睡着,直到有人PostTask过来
event_.Wait();
} else {
// 若是有delay的时间,那么进行睡眠直到时间到被唤醒
event_.TimedWaitUntil(delayed_work_time_);
}
}
}复制代码
首先代码在一个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:多线程
kDOMManipulation = 1,
kUserInteraction = 2,
kNetworking = 3,
kMicrotask = 9,
kJavascriptTimer = 10,
kWebSocket = 12,
kPostedMessage = 13,
...复制代码
消息循环有本身的message_loop_task_runner,这些task_runner对象是共享的,其它线程能够调用这个task_runner的PostTask函数发送任务。在上面的for循环里面也是经过task_runner的TakeTask函数取出pending的task进行执行的。
在post task的时候会把task入队的同时通时唤醒线程:
// 须要上锁,防止多个线程同时执行
AutoLock auto_lock(incoming_queue_lock_);
incoming_queue_.push(std::move(pending_task));
task_source_observer_->DidQueueTask(was_empty);复制代码
因为几个线程共享了task_runner对象,因此在给它post task的时候须要上锁。最后一行调用的DidQueueTask会进行通知线程唤醒:
// 先调
message_loop_->ScheduleWork();
// 上面的代码会调
pump_->ScheduleWork();
// 最后回到message_pump进行唤醒
void MessagePumpDefault::ScheduleWork() {
// Since this can be called on any thread, we need to ensure that our Run
// loop wakes up.
event_.Signal();
}复制代码
所谓的task是什么呢?一个Task其实就是一个callback回调,以下代码调用的第二个参数:
GetTaskRunner()->PostDelayedTask(
posted_from_,
BindOnce(&BaseTimerTaskInternal::Run, Owned(scheduled_task_)), delay);复制代码
等等,说了这么多,好像和JS没有半毛钱关系?确实没有半毛钱关系,由于这些都是在JS执行以前的。先不要着急。
上面说的是一个默认的事件循环执行的代码,可是Mac的Chrome的渲染线程并非执行的那里的,它的事件循环使用了Mac Cocoa sdk的NSRunLoop,根据源码的解释,是由于页面的滚动条、select下拉弹框是用的Cocoa的,因此必须接入Cococa的事件循环机制,以下代码所示:
#if defined(OS_MACOSX)
// As long as scrollbars on Mac are painted with Cocoa, the message pump
// needs to be backed by a Foundation-level loop to process NSTimers. See
// http://crbug.com/306348#c24 for details.
std::unique_ptr<base::MessagePump> pump(new base::MessagePumpNSRunLoop());
std::unique_ptr<base::MessageLoop> main_message_loop(
new base::MessageLoop(std::move(pump)));
#else
// The main message loop of the renderer services doesn't have IO or UI tasks.
std::unique_ptr<base::MessageLoop> main_message_loop(new base::MessageLoop());
#endif复制代码
若是是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代码上加了一行:
bool did_work = delegate->DoWork();
if (!keep_running_)
break;
event_base_loop(event_base_, EVLOOP_NONBLOCK);复制代码
就是在DoWork以后看一下libevent有没有要作的。因此能够看到它是在本身实现的事件循环里面又套了libevent的事件循环,只不过这个libevent是nonblock,即每次只会执行一次就退出,同时它也具有唤醒的功能。
如今来讨论一些和JS相关的。
当咱们在页面触发鼠标事件的时候,这个时候是浏览器的进程先收到了,而后再经过Chrome的Mojo多进程通讯库传递给页面进程,以下图所示,经过Mojo把消息forward给其它进程:
能够看到这个Mojo的原理是用的本地socket进行的多进程通讯,因此最后是用write socket的方式。Socket是多进程通讯的一种经常使用方式。
经过打断点观察页面进程,推测应该是经过页面进程的子IO线程的libevent唤醒,最后调用PostTask给消息循环的task_runner:
这一点没有获得直接的验证,由于不太好验证。不过结合这些库和打断点观察,这样的方式应该是比较合理比较有可能的,引入libevent就能比较方便地实现这一点。
也就是说点击鼠标消息传递是这样的:
Chromium文档也有对这个过程进行描述,可是它那个文档有点老了。
另一种常见的异步操做是setTimeout。
为了研究setTimeout的行为,咱们用如下JS代码运行:
console.log(Object.keys({a: 1}));
setTimeout(() => {
console.log(Object.keys({b: 2}));
}, 2000);复制代码
而后在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)。以下代码所示:
TimeDelta interval_milliseconds = std::max(TimeDelta::FromMilliseconds(1), interval);
// kMinimumInterval = 4 kMaxTimerNestingLevel = 5
// 若是嵌套了5层的setTimeout,而且时间间隔小于4ms,那么取时间为最小值4ms
if (interval_milliseconds < kMinimumInterval && nesting_level_ >= kMaxTimerNestingLevel)
interval_milliseconds = kMinimumInterval;
if (single_shot)
StartOneShot(interval_milliseconds, FROM_HERE);
else
StartRepeating(interval_milliseconds, FROM_HERE);复制代码
因为是一次的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:
// Chromium uses a minimum timer interval of 4ms. We'd like to go
// lower; however, there are poorly coded websites out there which do
// create CPU-spinning loops. Using 4ms prevents the CPU from
// spinning too busily and provides a balance between CPU spinning and
// the smallest possible interval timer.
static constexpr TimeDelta kMinimumInterval = TimeDelta::FromMilliseconds(4);复制代码
目的是避免对CPU太频繁的调用。实际上这个时间还要取决于操做系统可以提供的时间精度,特别是在Windows上面,经过time_win.cc这个文件咱们能够了解到Windows可以提供的普通时间精度偏差是10 ~ 15ms,也就是说当你setTimeout 10ms,实际上执行的间隔多是几毫秒也有多是20多毫秒。因此Chrome会对delay时间作一个判断:
#if defined(OS_WIN)
// We consider the task needs a high resolution timer if the delay is
// more than 0 and less than 32ms. This caps the relative error to
// less than 50% : a 33ms wait can wake at 48ms since the default
// resolution on Windows is between 10 and 15ms.
if (delay > TimeDelta() &&
delay.InMilliseconds() < (2 * Time::kMinLowResolutionThresholdMs)) {
pending_task.is_high_res = true;
}
#endif复制代码
经过比较,若是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后面还未执行的代码。
事件循环就讨论到这里,接下来讨论下微观任务和微观队列。
微观队列是真实存在的一个队列,是V8里面的一个实现。V8里面的microtask分为如下4种(可见microtask.h):
第一个callback是指普通的回调,包括blink过来的一些任务回调,如Mutation Observer是属于这种。第二个callable是内部调试用的一种任务,另外两个是promise的完成和失败。而promise的finally有then_finally和catch_finally内部会看成参数传给then/catch最后执行。
微观任务是在何时执行的呢?用如下JS进行调试:
console.log(Object.keys({a: 1}));
setTimeout(() => {
console.log(Object.keys({b: 2}));
var promise = new Promise((resolve, reject) => {
resolve(1);
});
promise.then(res => {
console.log(Object.keys({c: 1}));
});
}, 2000);复制代码
这里咱们重点关注promise.then是何时执行的。经过打断点的调用栈,咱们发现一个比较有趣的事情是,它是在一个解构函数里面运行的:
把主要的代码抽出来是这样的:
{
v8::MicrotasksScope microtasks_scope();
v8::MaybeLocal result = function->Call(receiver, argc, args);
}复制代码
这段代码先实例化一个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触发一个请求的时候也会建立一个微观任务:
let img = new Image();
img.src = 'image01.png?_=' + Date.now();
img.onload = function () {
console.log('img ready');
}
console.log(Object.keys({e: 1}));复制代码
咱们常常会有困扰,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里面发起的请求也会建立一个微观任务延后执行。