最近在做一個支持多進程請求的 Node 服務,要支持多併發請求,而且請求要按先後順序串聯同步執行返回結果。
對,這需求就是這麼奇琶,業務場景也是那麼奇琶。
需求是完成了,爲了對 Node.js 高併發請求原理有更深一些的理解,特意寫一篇文章來鞏固一下相關的知識點。
問題
Node.js 由這些關鍵字組成:事件驅動、非阻塞I/O、高效、輕量。
於是在我們剛接觸 Node.js 時,會有所疑問:
-
爲什麼在瀏覽器中運行的 JavaScript 能與操作系統進行如此底層的交互?
-
Node 真的是單線程嗎?
-
如果是單線程,他是如何處理高併發請求的?
-
Node 事件驅動是如何實現的?
下來我們一起來解祕這是怎麼一回事!
架構一覽
上面的問題,都挺底層的,所以我們從 Node.js 本身入手,先來看看 Node.js 的結構。
-
Node.js 標準庫,這部分是由 Javascript編寫的,即我們使用過程中直接能調用的 API。在源碼中的 lib 目錄下可以看到。
-
Node bindings,這一層是 Javascript 與底層 C/C++ 能夠溝通的關鍵,前者通過 bindings 調用後者,相互交換數據。
-
第三層是支撐 Node.js 運行的關鍵,由 C/C++ 實現。
-
V8:Google 推出的 Javascript VM,也是 Node.js 爲什麼使用的是 JavaScript 的關鍵,它爲 JavaScript 提供了在非瀏覽器端運行的環境,它的高效是 Node.js 之所以高效的原因之一。
-
Libuv:它爲 Node.js 提供了跨平臺,線程池,事件池,異步 I/O 等能力,是 Node.js 如此強大的關鍵。
-
C-ares:提供了異步處理 DNS 相關的能力。
-
http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、數據壓縮等其他的能力。
單線程、異步
-
單線程:所有任務需要排隊,前一個任務結束,纔會執行後一個任務。如果前一個任務耗時很長,後一個任務就不得不一直等着。Node 單線程指的是 Node 在執行程序代碼時,主線程是單線程。
-
異步:主線程之外,還維護了一個"事件隊列"(Event queue)。當用戶的網絡請求或者其它的異步操作到來時,Node 都會把它放到 Event Queue 之中,此時並不會立即執行它,代碼也不會被阻塞,繼續往下走,直到主線程代碼執行完畢。
注:
-
JavaScript 是單線程的,Node 本身其實是多線程的,只是 I/O 線程使用的 CPU 比較少;還有個重要的觀點是,除了用戶的代碼無法並行執行外,所有的 I/O (磁盤 I/O 和網絡 I/O) 則是可以並行起來的。 -
libuv 線程池默認打開 4 個,最多打開 128 個 線程。
事件循環
Nodejs 所謂的單線程,只是主線程是單線程。
-
主線程運行 V8 和 JavaScript -
多個子線程通過 事件循環
被調度
可以抽象爲:主線程對應於老闆,正在工作。一旦發現有任務可以分配給職員(子線程)來做,將會把任務分配給底下的職員來做。同時,老闆繼續做自己的工作,等到職員(子線程)把任務做完,就會通過事件把結果回調給老闆。老闆又不停重複處理職員(子線程)子任務的完成情況。
老闆(主線程)給職員(子線程)分配任務,當職員(子線程)把任務做完之後,通過事件把結果回調給老闆。老闆(主線程)處理回調結果,執行相應的 JavaScript。
更具體的解釋請看下圖:
1、每個 Node.js 進程只有一個主線程在執行程序代碼,形成一個執行棧(execution context stack)。
2、Node.js 在主線程裏維護了一個"事件隊列"(Event queue),當用戶的網絡請求或者其它的異步操作到來時,Node 都會把它放到 Event Queue之中,此時並不會立即執行它,代碼也不會被阻塞,繼續往下走,直到主線程代碼執行完畢。
3、主線程代碼執行完畢完成後,然後通過 Event Loop,也就是事件循環機制,檢查隊列中是否有要處理的事件,這時要分兩種情況:如果是非 I/O 任務,就親自處理,並通過回調函數返回到上層調用;如果是 I/O 任務,就從 線程池
中拿出一個線程來處理這個事件,並指定回調函數,當線程中的 I/O 任務完成以後,就執行指定的回調函數,並把這個完成的事件放到事件隊列的尾部,線程歸還給線程池,等待事件循環。當主線程再次循環到該事件時,就直接處理並返回給上層調用。這個過程就叫 事件循環 (Event Loop)
。
4、期間,主線程不斷的檢查事件隊列中是否有未執行的事件,直到事件隊列中所有事件都執行完了,此後每當有新的事件加入到事件隊列中,都會通知主線程按順序取出交 Event Loop 處理。
優缺點
Nodejs 的優點:I/O 密集型處理是 Nodejs 的強項,因爲 Nodejs 的 I/O 請求都是異步的(如:sql 查詢請求、文件流操作操作請求、http 請求...)
Nodejs 的缺點:不擅長 cpu 密集型的操作(複雜的運算、圖片的操作)
總結
1、Nodejs 與操作系統交互,我們在 JavaScript 中調用的方法,最終都會通過 process.binding 傳遞到 C/C++ 層面,最終由他們來執行真正的操作。Node.js 即這樣與操作系統進行互動。
2、Nodejs 所謂的單線程,只是主線程是單線程,所有的網絡請求或者異步任務都交給了內部的線程池去實現,本身只負責不斷的往返調度,由事件循環不斷驅動事件執行。
3、Nodejs 之所以單線程可以處理高併發的原因,得益於 libuv 層的事件循環機制,和底層線程池實現。
4、Event loop 就是主線程從主線程的事件隊列裏面不停循環的讀取事件,驅動了所有的異步回調函數的執行,Event loop 總共 7 個階段,每個階段都有一個任務隊列,當所有階段被順序執行一次後,event loop 完成了一個 tick。
參考文章:Nodejs探祕:深入理解單線程實現高併發原理
串聯同步執行併發請求
就像上面說的:Node.js 在主線程裏維護了一個"事件隊列"(Event queue),當用戶的網絡請求或者其它的異步操作到來時,Node 都會把它放到 Event Queue之中,此時並不會立即執行它,代碼也不會被阻塞,繼續往下走,直到主線程代碼執行完畢。
所以要串聯同步執行併發請求的關鍵在於維護一個隊列,隊列的特點是 先進先出
,按隊列裏面的順序執行就可以達到串聯同步執行併發請求的目的。
方案
-
根據每個請求的 uniqueId 變量作爲唯一令牌 -
隊列裏面維護一個結果數組和一個執行隊列,把執行隊列完成的 令牌與結果
存儲在結果數組裏面 -
根據唯一令牌,一直去獲取執行完成的結果,間隔 200 毫秒,超時等待時間爲 10 分鐘 -
一直等待並獲取結果,等待到有結果時,才返回給請求;並根據令牌把結果數組裏面相應的項刪除
隊列
代碼:
class Recorder {
private list: any[];
private queueList: any[];
private intervalTimer;
constructor() {
this.list = [];
this.queueList = [];
this.intervalTimer = null;
}
// 根據 id 獲取任務結果
public get(id: string) {
let data;
console.log('this.list: ', this.list);
let index;
for (let i = 0; i < this.list.length; i++) {
const item = this.list[i];
if (id === item.id) {
data = item.data;
index = i;
break;
}
}
// 刪除獲取到結果的項
if (index !== undefined) {
this.list.splice(index, 1);
}
return data;
}
public clear() {
this.list = [];
this.queueList = [];
}
// 添加項
public async addQueue(item: any) {
this.queueList.push(item);
}
public async runQueue() {
clearInterval(this.intervalTimer);
if (!this.queueList.length) {
// console.log('隊列執行完畢');
return;
}
// 取出隊列裏面的最後一項
const item = this.queueList.shift();
console.log('item: ', item);
// 執行隊列的回調
const data = await item.callback();
console.log('回調執行完成: ', data);
// 把結果放進 結果數組
this.list.push({ id: item.id, data });
}
public interval() {
clearInterval(this.intervalTimer);
this.intervalTimer = setInterval(async () => {
clearInterval(this.intervalTimer);
// 一直執行裏面的任務
await this.runQueue();
this.interval();
}, 200);
}
}
const recorder = new Recorder();
recorder.interval();
export default recorder;
服務
下面模擬一個請求端口的的 Node 服務。
代碼:
const Koa = require('koa')
const Router = require('koa-router')
const cuid = require('cuid');
const bodyParser = require('koa-bodyparser')
import recorder from "./libs/recorder";
const MAX_WAITING_TIME = 60 * 5; // 最大等待時長
// web服務端口
const SERVER_PORT: number = 3000;
const app = new Koa();
app.use(bodyParser());
const router = new Router();
/**
* 程序睡眠
* @param time 毫秒
*/
const timeSleep = (time: number) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve("");
}, time);
});
};
/**
* 程序睡眠
* @param second 秒
*/
const sleep = (second: number) => {
return timeSleep(second * 1000);
};
router.post("/getPort", async (ctx, next) => {
const { num } = ctx.request.body;
const uniqueId = cuid();
console.log('uniqueId: ', uniqueId);
recorder.addQueue({
id: uniqueId,
callback: getPortFun(num)
});
let waitTime = 0;
while (!ctx.body) {
await sleep(0.2);
console.log('1');
const data: any = recorder.get(uniqueId);
if (data) {
ctx.body = {
code: 0,
data: data,
msg: 'success'
};
}
waitTime++;
// 超過最大時間就返回一個結果
if (waitTime > MAX_WAITING_TIME) {
ctx.body = {};
}
}
});
// 返回一個函數
function getPortFun(num) {
return () => {
return new Promise((resolve) => {
// 模擬異步程序
setTimeout(() => {
console.log(`num${num}: `, num);
resolve(num * num);
}, num * 1000);
});
};
}
app.use(router.routes()).use(router.allowedMethods());
app.listen(SERVER_PORT);
最後
最近狀態很差勁,上班工作多人的時候還好,但是自己一個人的時候,心情常常不能平靜,心好亂,有點心慌 😥
心情不好時,啥都不想幹,心態有點扭轉不過來,集中不了注意力,所以最近想專心寫篇原創技術文章都不行,想重構自己開源的 blog 項目也不行,很糟糕 😭
所以最近的原創技術文章有點難產了 😥
心態急需調整,週末想出去玩,放鬆一下自己,找回那個鬥志滿滿的真我纔行,唉。
可以加貓哥的 wx:CB834301747 ,一起閒聊前端。
微信搜 “前端GitHub”,回覆 “電子書” 即可以獲得 160 本前端精華書籍哦。
往期精文
本文分享自微信公衆號 - 全棧修煉(QuanZhanXiuLian)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。