文章也同時發表於medium(`・ω・´)”
某次我跟partner小明發生了一件趣事,不知大家是否也有以下相似的經驗:
小明:你的server好像卡住了,client這邊打requests要等很久耶!
我:ㄜ...我記得Node.js因為有Event queue與Event loop非同步模型,所以是非阻塞IO的server,這好像就是我要一直要用callback的原因,整個server那麼多callback應該是不會被多個requests而阻塞吧?
小明:喔喔,這應該就是Node.js適合IO-bound的原因吧,但聽說還有CPU-bound,CPU是不是也有可能阻塞啊?
我:???
小明:我說...
一開始聽到這些名詞,我矇圈了許久。
我們現在來介紹這些名詞到底能辦到什麼,讓大家先有個概念。
大家要先有一個認知就是,Node.js是單執行緒-single thread,所以做什麼事情都沒辦法使用影分身之術多執行緒-multi thread來多工,但網路的世界肯定是多工的啊!
舉個例子:「如果FB網頁載入一張圖片,聊天框就不能動了」,那麼大家肯定都在也不用FB,大家都視力2.0,而Node.js怎麼解決這件事呢?
以下就用麥當當點餐情境配合圖片來解釋:
顧客(request)光顧麥當當時,需要有一個空間可以等候餐點,而Event queue就是此空間。
面對大量個顧客,Node.js必須要有快速處理餐點的能力,此時靠的就是櫃檯Event loop(這間麥當當只有一個櫃檯),負責把顧客的餐點需求告知給廚房,大廚們就會「自行分配」到底誰要處理漢堡、薯條等等。
而當廚房餐點正在製作時,櫃檯人員還是能繼續服務顧客點餐,在處理點餐時,大廚們的餐點好了通知櫃檯(callback),這時櫃檯會停下手邊工作,把餐點送給客人,再繼續原本的點餐服務。
這代表了Node.js不會一直處理顧客點餐的服務,當餐點好了就會先暫停手邊工作而去送餐,這讓執行的順序是非同步的
大家可以發現到,就算櫃檯始終只有一個,但有了大廚們的配合,還是可以很快地處理完這些餐點,可以想想看,如果沒有大廚們,全都靠櫃檯人員負責點餐跟煮菜,那麥當當應該直接排隊排到馬路上。
所以套回Node.js上,因為Node.js很適合這樣與「不同周邊互動」的情境,例如:其他server、資料庫、文件溝通時,所以他很適合密集的接受一連串的點餐,「不會因溝通而停止自己手邊的工作」即是非阻塞IO,因此我們說「Node.js適合IO-bound(IO密集型)操作」。
於是麥當當又過了和平沒有塞車的一天,直到...
但是,如果今天賣當當規定,櫃檯要先與顧客打五場任天堂明星大亂鬥才能點餐,那櫃檯肯定會因這五場遊戲而「卡住」了所有事情,其他顧客點餐要等很久,而大廚在廚房也沒事做,因為櫃檯打遊戲太拖台錢了。
櫃檯沒辦法分身(多執行緒)去應付點餐,所以我們才會說「Node.js不適合CPU-bound(CPU密集型)操作」。
我這邊準備了Node.js 兩個server與test script來解釋,
完整範例:Github link
Server分別為:
首先是測試Node.js的強項非阻塞IO的部分,
我們用testIO.js
發送多個requests至主server,
而主server再去向Sleep server發出請求,此動作即是非同步的。
// server.js
async function request () {
console.log("let't go")
await rp('http://localhost:9999/sleep')
console.log('done')
}
app.get('/io', async function (req, res) {
await request()
res.send('Hello World!');
})
// testIO.js
async function request () {
console.log("let't go")
await rp('http://localhost:3000/io')
console.log('done')
}
(async () => {
await Promise.all([
request(),
request(),
request(),
request(),
request(),
request(),
])
})()
在測試腳本執行下去之後會得到此結果:
腳本的全部requests發出後,會在約五秒後,一次把responses都拿回來,在這五秒到底發生了什麼事呢?
我們用時序圖來看看
我們可以發現,在藍箭頭處,主server並沒有停留在等待SleepServer的回應,而是緊接著處理下一個request,
假如主server沒有非阻塞IO的特性,會等待SleepServer回應才處理下個request,就會變成下圖
處理的時間會直接變成三倍,因為主server與SleepServer溝通時都在等待SleepServer,完完全全的被「阻塞」了,
這就是非阻塞IO與阻塞IO實務上的狀況
再來是測試Node.js的會阻塞CPU的部分,
testCPU.js
也發送多個requests至主server,
而主server上面是重複計算x++十萬次並打印出他,
// server.js
async function request () {
console.log("let't go")
await rp('http://localhost:9999/sleep')
console.log('done')
}
app.get('/cpu', function (req, res) {
let x = 0
while (x <= 100000){
console.log(x)
x++
}
res.send('Hello World!');
});
// testCPU.js
async function request () {
console.log("let't go")
await rp(`http://localhost:${host}/cpu`)
console.log('done')
}
(async () => {
await Promise.all([
request(),
request(),
request(),
request(),
request(),
request(),
])
})()
執行起來會發現,done是一個一個出現的,主server每打印完十萬次才會回應給腳本,
我們來看看時序圖到底發生了什麼事
可以看到主server因為要處理這十萬次的x++,導致第二個request沒辦法馬上被處理到,凡事像這樣的演算,都是靠CPU而不是IO操作,所以沒辦法透過Event loop來解決,由於Node.js只有單執行緒,就會被這演算「阻塞」住,
這就是阻塞CPU實務上的狀況。
這是與一些朋友討論時,有些不同語言的人滿喜歡提的點,但我認為,其實沒有所謂的「誰弱誰強」,只是此類語言特性「適合」用在什麼情境。
CPU處理是單執行緒,而IO上是多執行緒,幫助了Node.js開法者可以不用管理thread pool就可以擁有多IO的功能,而一定程度上程度也解決了race condition的問題。
什麼事race condition?接幾週會再繼續深入介紹,但簡單來說,就是
如果多個執行緒對同一個進行演算,同時存取某變數,造成存取值不正常
更白話的來說,就是當三個人要同時吃一顆蘋果時,三人如果同時伸手去拿,結果發生靈異事件,三個人都拿到,並且咬了一口放回去,此時放回去的蘋果到底長怎樣...這是什麼巫術ヘ(;´Д`ヘ)
而Node.js在演算任何事情時,都只有單執行緒一個人,所以Node.js「演算上」不會發生這樣的靈異事件,
不過,「其他周邊IO可能會發生」,因為Event loop通知的thread pool是多執行緒,這些廚房大廚們還是可能發生race condition的,比如同時讀取一個文件,就有可能會有存取錯誤的問題。
但實際上這些race condtion的可能性並不出在Node.js「本身的演算上」,開發上的難度還是減少許多。
適合!接下幾篇就來介紹介紹他ヽ(`◇´)/
如有錯誤歡迎勘正指教,謝謝你的閱讀~
嗨
1.想請問NodeJS如果使用pm2 start app.js -i max去運行的話,這樣不是等於在每個thread上各運行一個process,請求會分別負載平衡導向至每個thread上執行,那這樣與golang使用全部thread的效能會有什麼差別嗎?
2.另外想請問 如果在server.js中的迴圈部分裡的console.log拿掉,感覺不出有CPU-bound的問題,是不是不要加console.log就不會有CPU-bound的問題呢?