小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

邵威儒發表於2018-09-28

前言:大家好,我叫邵威儒,大家都喜歡喊我小邵,學的金融專業卻憑藉興趣愛好入了程式猿的坑,從大學買的第一本vb和自學vb,我就與程式設計結下不解之緣,隨後自學易語言寫遊戲輔助、交易軟體,至今進入了前端領域,看到不少朋友都寫文章分享,自己也弄一個玩玩,以下文章純屬個人理解,便於記錄學習,肯定有理解錯誤或理解不到位的地方,意在站在前輩的肩膀,分享個人對技術的通俗理解,共同成長!

後續我會陸陸續續更新javascript方面,儘量把javascript這個學習路徑體系都寫一下
包括前端所常用的es6、angular、react、vue、nodejs、koa、express、公眾號等等
都會從淺到深,從入門開始逐步寫,希望能讓大家有所收穫,也希望大家關注我~

文章列表:juejin.im/user/5a84f8…

Author: 邵威儒
Email: 166661688@qq.com
Wechat: 166661688
github: github.com/iamswr/


接下來會寫nodejs連載的筆記,本文主要是講nodejs解決了什麼問題有什麼優勢、程式與執行緒的概念、同步與非同步的概念、阻塞與非阻塞的概念、佇列和棧的概念、巨集任務和微任務以及非常重要的瀏覽器的事件環和nodejs的事件環(event loop)。


Node解決了什麼問題,有什麼優勢?

我們前端和後端互動,主要是請求個介面或者讓後端返回個頁面,頻繁進行io操作,web服務最大的瓶頸就是處理高併發(同一時間併發訪問伺服器的數量),而node則在高併發、io密集型的場景,有明顯的優勢。

i/o密集型是指檔案操作、網路操作、讀取等操作;
cpu密集型則是指需要進行大量的邏輯處理運算、加解密、壓縮解壓等操作;

node是什麼?

Node.js是一個基於 Chrome V8 引擎的JavaScript執行環境(runtime)。

雖然node是用javascript的語法,但是並非是完全的javascript,我們知道javascript是包含了ECMAScript、DOM、BOM,而node則不包含DOM和BOM,但是它也提供了一系列模組供我們使用,如http、fs模組。

node是使用了事件驅動非阻塞式 I/O的模型,使其輕量而且高效,我們在開發中,會大量接觸到node的第三方模組包,以及node擁有全球最大的開源庫生態系統。

事件驅動:傳送事件後,通過回撥的訊息通知機制通知;
非阻塞式 I/O:如操作檔案,通過非阻塞非同步的方式讀取檔案;

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)


程式和執行緒

假設我們使用java、php等伺服器

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

一般啟伺服器用tomcat(apache)、iis,屬於多執行緒同步阻塞,然後啟動服務的時候會配置執行緒數。

mysql、mongo、redis等則是資料庫。

一般是從客戶端發起請求給伺服器,伺服器運算元據庫,然後資料庫把資料返給伺服器,伺服器再返回給客戶端。

當客戶端發起請求到伺服器時,伺服器會有執行緒來處理這條請求,假如是tomcat iis等是屬於多執行緒同步阻塞的,在起服務的時候會配置執行緒數,然後再通過伺服器傳送請求到資料庫請求資料,此時該執行緒會一直等待資料庫的資料返回,當資料庫返回資料給伺服器後,伺服器再把資料返回給客戶端。

當併發量很大時,請求超過執行緒數時,則排在後面的請求會等待前面的請求完成後才會執行,執行緒完成後,並不是馬上銷燬,再建立,而是完成上一次請求後,會被複用到下一個請求當中。

圖中顯示的外層方形,為程式,內層方形為執行緒,一個程式可以分配多個執行緒,我們實際開發中,一個專案一般是多程式。

那什麼是程式?程式是作業系統分配資源和排程任務的基本單位,執行緒是建立在程式上的一次程式執行單位,一個程式上可以有多個執行緒。

假如我們使用node

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

那麼nodejs,我們說了是單執行緒,並不是說一個程式裡面只能跑一條執行緒,而是主執行緒是單執行緒,node是如上圖這樣的。

當客戶端同時傳送請求時,第一條請求傳送到伺服器後會有一條執行緒處理,伺服器會請求資料庫,此時執行緒並不像上面那種方式,在等待資料的返回,該執行緒而是去處理第二條請求,當資料庫返回第一條資料時,該執行緒再通過callback、事件環等機制執行。

雖然node是單執行緒,但是可以通過setTimeout開啟多個執行緒,當併發量很高的時候可以這樣玩。

但是node並不是什麼場景都能使用的,對於cpu密集型的場景,反而不太實用,cpu密集型是指需要大量邏輯、計算,比如大量計算、壓縮、加密、解密等(這部分c++優勢大),node比較適合的是io操作(io密集),為什麼說node適合前端?因為前端主要就是請求個介面,或者伺服器渲染返回頁面,所以node會非常適合前端這種場景。

我們常用node作為中間層,客戶端訪問node,然後由node去訪問伺服器,比如java層,java把資料返回給node,node再把資料返回給客戶端。

我們常見的java是同步多執行緒,node是非同步單執行緒,如果說java的併發量是1000萬,那麼node併發量可以達到3倍以上。

那麼我們接下來了解一下經常和前端打交道的瀏覽器程式、執行緒

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

  • User Interface(使用者介面 程式):如位址列、標籤、前進後退等;
  • Browser engine(瀏覽器引擎 瀏覽器的主程式):在使用者介面和渲染引擎之間傳達指令;
  • Data Persistence(持久層 程式):存放cookies、sessionStorage、loaclStorage、indexedDB等;
  • Rendering engine(渲染引擎 程式):渲染引擎內部是多執行緒的,其中有Networking(ajax請求)、JavaScript Interpreter(js執行緒)、UI Backend(UI執行緒)

在渲染引擎中,需要注意的是,在其內部有兩個非常重要的執行緒,就是js執行緒和ui執行緒,js執行緒和ui執行緒是互斥的,共用同一條執行緒。

那麼為什麼js執行緒和ui執行緒是互斥的?為什麼是單執行緒?我們可以設想一下,當我們通過js操作一個DOM節點的時候,如果同時執行,那麼就存在快慢之分,會顯得很混亂,再設想一下,如果是多執行緒的話,多條執行緒操作同一個DOM節點,是不是也顯得很混亂?所以js設計為單執行緒。

衍生一下,使用java時,如果多執行緒訪問某一個同樣的資源,往 往會給這個資源加一把鎖,有點類似下課了,多個同學上廁所,而廁所只有一間,先進去的人把門鎖上了,後面的人只能排隊,但是nodejs就基本不用擔心這個問題。

js單執行緒指的是js的主執行緒是單執行緒。

瀏覽器中還有其他執行緒
  • 瀏覽器事件觸發執行緒
  • 定時器觸發執行緒
  • 非同步HTTP請求觸發執行緒

非同步和同步、阻塞和非阻塞

主要分為以下幾類組合

  • 同步阻塞
  • 非同步阻塞
  • 同步非阻塞
  • 非同步非阻塞

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

假設呼叫方為小明,被呼叫方為小紅 圖1:小明喜歡小紅,小明於是乎決定給小紅打電話表白,小紅接電話,如果此時小紅把電話晾在那,小明則有兩種狀態,一種是阻塞、一種是非阻塞,阻塞就是小紅晾電話的同時,小明還在等著,叫做阻塞,非阻塞就是小紅晾電話的同時,小明可以去幹別的事情,叫做非阻塞,小紅接電話後說,我要想一想再給你答覆,此時如果沒掛掉電話,那麼是在同步,如果掛掉電話一會再告訴小明,那麼就會是非同步。

圖2:當小紅接電話後,說想一想,一會再告訴你結果,然後把電話掛了,此時屬於非同步,然後小明如果還在痴情地等待電話回覆(即2.1),那麼稱為阻塞,如果小明此時並不是乾等這個答覆,而是打電話向另外一個妹子表白(即2.2),那麼稱為非阻塞,結合起來就是非同步堵塞或非同步非堵塞。

圖3:當小紅接電話後,說想一想,一會再告訴你結果,然後電話也不掛,一直通話,此時屬於同步,但是小明此時偷偷向另外一個妹子打電話表白,這個行為屬於非堵塞,結合起來就是同步非阻塞。


佇列和棧

  • 佇列的特點:佇列的特點是先進先出,如陣列,依次往後新增。

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

  • 棧的特點則是先進後出

首先我們往棧裡分別放1、2、3進去,然後取出時,是按照3 2 1取出

function a() {
  function b() {
    function c() {

    }
    c()
  }
  b()
}

a()

// 這個程式碼中,我們是依次執行了a函式、b函式、c函式
// 但是在銷燬的時候,是先從c函式銷燬,然後再銷燬b函式
// 最後銷燬a函式,如果是先銷燬a函式的話,那麼b就會失去了其執行棧
// 也就是執行上下文,所以在執行棧中,是先進後出。
複製程式碼

巨集任務、微任務(都屬於非同步操作,暫時以瀏覽器事件環機制來講)

大家都知道非同步,但是在非同步當中,又分為兩大類,即巨集任務、微任務,在瀏覽器事件環當中,微任務是在巨集任務執行之前執行的。

常見的巨集任務:

  • setTimeout
  • setImmediate(只有ie支援)
  • setInterval
  • messageChannel

常見的微任務:

  • Promise.then()
  • mutationObserver
我們在使用vue的時候,有一個nextTick方法,意思是把一個方法插入到下一個佇列當中,我們可以看看它的原始碼是怎樣實現的

原始碼:github.com/vuejs/vue/b…

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
複製程式碼

在這段程式碼中,可以看出vue的nextTick對於巨集任務的處理,首先是判斷是否有setImmediate,如果沒有的話,則判斷是否有MessageChannel,如果還沒有的話,最後降級為setTimeout。

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}
複製程式碼

在這段程式碼中,可以看出vue的nextTick對於微任務的處理,首先是判斷是否有Promise,如果沒有Promise的話,則降級為巨集任務。

setImmediate

接下來我們看下這段程式碼如何執行(注意setImmediate需要ie瀏覽器開啟)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <script>
    setImmediate(function(){
      console.log('我是setImmediate')
    },0)

    Promise.resolve().then(function(){
      console.log('我是Promise')
    })
    console.log('我是同步程式碼')
  </script>
</body>
</html>
複製程式碼

依次列印出 '我是同步程式碼' -> '我是Promise' -> '我是setImmediate'
可以看出執行順序是先執行同步程式碼,然後微任務的程式碼,然後巨集任務的程式碼。

MessageChannel

以下程式碼依次列印出 "我是同步程式碼" -> "hello swr",因為MessageChannel也是巨集任務。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <script>
    let messageChannel = new MessageChannel()
    let port1 = messageChannel.port1
    let port2 = messageChannel.port2
    port1.postMessage('hello swr')
    port2.onmessage = function (data) {
      console.log(data.data)
    }
    console.log('我是同步程式碼')
  </script>
</body>
</html>
複製程式碼

MutationObserver

MutationObserve主要是用於監控DOM節點的更新,比如我們有個需求,希望插入DOM完成後,才執行某些行為,我們就可以這樣做。

首先會列印"我是同步程式碼",然後執行兩個for迴圈插入dom節點,最終dom節點更新完畢後,會列印“插入完成 100”。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <div id='div'>
    
  </div>
  <script>
    let observe = new MutationObserver(function(){
      // 這一步之所以列印出p的數量,是為了驗證插完dom節點後,才執行這一步。
      console.log('插入完成',document.querySelectorAll('p').length)
    })
    observe.observe(div,{childList:true})
    console.log('我是同步程式碼')
    for(let i = 0 ;i < 50;i++){
      div.appendChild(document.createElement('p'))
    }
    for(let i = 0 ;i < 50;i++){
      div.appendChild(document.createElement('p'))
    }
  </script>
</body>
</html>
複製程式碼

巨集任務和微任務如何執行

首先我們看一段程式碼

setTimeout(() => {
  console.log('我是setTimeout1')
  Promise.resolve().then(()=>{
    console.log('我是Promise1')
  })
}, 0);

Promise.resolve().then(()=>{
  console.log('我是Promise2')
  setTimeout(() => {
    console.log('我是setTimeout2')
  }, 0);
  Promise.resolve().then(()=>{
    console.log('我是Promise3')
  })
})
複製程式碼

在這段程式碼中,我們要弄明白,什麼是巨集任務,什麼是微任務,
setTimeout是巨集任務,而Promise.resolve().then()是微任務。

還有個概念就是特別需要注意的,這也是和node.js有所區別,
在瀏覽器事件環機制中,當執行棧的同步程式碼清空後,系統會去讀取任務佇列,其中會優先讀取微任務,把微任務清空後,再依次讀取巨集任務,這裡特別注意,並非一次性執行完所有巨集任務,而是像佇列那樣,先取一個巨集任務執行,執行完後,再去看是否有微任務,如果有,則執行微任務,然後再讀取一個巨集任務執行,不斷迴圈。

這裡需要注意的是,在nodejs的事件環機制中,是優先執行微任務,但是當執行完微任務,進入巨集任務的時候,即使在執行巨集任務過程中存在新的微任務,也不會優先執行微任務,而是把巨集任務佇列中執行完畢。

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

首先,程式碼會從上到下執行,碰到setTimeout1,會丟到巨集任務佇列中,然後往下執行遇到Promise2,那麼在執行棧執行完畢後,會優先執行Promise2,列印出“我是Promise2”,執行了Promise2後,發現裡面有個setTimeout2,此時會把setTimeout2丟到巨集任務佇列中,然後繼續往下執行,會碰到Promise3,此時會把Promise3丟到微任務中,並且執行,列印出“我是Promise3”,然後此時微任務佇列執行完畢了,會去巨集任務中讀setTimeout1出來執行,列印出“我是setTimeout1”,再繼續執行,發現裡面有個Promise1,那麼此時會把Promise1丟到微任務中,並且執行Promise1,列印出“我是Promise1”,此時微任務佇列又清空了,再去巨集任務佇列中取出setTimeout2並且執行,列印出“我是setTimeout2”。

列印順序為'我是Promise2' -> '我是Promise3' -> '我是setTimeout1' -> '我是Promise1' -> '我是setTimeout2'


事件環之瀏覽器的事件環Event Loop

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

這個圖就是瀏覽器的事件環,JS中分為兩部分,為堆(heap)和棧(stack),一般棧,我們也可以成為執行棧、執行上下文,我們在棧中操作的時候,會發一些比如ajax請求操作、定時器等,可以看圖中的WebAPIs,是屬於多執行緒,那麼這個WebAPIs多執行緒是怎樣放到棧中執行呢?

比如ajax請求成功後後,會把ajax的回撥放到佇列(callback queue)中,然後當執行棧中把所有的同步任務執行完畢後,系統會讀取佇列中的事件放到執行棧中依次執行,如果執行棧中有同步任務,那麼則會執行同步任務中的任務後再依讀取佇列中的事件到執行棧中依次執行,這個過程是不斷迴圈的。

總結:

  1. 所有的同步任務在主執行緒上執行,形成了一個執行棧;
  2. 如在執行棧中有非同步任務,那麼當這個非同步任務有執行結果後,會放置任務佇列中;
  3. 如果執行棧中的同步任務執行完畢,那麼會從任務佇列中依次讀取事件到執行棧中依次執行;
  4. 執行棧從任務佇列中讀取事件的過程,是不斷迴圈的;

舉些例子驗證

// 我們有3個setTimeout
setTimeout(() => {
  console.log('a')
}, 0);

setTimeout(() => {
  console.log('b')
}, 0);

setTimeout(() => {
  console.log('c')
}, 0);
console.log('hello swr')
// 首先會依次從上到下執行程式碼,遇到setTimeout非同步事件,則放到WebAPIs中
// 如果有了結果(記住,是要有了執行結果!),則會放到任務佇列中
// 然後會執行同步程式碼console.log('hello swr')
// 此時的流程是:
// 首先列印出 'hello swr',然後執行棧中同步程式碼已經執行完畢,則去任務佇列中
// 依次取出這3個setTimeout的事件執行,依次列印出 'a' 'b' 'c'
// 這個順序永遠都不會亂,因為遵循了事件環的機制
複製程式碼

那麼為什麼說執行棧中的同步程式碼執行完後才會執行任務佇列中的任務呢?
接下來我們可以看看這個例子,假如我們在同步程式碼中寫了死迴圈,那麼還會執行任務佇列中的事件嗎?

setTimeout(() => {
  console.log('a')
}, 0);

setTimeout(() => {
  console.log('b')
}, 0);

setTimeout(() => {
  console.log('c')
}, 0);
for(;;){} 
// 死迴圈,我們發現永遠都不會列印出'a' 'b' 'c'
// 因為同步程式碼是死迴圈,一直處於執行狀態,執行棧中的同步程式碼還沒執行完畢
// 是不會去讀取任務佇列中的事件的
複製程式碼

事件環之nodejs的事件環Event Loop

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

nodejs也有它自己的事件環,和瀏覽器事件環的機制並非都一樣的,我們寫的應用程式碼一般是執行在V8引起裡面,它裡面並非僅僅是V8引擎裡面的東西,比如setTimeout,比如eval都是V8引擎提供的,我們寫程式碼還會基於一些node api,也就是node.js bindings,比如node的fs模組,可以發一些非同步的io操作,但是node裡面的非同步和瀏覽器的不一樣,它是自己有一套LIBUV庫,專門處理非同步的io操作的,它靠的是多執行緒實現的(worker threads),它用多執行緒模擬了非同步的機制,我們每次呼叫node api的時候,它裡面會進入LIBUV呼叫多個執行緒執行,同步堵塞呼叫,模擬了非同步的機制,成功以後,通過callback執行放到一個佇列裡,然後返回給我們的客戶端。

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

nodejs事件環,給每個階段都劃分得很清楚,因為nodejs裡面有libuv庫,裡面有以上這幾個方面,每一個都是一個佇列。

在4中,會不斷進行輪詢poll中的i/o佇列和檢查定時器是否到時,如果是的話,會從把這個事件切換到1的佇列中。

在5中,只存放setImmediate,如果4處於輪詢時,發現有check階段,那麼就會往下走進入check階段。

執行順序

  • 首先執行完執行棧中的程式碼;
  • 如微任務中有事件,則執行微任務中的所有佇列執行完畢;
  • 執行1中的佇列;
  • 然後依次執行下一個佇列,需要注意的是,這裡對於微任務的處理,和瀏覽器事件環機制不同,比如node在執行1中的佇列時,是依次執行完,哪怕中途有新的微任務,也不會執行微任務,而是當這個佇列執行完畢後,切換到下一個佇列之前,才執行微任務;
  • 佇列之間的切換,會執行一次微任務;
  • 這個過程是不斷迴圈的;

這段程式碼可以看出,在node的事件環中,當執行到1的佇列中時,即使有新的微任務,也不會馬上執行微任務,而是把當前的佇列清空後才會執行微任務。

setTimeout(() => {
  console.log('setTimeout1')
  process.nextTick(()=>{
    console.log('nextTick')
  })
}, 0);

setTimeout(() => {
  console.log('setTimeout2')
}, 0);

// 依次列印輸出 'setTimeout1' -> 'setTimeout2' -> 'nextTick'
複製程式碼

小夥伴提問區:

小邵教你玩轉nodejs之nodejs概念、事件環機制(1)
小邵教你玩轉nodejs之nodejs概念、事件環機制(1)
小邵教你玩轉nodejs之nodejs概念、事件環機制(1)

相關文章