從Chrome源碼看事件循環

咱們常常說JS的事件循環有微觀隊列和宏觀隊列,全部的異步事件都會放到這兩個隊列裏面等待執行,而且微觀任務要先於宏觀任務執行。實際上事件循環是多線程的一種工做方式。一般爲了提升運行效率會新起一條或多條線程進行並行運算,而後算完了就告知結果並退出,可是有時候並不想每次都新起線程,而是讓這些線程變成常駐的,有任務的時候工做,沒任務的時候睡眠,這樣不用頻繁地建立和銷燬線程。這種可讓這些線程使用事件循環的工做方式。javascript

1. 常規JS事件循環

咱們知道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

2. 宏觀任務的本質

實際上在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相關的。

(1)用戶事件

當咱們在頁面觸發鼠標事件的時候,這個時候是瀏覽器的進程先收到了,而後再經過Chrome的Mojo多進程通訊庫傳遞給頁面進程,以下圖所示,經過Mojo把消息forward給其它進程:

能夠看到這個Mojo的原理是用的本地socket進行的多進程通訊,因此最後是用write socket的方式。Socket是多進程通訊的一種經常使用方式。

經過打斷點觀察頁面進程,推測應該是經過頁面進程的子IO線程的libevent喚醒,最後調用PostTask給消息循環的task_runner:

這一點沒有獲得直接的驗證,由於不太好驗證。不過結合這些庫和打斷點觀察,這樣的方式應該是比較合理比較有可能的,引入libevent就能比較方便地實現這一點。

也就是說點擊鼠標消息傳遞是這樣的:

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的執行時機,以下圖所示,這個函數就是執行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後面還未執行的代碼。

事件循環就討論到這裏,接下來討論下微觀任務和微觀隊列。

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進行調試:

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裏面發起的請求也會建立一個微觀任務延後執行。

相關文章
相關標籤/搜索