1. 程式人生 > >從Chrome原始碼看事件迴圈

從Chrome原始碼看事件迴圈

我們經常說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,還有子IO執行緒。

這些執行緒都是常駐執行緒,它們執行在一個for死迴圈裡面,它們有若干任務佇列,不斷地執行自己或者其它執行緒通過PostTask過來的任務,或者是處於睡眠狀態直到設定的時間或者是有人PostTask的時候把它們喚醒。

通過原始碼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,如定時器
    // 獲取到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裡面發起的請求也會建立一個微觀任務延後執行。