51

从Chrome源码看事件循环

 5 years ago
source link: https://www.yinchengli.com/2018/11/04/chrome-event-loop/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

1. 常规JS事件循环

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

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的微观任务慢。

2. 宏观任务的本质

实际上在Chrome源码里面没有任何有关宏观任务(MacroTask)字样,所谓的宏观任务其实就是通常意义上的多线程事件循环或消息循环。Chrome的所有常驻多线程,包括浏览器线程和页面的渲染线程都是运行在事件循环里的,我们知道Chrome是多进程结构的,浏览器进程的主线程和IO线程是统一负责地址输入样栏响应、网络请求加载资源等功能的浏览器层面的进程,而每个页面都有独立的进程,每个页面进程的主线程是渲染线程,负责构建DOM、渲染、执行JS。

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

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

void MessagePumpDefault::Run(Delegate* delegate) {
  // 在一个死循环里面跑着
  for (;;) {
    // DoWork会去执行当前所有的pending_task(放一个队列里面)
    bool did_work = delegate->DoWork();
    if (!keep_running_)
      break;
    // 上面的pending_task可能会创建一些delay的task,如setTimeout定时器
    // 获取到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的PostDelayedTask发送任务,在上面的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相关的。

(1)用户事件

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

faaQrqM.png!web

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

页面的进程通过打断点观察,应该是通过页面进程的子IO线程的libevent唤醒,最后调用ScheduleWork通知Cococa的消息pump:

jiUnMfR.png!web

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

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

RNVjIbV.png!web

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

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

(2)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的执行时机,如下图所示:

nAzmMj3.png!web

我们发现,第一次执行是在DoWork后由HTMLParserScriptParser触发执行的,而第二次是在DoDelayedWork(最上面提到的事件循环模型)里面执行的。实际上第一次执行后就会注册一个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 runner的PostTask:

nmmYZvu.png!web

并且可以看到delay的时间就是传进去的2000ms,这里被转为了纳秒。

在源码里面可以看到,调用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

如果比较设置得比较小,就会尝试使用用高精度的时间。但是由于高精度的时间API(QPC)需要操作系统支持,并且非常耗时和耗电,所以如果笔记本没有插电的情况是不会启用。

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

需要注意的是setTimeout是存放在sequence_queue里面的,这个是为了严格确保执行先后顺序的(而上面消息循环的队列不能严格保证)。而这个sequence的didRunTask函数会当作一个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最后执行。

微观任务是在什么时候执行的呢?用以下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是什么时候执行的。通过打断点的调用栈,我们发现一个比较有趣的事情是,它是在一个解构函数里面运行的:

Fz2Afiq.png!web

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

{
  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会创建一个微观任务,推到微观队列里面,如下代码所示:

ZjyANjn.png!web

这个是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里面发起的请求也会创建一个微观任务延后执行。

Post Views: 2


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK