【nodejs原理&源碼雜記(8)】Timer模塊與基於二叉堆的定時器

示例代碼託管在:http://www.github.com/dashnowords/blogs前端

博客園地址:《大史住在大前端》原創博文目錄node

華爲雲社區地址:【你要的前端打怪升級指南】git

一.概述

Timer模塊相關的邏輯較爲複雜,不只包含JavaScript層的實現,也包括C++編寫的與底層libuv協做的代碼,想要完整地看明白是比較困難的,本章僅以setTimeout這個API的實現機制爲主線,講述源碼中的JavaScript相關的實現部分,這部分只須要一些數據結構的基本知識就能夠理解。github

二. 數據結構

setTimeout這個API的實現基於兩類基本數據結構,咱們先來複習一下相關的特色。對數據結構知識比較陌生的小夥伴能夠參考【野生前端的數據結構基礎練習】系列博文自行學習,全部的章節都有示例代碼。bootstrap

2.1 鏈表

鏈表是一種物理存儲單元上非連續的存儲結構,存儲元素的邏輯順序是由鏈表中的指針連接次序來決定的。每個節點包含一個存放數據的數據域和存放下一個節點的指針域(雙向鏈表中指針數量爲2)。鏈表在插入元素時的時間複雜度爲O(1)(由於隻影響插入點先後的節點,不管鏈表有多大),可是因爲空間不連續的特色,訪問一個未排序鏈表的指定節點時就須要逐個對比,時間複雜度爲O(n),比數組結構就要慢一些。鏈表結構也能夠根據指針特色分爲單向鏈表,雙向鏈表循環鏈表Timer模塊中使用的鏈表結構就是雙向循環鏈表,Node.js中源碼的底層數據結構實現都是獨立的,鏈表的源碼放在lib/internal/linkedlist.js數組

'use strict';

function init(list) {
  list._idleNext = list;
  list._idlePrev = list;
}

// Show the most idle item.
function peek(list) {
  if (list._idlePrev === list) return null;
  return list._idlePrev;
}

// Remove an item from its list.
function remove(item) {
  if (item._idleNext) {
    item._idleNext._idlePrev = item._idlePrev;
  }

  if (item._idlePrev) {
    item._idlePrev._idleNext = item._idleNext;
  }

  item._idleNext = null;
  item._idlePrev = null;
}

// Remove an item from its list and place at the end.
function append(list, item) {
  if (item._idleNext || item._idlePrev) {
    remove(item);
  }

  // Items are linked  with _idleNext -> (older) and _idlePrev -> (newer).
  // Note: This linkage (next being older) may seem counter-intuitive at first.
  item._idleNext = list._idleNext; //1
  item._idlePrev = list;//2

  // The list _idleNext points to tail (newest) and _idlePrev to head (oldest).
  list._idleNext._idlePrev = item;//3
  list._idleNext = item;//4
}

function isEmpty(list) {
  return list._idleNext === list;
}

鏈表實例初始化了兩個指針,初始時均指向本身,_idlePrev指針將指向鏈表中最新添加進來的元素,_idleNext指向最新添加進來的元素,實現的兩個主要操做爲removeappend。鏈表的remove操做很是簡單,只須要將刪除項先後的元素指針加以調整,而後將被刪除項的指針置空便可,就像從一串鎖鏈中拿掉一節,很形象。數據結構

源碼中的idlePrevidleNext很容易混淆,建議不用強行翻譯爲「先後」或者「新舊」,(反覆記憶N次都記不住我也很無奈),直接按對應位置來記憶就能夠了,愛翻譯成什麼就翻譯成什麼。app

源碼中的鏈表實現並無提供指定位置插入的方法,append( )方法默認只接收listitem兩個參數,新元素會被默認插入在鏈表的固定位置,這與它的使用方式有關,因此不必實現完整的鏈表數據結構。append稍微複雜一些,可是源碼中也作了很是詳細的註釋。首先須要確保插入的元素是獨立的(也就是prevnext指針都爲null),而後再開始調整,源碼中的鏈表是一個雙向循環鏈表,咱們調整一下源碼的順序會更容易理解,其實插入一個元素就是要將各個元素的prevnext兩個指針調整到位就能夠了。先來看_idlePrev指針鏈的調整, 也就是指針調整代碼中標記爲2和3的語句:異步

item._idlePrev = list;//2
list._idleNext._idlePrev = item;//3

這裏能夠把list看做是一個prev指針鏈接起來的單向鏈表,至關於將新元素item按照prev指針的指向添加到list和本來的list._idleNext指向的元素中間,而1和4語句是調整了反方向的next指針鏈:

item._idleNext = list._idleNext; //1
list._idleNext = item;//4

調整後的鏈表以next指針爲依據就能夠造成反方向的循環鏈表,而後只須要記住list._idleNext指針指向的是最新添加的項就能夠了。

如上圖所示,nextprev分別能夠做爲鏈表的邏輯順序造成循環鏈。

2.2 二叉堆

源碼放在lib/internal/priority_queue.js中,一些博文也直接翻譯爲優先隊列,它們是抽象結構和具體實現之間的關係,特性是一致的。二叉堆是一棵有序的徹底二叉樹,又以節點與其後裔節點的關係分爲最大堆最小堆徹底二叉樹的特色使其能夠很容易地轉化爲一維數組來存儲,且不須要二外記錄其父子關係,索引爲i的節點的左右子節點對應的索引爲2i+12i+2(固然左右子節點也可能只有一個或都不存在)。Node.js就使用一維數組來模擬最小堆。源碼基本上就是這一數據結構和「插入」,「刪除」這些基本操做的實現。

結構的使用最主要的是爲了得到堆頂的元素,由於它老是全部數據裏最大或最小的,同時結構是一個動態調整的數據結構,插入操做時會將新節點插入到堆底,而後逐層檢測和父節點值的相對大小而「上浮」直到整個結構從新變爲;進行移除操做(移除堆頂元素也是移除操做的一種)時,須要將堆尾元素置換到移除的位置,以維持整個數據結構依然是一棵徹底二叉樹,而後經過與父節點和子節點進行比較來決定該位置的元素應該「上浮」或「下沉」,並遞歸這個過程直到整個數據結構被重建爲。相關的文章很是,本文再也不贅述(能夠參考這篇博文【二叉堆的添加和刪除元素方法】,有動畫好理解)。

三. 從setTimeout理解Timer模塊源碼

timer模塊並不須要手動引入,它的源碼在/lib/timers.js目錄中,咱們以這樣一段代碼來看看setTimeout方法的執行機制:

setTimeout(()=>{console.log(1)},1000);
setTimeout(()=>{console.log(2)},500);
setTimeout(()=>{console.log(3)},1000);

3.1 timers.js中的定義

最上層方法的定義進行了一些參數格式化,將除了回調函數和延遲時間之外的其餘參數組成數組(應該是用apply來執行callback方法時把這些參數傳進去),接着作了三件事,生成timeout實例,激活實例,返回實例。

3.2 Timeout類定義

Timeout類定義在【lib/internal/timers.js】中:

初始化了一些屬性,能夠看到傳入構造函數的callback,after,args都被記錄下來,能夠看到after的最小值爲1msTimeout還定義了一些原型方法能夠先不用管,而後調用了initAsyncResource( )這個方法,它在實例上添加了[async_id_symbol][trigger_async_id_symbol]兩個標記後,又調用了emitInit( )方法將這些參數均傳了進去,這個emitInit( )方法來自於/lib/internal/async_hooks.js,官方文檔對async_hook模塊的解釋是:

The async_hooks module provides an API to register callbacks tracking the lifetime of asynchronous resources created inside a Node.js application.

它是一個實驗性質的API,是爲了Node.js內部建立的用於追蹤異步資源生命週期的模塊,因此推測這部分邏輯和執行機制關係不大,能夠先擱在一邊。

3.3 active(timeout)

得到了timeout實例後再回到上層函數來,接下來執行的是active(timeout)這個方法,它調用的是insert( item, true, getLibuvNow()),不難猜想最後這個方法就是從底層libuv中獲取一個準確的當前時間,insert方法的源碼以下:

首先爲timeout實例添加了開始執行時間idleStart屬性,接下來的邏輯涉及到兩個對象,這裏提早說明一下:timerListMap是一個哈希表,延時的毫秒數爲key,其value是一個雙向鏈表,鏈表中存放着timeout實例,因此timerListMap就至關於一個按延時時間來分組存放定時器實例的Hash+linkedList結構,另外一個重要對象timerListQueue就是上面講過的優先隊列(後文使用「二叉堆」這一律念)。

這裏有一個小細節,就是將新的定時器鏈表加入二叉堆時,比較函數是自定義傳入的,在源碼中很容易看到compareTimersLists ( )這個方法使用鏈表的expiry屬性的值進行比較來獲得最小堆,由此能夠知道,堆頂的鏈表老是expiry最小的,也就是說堆頂鏈表的__idlePrev指向的定時器,就是全部定時器裏下一個須要觸發回調的。

接下來再來看看active( )函數體的具體邏輯,若是有對應鍵的鏈表則獲取到它(list變量),若是沒有則生成一個新的空鏈表,而後將這個鏈表添加進二叉堆,跳過中間的步驟,在最後能夠看到執行了:

L.append(list, item);

這個L其實是來自於前文提過的linkedList.js中的方法,就是將timeout實例添加到list鏈表中,來個圖就很容易理解了:

中間咱們跳過了一點邏輯,就是在新鏈表生成時執行的:

if(nextExpiry > expiry){
    scheduleTimer(msecs);
    nextExpiry = expiry;
}

nextExpirytimer模塊中維護的一個模塊內的相對全局變量,這裏的expiry是新鏈表的下一個定時器的過時時間(也就是新鏈表中惟一一個timeout實例的過時時間),這裏針對的狀況就是新生成的定時器比已存在的全部定時器都要更早觸發,這時就須要從新調度一下,並把當前這個定時器的過時時間點設置爲nextExpiry時間。

這個scheduleTimer( )使用internalBinding('timers')引入的,在lib/timer.cc中找到這個方法:

void ScheduleTimer(const FunctionCallbackInfo<Value>& args) {
  auto env = Environment::GetCurrent(args);
  env->ScheduleTimer(args[0]->IntegerValue(env->context()).FromJust());
}

再跳到env.cc:

void Environment::ScheduleTimer(int64_t duration_ms) {
  if (started_cleanup_) return;
  uv_timer_start(timer_handle(), RunTimers, duration_ms, 0);
}

能夠看到這裏就將定時器的信息和libuv的事件循環聯繫在一塊兒了,libuv尚未研究,因此這條邏輯線暫時到此爲止。再回到以前的示例,當三個定時器都添加完成後,內存中的對象關係基本是下面的樣子:

3.4 定時器的處理執行邏輯

至此咱們已經將定時器的信息都存放好了,那麼它是如何被觸發的呢?咱們找到node.js的啓動文件lib/internal/bootstrap/node.js284-290行,能夠看到,在啓動函數中,Node.js經過調用setTimers( )方法將定時器處理函數processTimers傳遞給了底層,它最終會被用來調度執行定時器,processTimers方法由lib/internal/timers.js中提供的getTimerCallbacks(runNextTicks) 方法運行獲得,因此聚焦到/lib/internal/timers.js中:

推測libuv每次須要檢查是否有定時器到期時都會運行processTimers( )方法,來看一下對應的邏輯,一個無限循環的while語句,直到二叉堆的堆頂沒有任何定時器時跳出循環並返回0。在循環體內部,會用堆頂元素的過時時間和當前時間相比,若是list.expiry更大,說明時機未到還不須要執行,把它的過時時間賦值給nextExpiry而後返回(返回邏輯先不細究)。若是邏輯執行到471行,說明堆頂元素的過時時間已通過了,ranAtLeastOneList這個標記位使得這段邏輯按照以下方式運行:

1.獲取到一個expiry已通過期的鏈表,首次向下執行時`ranAtLeastOneList`爲false,則將其置爲true,而後執行`listOnTimeout()`這個方法;
2.而後繼續取堆頂的鏈表,若是也過時了,再次執行時,會先執行`runNextTicks()`,再執行`listOnTimeout()`。

咱們按照邏輯順序,先來看看listOnTimeout( )這個方法,它有近100行(咱們以上面3個定時器的實例來看看它的執行邏輯):

function listOnTimeout(list, now) {
    const msecs = list.msecs; //500 , 500ms的鏈表在堆頂

    debug('timeout callback %d', msecs);

    var diff, timer;
    let ranAtLeastOneTimer = false;
    while (timer = L.peek(list)) {  //取鏈表_idlePrev指向的定時器,也就是鏈表中最早到期的
      diff = now - timer._idleStart;  //計算當前時間和它開始計時那個時間點的時間差,

      // Check if this loop iteration is too early for the next timer.
      // This happens if there are more timers scheduled for later in the list.
      // 原文翻譯:檢測當前事件循環對於下一個定時器是否過早,這種狀況會在鏈表中還有其餘定時器時發生。
      // 人話翻譯:就是當前的時間點只須要觸發鏈表中第一個500ms定時器,下一個500ms定時器還沒到觸發時間。
      //         極端的相反狀況就是因爲阻塞時間已通過去好久了,鏈表裏的N個定時器全都過時了,都得執行。
      if (diff < msecs) {
        //更新鏈表中下一個到期定時器的時間記錄,計算邏輯稍微有點繞
        list.expiry = Math.max(timer._idleStart + msecs, now + 1);
        list.id = timerListId++;
        timerListQueue.percolateDown(1);//堆頂元素值發生更新,須要經過「下沉」來重構「堆」
        debug('%d list wait because diff is %d', msecs, diff);
        return; //直接結束了
      }

      //是否是貌似見過這段,先放着等會一塊說
      if (ranAtLeastOneTimer)
        runNextTicks();
      else
        ranAtLeastOneTimer = true;

      // The actual logic for when a timeout happens.
      L.remove(timer);

      const asyncId = timer[async_id_symbol];

      if (!timer._onTimeout) {
        if (timer[kRefed])
          refCount--;
        timer[kRefed] = null;

        if (destroyHooksExist() && !timer._destroyed) {
          emitDestroy(asyncId);
          timer._destroyed = true;
        }
        continue;
      }

      emitBefore(asyncId, timer[trigger_async_id_symbol]);

      let start;
      if (timer._repeat) //這部分看起來應該是interval的邏輯,interval底層實際上就是一個重複的timeout
        start = getLibuvNow();

      try {
        const args = timer._timerArgs;
        if (args === undefined)
          timer._onTimeout();  //設置定時器時傳入的回調函數被執行了
        else
          timer._onTimeout(...args);
      } finally {
        if (timer._repeat && timer._idleTimeout !== -1) {
          timer._idleTimeout = timer._repeat;
          if (start === undefined)
            start = getLibuvNow();
          insert(timer, timer[kRefed], start);//interval的真實執行邏輯,從新獲取時間而後插入到鏈表中
        } else if (!timer._idleNext && !timer._idlePrev) {
          if (timer[kRefed])
            refCount--;
          timer[kRefed] = null;

          if (destroyHooksExist() && !timer._destroyed) {
            emitDestroy(timer[async_id_symbol]);
            timer._destroyed = true;
          }
        }
      }

      emitAfter(asyncId);
    }
      
    //這塊須要注意的是,上面整個邏輯都包在while(timer = L.peek(list)){...}裏面

    // If `L.peek(list)` returned nothing, the list was either empty or we have
    // called all of the timer timeouts.
    // As such, we can remove the list from the object map and
    // the PriorityQueue.
    debug('%d list empty', msecs);

    // The current list may have been removed and recreated since the reference
    // to `list` was created. Make sure they're the same instance of the list
    // before destroying.
    // 原文翻譯:當前的list標識符所引用的list有可能已經通過了重建,刪除前須要確保它指向哈希表中的同一個實例。
    if (list === timerListMap[msecs]) {
      delete timerListMap[msecs];
      timerListQueue.shift();
    }
  }

3.5 實例分析

代碼邏輯由於包含了不少條件分支,因此不容易理解,咱們之前文的實例做爲線索來看看定時器觸發時的執行邏輯:

程序啓動後,processTimer( )方法不斷執行,第一個過時的定時器會在堆頂的500ms定時器鏈表(下面稱爲500鏈表)中產生,過時時間戳爲511。

假設時間戳到達600時程序再次執行processTimer( ),此時發現當前時間已經超過nextExpiry記錄的時間戳511,因而繼續向下執行進入listOnTimeout(list, now),這裏的list就是哈希表中鍵爲500的鏈表,now就是當前時間600,進入listOnTimeout方法後,獲取到鏈表中最先的一個定時器timer,而後計算diff參數爲600-11=589, 589 > 500, 因而繞過條件分支語句,ranAtLeastOneTimer爲false也跳過(跳事後其值爲true),接下來的邏輯從鏈表中刪除了這個timer,而後執行timer._onTimeout指向的回調函數,500鏈表只有一個定時器,因此下一循環時L.peek(list)返回null,循環語句跳出,繼續向後執行。此時list依然指向500鏈表,因而執行刪除邏輯,從哈希表和二叉堆中均移除500鏈表,兩個數據結構在底層會進行自調整。

processTimer( )再次執行時,堆頂的鏈表變成了1000ms定時器鏈表(下面稱爲1000鏈表),nextExpiry賦值爲list.expiry,也就是1001,表示1000ms定時器鏈表中下一個到期的定時器會在時間戳超過1001時過時,但時間尚未到。下面分兩種狀況來分析:

1.時間戳爲1010時執行processTimer( )

時間來到1010點,processTimer( )被執行,當前時間1010大於nextExpiry=1001,因而從堆頂獲取到1000鏈表,進入listOnTimeout( ),第一輪while循環執行時的情形和500鏈表執行時是一致的,在第二輪循環中,timer指向1000鏈表中後添加的那個定時器,diff的值爲 1010 - 21 = 989,989 < 1000 ,因此進入if(diff < msecs)的條件分支,list.expiry調整爲下一個timer的過時時間1021,而後經過下沉來重建二叉堆(堆頂元素的expiry發生了變化),上面的實例中只剩了惟一一個鏈表,因此下沉操做沒有引起什麼實質影響,接着退出當前函數回到processTimer的循環體中,接着processTimer裏的while循環繼續執行,再次檢查棧頂元素,時間還沒到,而後退出,等時間超過下一個過時時間1021後,最後一個定時器被觸發,過程基本一致,只是鏈表耗盡後會觸發listOnTimeout後面的清除哈希表和二叉堆中相關記錄的邏輯。

總結一下,鏈表的消耗邏輯是,從鏈表中不斷取出peek位置的定時器,若是過時了就執行,若是取到一個沒過時的,說明鏈表裏後續的都沒過時,就修改鏈表上的list.expiry屬性而後退回到processTimer的循環體裏,若是鏈表耗盡了,就將它從哈希表和二叉堆中把這個鏈表刪掉。

2.時間戳爲1050時執行processTimer( )

假如程序由於其餘緣由直到時間爲1050時纔開始檢查1000鏈表,此時它的兩個定時器都過時了須要被觸發,listOnTimeout( )中的循環語句執行第一輪時是同樣的,第二次循環執行時,跳過if(diff < msecs)的分支,而後因爲ranAtLeastOneTimer標記位的變化,除了第一個定時器的回調外,其餘都會先執行runNextTicks( )而後再執行定時器上綁的回調,等到鏈表耗盡後,進入後續的清除邏輯。

咱們再來看一種更極端的狀況,假如程序一直阻塞到時間戳爲2000時才執行到processTimer(此時3個定時器都過時了,2個延遲1000ms,1個延遲500ms,堆頂爲500ms鏈表),此時processTimer( )先進入第一次循環,處理500鏈表,而後500鏈表中惟一的定時器處理完後,邏輯回到processTimer的循環體,再進行第二輪循環,此時獲取到堆頂的1000鏈表,發現仍然須要執行,那麼就會先執行runNextTicks( ),而後在處理1000鏈表,後面的邏輯就和上面時間戳爲1050時執行processTimer基本一致了。

至此定時器的常規邏輯已經解析完了,還有兩個細節須要提一下,首先是runNextTicks( ),從名字能夠推測它應該是執行經過process.nextTick( )添加的函數,從這裏的實現邏輯來看,當有多個定時器須要觸發時,每一個間隙都會去消耗nextTicks隊列中的待執行函數,以保證它能夠起到「儘量早地執行」的職責,對此不瞭解的讀者能夠參考上一篇博文【譯】Node.js中的事件循環,定時器和process.nextTick

四. 小結

timer模塊比較大,在瞭解基本數據結構的前提下不算特別難理解,setImmediate( )process.nextTick( )的實現感興趣的讀者能夠自行學習,想要對事件循環機制有更深刻的理解,須要學習C++和libuv的相關原理,筆者還沒有深刻涉獵,之後有機會再寫。

相關文章
相關標籤/搜索