細說JavaScript單線程的一些事

標籤: JavaScript 單線程javascript


首發地址:碼農網《細說JavaScript單線程的一些事》html

最近被同窗問道 JavaScript 單線程的一些事,我竟回答不上。好吧,感受本身的 JavaScript 白學了。下面是我這幾天整理的一些關於 JavaScript 單線程的一些事。html5

首先,說下爲何 JavaScript 是單線程?

總所周知,JavaScript 是以單線程的方式運行的。說到線程就天然聯想到進程。那它們有什麼聯繫呢?java

進程和線程都是操做系統的概念。進程是應用程序的執行實例,每個進程都是由私有的虛擬地址空間、代碼、數據和其它系統資源所組成;進程在運行過程當中可以申請建立和使用系統資源(如獨立的內存區域等),這些資源也會隨着進程的終止而被銷燬。而線程則是進程內的一個獨立執行單元,在不一樣的線程之間是能夠共享進程資源的,因此在多線程的狀況下,須要特別注意對臨界資源的訪問控制。在系統建立進程以後就開始啓動執行進程的主線程,而進程的生命週期和這個主線程的生命週期一致,主線程的退出也就意味着進程的終止和銷燬。主線程是由系統進程所建立的,同時用戶也能夠自主建立其它線程,這一系列的線程都會併發地運行於同一個進程中。

顯然,在多線程操做下能夠實現應用的並行處理,從而以更高的 CPU 利用率提升整個應用程序的性能和吞吐量。特別是如今不少語言都支持多核並行處理技術,然而 JavaScript 卻以單線程執行,爲何呢?git

其實這與它的用途有關。做爲瀏覽器腳本語言,JavaScript 的主要用途是與用戶互動,以及操做 DOM。若以多線程的方式操做這些 DOM,則可能出現操做的衝突。假設有兩個線程同時操做一個 DOM 元素,線程 1 要求瀏覽器刪除 DOM,而線程 2 卻要求修改 DOM 樣式,這時瀏覽器就沒法決定採用哪一個線程的操做。固然,咱們能夠爲瀏覽器引入「鎖」的機制來解決這些衝突,但這會大大提升複雜性,因此 JavaScript 從誕生開始就選擇了單線程執行。github

另外,由於 JavaScript 是單線程的,在某一時刻內只能執行特定的一個任務,而且會阻塞其它任務執行。那麼對於相似 I/O 等耗時的任務,就不必等待他們執行完後才繼續後面的操做。在這些任務完成前,JavaScript 徹底能夠往下執行其餘操做,當這些耗時的任務完成後則以回調的方式執行相應處理。這些就是 JavaScript 與生俱來的特性:異步與回調。web

固然對於不可避免的耗時操做(如:繁重的運算,多重循環),HTML5 提出了Web Worker,它會在當前 JavaScript 的執行主線程中利用 Worker 類新開闢一個額外的線程來加載和運行特定的 JavaScript 文件,這個新的線程和 JavaScript 的主線程之間並不會互相影響和阻塞執行,並且在 Web Worker 中提供了這個新線程和 JavaScript 主線程之間數據交換的接口:postMessage 和 onMessage 事件。但在 HTML5 Web Worker 中是不能操做 DOM 的,任何須要操做 DOM 的任務都須要委託給 JavaScript 主線程來執行,因此雖然引入 HTML5 Web Worker,但仍然沒有改線 JavaScript 單線程的本質。瀏覽器

併發模式與 Event Loop

JavaScript 有個基於「Event Loop」併發的模型。
啊,併發?不是說 JavaScript 是單線程嗎? 沒錯,的確是單線程,可是併發與並行是有區別的。
前者是邏輯上的同時發生,然後者是物理上的同時發生。因此,單核處理器也能實現併發。安全

併發與並行

併發與並行 多線程

並行你們都好理解,而所謂「併發」是指兩個或兩個以上的事件在同一時間間隔中發生。如上圖的第一個表,因爲計算機系統只有一個 CPU,故 ABC 三個程序從「微觀」上是交替使用 CPU,但交替時間很短,用戶察覺不到,造成了「宏觀」意義上的併發操做。

Runtime 概念

下面的內容解釋一個理論上的模型。現代 JavaScript 引擎已着重實現和優化了如下所描述的幾個概念。

Stack、Heap、Queue

Stack(棧)

這裏放着 JavaScript 正在執行的任務。每一個任務被稱爲幀(stack of frames)。

function f(b) {
  var a = 12;
  return a + b + 35;
}

function g(x) {
  var m = 4;
  return f(m * x);
}

g(21);

上述代碼調用 g 時,建立棧的第一幀,該幀包含了 g 的參數和局部變量。當 g 調用 f 時,第二幀就會被建立,而且置於第一幀之上,固然,該幀也包含了 f 的參數和局部變量。當 f 返回時,其對應的幀就會出棧。同理,當 g 返回時,棧就爲空了(棧的特定就是後進先出 Last-in first-out (LIFO))。

Heap(堆)

一個用來表示內存中一大片非結構化區域的名字,對象都被分配在這。

Queue(隊列)

一個 JavaScript runtime 包含了一個任務隊列,該隊列是由一系列待處理的任務組成。而每一個任務都有相對應的函數。當棧爲空時,就會從任務隊列中取出一個任務,並處理之。該處理會調用與該任務相關聯的一系列函數(所以會建立一個初始棧幀)。當該任務處理完畢後,棧就會再次爲空。(Queue的特色是先進先出 First-in First-out (FIFO))。

爲了方便描述與理解,做出如下約定:

  • Stack 棧爲主線程
  • Queue 隊列爲任務隊列(等待調度到主線程執行)

OK,上述知識點幫助咱們理清了一個 JavaScript runtime 的相關概念,這有助於接下來的分析。

Event Loop

之因此被稱爲 Event loop,是由於它以如下相似方式實現:

while(queue.waitForMessage()) {
  queue.processNextMessage();
}

正如上述所說,「任務隊列」是一個事件的隊列,若是 I/O 設備完成任務或用戶觸發事件(該事件指定了回調函數),那麼相關事件處理函數就會進入「任務隊列」,當主線程空閒時,就會調度「任務隊列」裏第一個待處理任務(FIFO)。固然,對於定時器,當到達其指定時間時,纔會把相應任務插到「任務隊列」尾部。

「執行至完成」

每當某個任務執行完後,其它任務纔會被執行。也就是說,當一個函數運行時,它不能被取代且會在其它代碼運行前先完成。
固然,這也是 Event Loop 的一個缺點:當一個任務完成時間過長,那麼應用就不能及時處理用戶的交互(如點擊事件),甚至致使該應用奔潰。一個比較好解決方案是:將任務完成時間縮短,或者儘量將一個任務分紅多個任務執行。

毫不阻塞

JavaScript 與其它語言不一樣,其 Event Loop 的一個特性是永不阻塞。I/O 操做一般是經過事件和回調函數處理。因此,當應用等待 indexedDB 或 XHR 異步請求返回時,其仍能處理其它操做(如用戶輸入)。

例外是存在的,如 alert 或者同步 XHR,但避免它們被認爲是最佳實踐。注意的是,例外的例外也是存在的(但一般是實現錯誤而非其它緣由)。

定時器

定時器的一些概念

上面也提到,在到達指定時間時,定時器就會將相應回調函數插入「任務隊列」尾部。這就是「定時器(timer)」功能。

定時器 包括 setTimeout 與 setInterval 兩個方法。它們的第二個參數是指定其回調函數推遲每隔多少毫秒數後執行。

對於第二個參數有如下須要注意的地方:

  • 當第二個參數缺省時,默認爲 0;
  • 當指定的值小於 4 毫秒,則增長到 4ms(4ms 是 HTML5 標準指定的,對於 2010 年及以前的瀏覽器則是 10ms);

若是你理解上述知識,那麼如下代碼就應該對你沒什麼問題了:

console.log(1);
setTimeout(function() {
  console.log(2);
},10);
console.log(3);
// 輸出:1 3 2

深刻了解定時器

零延遲 setTimeout(func, 0)

零延遲並非意味着回調函數馬上執行。它取決於主線程當前是否空閒與「任務隊列」裏其前面正在等待的任務。

看看如下代碼:

(function () {

  console.log('this is the start');

  setTimeout(function cb() {
    console.log('this is a msg from callback');
  });

  console.log('this is just a message');

  setTimeout(function cb1() {
    console.log('this is a msg from callback1');
  }, 0);

  console.log('this is the end');

})();

// 輸出以下:
this is the start
this is just a message
this is the end
undefined // 當即調用函數的返回值
this is a msg from callback
this is a msg from callback1
setTimeout(func, 0) 的做用
  • 讓瀏覽器渲染當前的元素更改(瀏覽器將 UI render 和 JavaScript 的執行是放在一個線程中,線程阻塞會致使界面沒法更新渲染)
  • 從新評估「scriptis running too long」警告
  • 改變執行順序

再看看如下代碼:

<button id='do'> Do long calc!</button>
<div id='status'></div>
<div id='result'></div>


$('#do').on('click', function() {
  
  // 此處會觸發 redraw 事件,但會放到隊列裏執行,直到 long() 執行完。
  $('#status').text('calculating....');

  // 沒設定定時器,用戶將沒法看到 「calculating...」
  // 這是由於「calculation」的 redraw 事件會緊接在
  // 「calculating...」的 redraw 事件後執行
  long(); // 執行長時間任務,形成阻塞

  // 設定了定時器,用戶就如期看到「calculating...」
  // 大約 50ms 後,將耗時長的 long 回調函數插入「任務隊列」末尾,
  // 根據先進先出原則,其將在 redraw 以後被調度到主線程執行
  //setTimeout(long,50);

});

function long() {
  var result = 0;
  for (var i = 0; i<1000; i++){
    for (var j = 0; j<1000; j++){
      for (var k = 0; k<1000; k++){
        result = result + i+j+k;
      }
    } 
  }
  // 在本案例中,該語句必須放到這裏,這將使它與回調函數的行爲相似
  $('#status').text('calculation done');
}
正版與翻版 setInterval 的區別

你們均可能知道經過 setTimeout 能夠模仿 setInterval 的效果,下面咱們看看如下代碼的區別:

// 利用 setTimeout 模仿 setInterval
setTimeout(function() {
  /* 執行一些操做. */
  setTimeout(arguments.callee, 1000);
}, 1000);

setInterval(function() {
  /* 執行一些操做 */
}, 1000);

可能你認爲這沒什麼區別。的確,當回調函數裏的操做耗時很短時,並不能看出它們有什麼區別。
其實:上面案例中的 setTimeout 老是會在其回調函數執行後延遲 1000ms(或者更多,但不可能少)再次執行回調函數,從而實現 setInterval 的效果,而 setInterval 老是 1000ms 執行一次,而無論它的回調函數執行多久。

因此,若是 setInterval 的回調函數執行時間比你指定的間隔時間相等或者更長,那麼其回調函數會連在一塊兒執行。

你能夠試試運行如下代碼:

var counter = 0;
  var initTime = new Date().getTime();
  var timer = setInterval(function() {
    if(counter===2) {
      clearInterval(timer);
    }
    if(counter === 0) {
      for(var i = 0; i < 1990000000; i++) {
        ;
      }
    }

    console.log("第"+counter+"次:" + (new Date().getTime() - initTime) + " ms");

    counter++;
},1000);

我電腦 Chrome 瀏覽器的輸入以下:

第0次:2007 ms
第1次:2013 ms
第2次:3008 ms

從上面的執行結果可看出,第一次和第二次執行間隔很短(不足 1000ms)。

瀏覽器

瀏覽器不是單線程的

上面說了這麼多關於 JavaScript 是單線程的,下面說說其宿主環境——瀏覽器。
瀏覽器的內核是多線程的,它們在內核制控下相互配合以保持同步,一個瀏覽器至少實現三個常駐線程:

  1. JavaScript 引擎線程 JavaScript 引擎是基於事件驅動單線程執行的,JavaScript 引擎一直等待着任務隊列中任務的到來,而後加以處理。
  2. GUI 渲染線程 GUI 渲染線程負責渲染瀏覽器界面,當界面須要重繪(Repaint)或因爲某種操做引起迴流(reflow)時,該線程就會執行。但須要注意 GUI 渲染線程與 JavaScript 引擎是互斥的,當 JavaScript 引擎執行時 GUI 線程會被掛起,GUI 更新會被保存在一個隊列中等到 JavaScript 引擎空閒時當即被執行。
  3. 瀏覽器事件觸發線程事件觸發線程,當一個事件被觸發時該線程會把事件添加到「任務隊列」的隊尾,等待 JavaScript 引擎的處理。這些事件可來自 JavaScript 引擎當前執行的代碼塊如 setTimeOut、也可來自瀏覽器內核的其餘線程如鼠標點擊、AJAX 異步請求等,但因爲 JavaScript 是單線程執行的,全部這些事件都得排隊等待 JavaScript 引擎處理。

在 Chrome 瀏覽器中,爲了防止因一個標籤頁奔潰而影響整個瀏覽器,其每一個標籤頁都是一個進程(Renderer Process)。固然,對於同一域名下的標籤頁是可以相互通信的,具體可看 瀏覽器跨標籤通信。在 Chrome 設計中存在不少的進程,並利用進程間通信來完成它們之間的同步,所以這也是 Chrome 快速的法寶之一。對於 Ajax 的請求也須要特殊線程來執行,當須要發送一個 Ajax 請求時,瀏覽器會開闢一個新的線程來執行 HTTP 的請求,它並不會阻塞 JavaScript 線程的執行,當 HTTP 請求狀態變動時,相應事件會被做爲回調放入到「任務隊列」中等待被執行。

看看如下代碼:

document.onclick = function() {
  console.log("click");
}

for(var i = 0; i< 100000000; i++);

解釋一下代碼:首先向 document 註冊了一個 click 事件,而後就執行了一段耗時的 for 循環,在這段 for 循環結束前,你能夠嘗試點擊頁面。當耗時操做結束後,console 控制檯就會輸出以前點擊事件的「click」語句。這證實了點擊事件(也包括其它各類事件)是由額外單獨的線程觸發的,事件觸發後就會將回調函數放進了「任務隊列」的末尾,等待着 JavaScript 主線程的執行。

總結

  • JavaScript 是單線程的,同一時刻只能執行特定的任務,而瀏覽器是多線程的。
  • 異步任務(各類瀏覽器事件、定時器等)都是先添加到「任務隊列」(定時器則到達其指定參數時)。當 Stack 棧(JavaScript 主線程)爲空時,就會讀取 Queue 隊列(任務隊列)的第一個任務(隊首),而後執行。

JavaScript 爲了不復雜性,而實現單線程執行。而現在 JavaScript 卻變得愈來愈不簡單了,固然這也是 JavaScript 迷人的地方。

後續更新(回覆網友的問題)

  1. 關於"setTimeout(func, 0)的做用"一節中,redraw事件發生後,事件處理函數被插入任務隊列,等待當前棧中long函數執行完畢再執行。此時經過setTimeout(long,0)便可將long函數插到任務隊列中redraw事件處理函數的後面。事實上Chrome中也確實是這麼處理的(個人版本號是55.0.2883.87 m),但是最新的火狐和Edge都至少要將延時設置爲15ms以上,請問這是爲何?

答:恩,這的確取決於瀏覽器的內部實現。

昨晚,我看了Chrome(chromium)的定時器源碼實現:

一些變量的定義:

static const int maxIntervalForUserGestureForwarding = 1000; // One second matches Gecko.
static const int maxTimerNestingLevel = 5;
static const double oneMillisecond = 0.001;
// 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 const double minimumInterval = 0.004;

定時器的部分實現:

DOMTimer::DOMTimer(ExecutionContext* context, ScheduledAction* action, int interval, bool singleShot, int timeoutID)
    : SuspendableTimer(context)
    , m_timeoutID(timeoutID)
    , m_nestingLevel(context->timers()->timerNestingLevel() + 1)
    , m_action(action)
{
    ASSERT(timeoutID > 0);
    if (shouldForwardUserGesture(interval, m_nestingLevel))
        m_userGestureToken = UserGestureIndicator::currentToken();

    InspectorInstrumentation::asyncTaskScheduled(context, singleShot ? "setTimeout" : "setInterval", this, !singleShot);

    double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);
    if (intervalMilliseconds < minimumInterval && m_nestingLevel >= maxTimerNestingLevel)
        intervalMilliseconds = minimumInterval;
    if (singleShot)
        startOneShot(intervalMilliseconds, BLINK_FROM_HERE);
    else
        startRepeating(intervalMilliseconds, BLINK_FROM_HERE);
}

從上述代碼可看出:Chrome 實現的定時器的最小時間間隔是 1ms。只有知足 intervalMilliseconds < minimumInterval && m_nestingLevel >= maxTimerNestingLevel 該條件時,定時器的最小時間間隔纔是 4ms

所以,各瀏覽器是往響應更快的方向發展的。


對於你提問的「在Edge和火狐上,redraw事件和setTimeout執行順序問題」,也一樣取決於瀏覽器的內部實現。

我在我電腦的Edge和火狐瀏覽器上進行測試,當時間間隔較小時(如 0~10ms),redraw和setTimeout的執行順序是不固定的。

所以,這須要你通過足夠多的測試,獲得一個相對安全的時間值,以確保執行順序的正確性。

參考資料:

  1. JavaScript 運行機制詳解:再談Event Loop
  2. JavaScript單線程和瀏覽器事件循環簡述
  3. Javascript是單線程的深刻分析
  4. Concurrency model and Event Loop
  5. 也談setTimeout
  6. 單線程的Javascript

若這篇文章讓您獲益,歡迎您在 Github 給個 Star


本文連接:http://www.codeceo.com/articl...本文做者:碼農網 – 劉健超[ 原創做品,轉載必須在正文中標註並保留原文連接和做者等信息。]

相關文章
相關標籤/搜索