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

NO IMAGE

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

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

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

Author: 邵威儒
Email: [email protected]
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)

相關文章

徹底弄懂JS原型與繼承

不會發布node包?進來看看

webpack4.x最詳細入門講解

小邵教你玩轉Typescript、ts版React全家桶腳手架