Nodejs探祕:深入理解單執行緒實現高併發原理
前言
從Node.js進入我們的視野時,我們所知道的它就由這些關鍵字組成 事件驅動、非阻塞I/O、高效、輕量,它在官網中也是這麼描述自己的:
Node.js® is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient.
於是在我們剛接觸Nodejs時,會有所疑問:
1、為什麼在瀏覽器中執行的Javascript 能與作業系統進行如此底層的互動?
2、nodejs 真的是單執行緒嗎?
3、如果是單執行緒,他是如何處理高併發請求的?
4、nodejs 事件驅動是如何實現的?
等等。。。
看到這些問題,是否有點頭大,別急,帶著這些問題我們來慢慢看這篇文章。
架構一覽
上面的問題,都挺底層的,所以我們從 Node.js 本身入手,先來看看 Node.js 的結構:
image
-
Node.js 標準庫,這部分是由 Javascript 編寫的,即我們使用過程中直接能呼叫的 API。在原始碼中的 lib 目錄下可以看到。
-
Node bindings,這一層是 Javascript 與底層 C/C++ 能夠溝通的關鍵,前者通過 bindings 呼叫後者,相互交換資料。實現在 node.cc
-
這一層是支撐 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、資料壓縮等其他的能力。
與作業系統互動
舉個簡單的例子,我們想要開啟一個檔案,並進行一些操作,可以寫下面這樣一段程式碼:
var fs = require('fs');
fs.open('./test.txt', "w", function(err, fd) { //..do something});
這段程式碼的呼叫過程大致可描述為:lib/fs.js → src/node_file.cc → uv_fs
lib/fs.js
async function open(path, flags, mode) {
mode = modeNum(mode, 0o666);
path = getPathFromURL(path);
validatePath(path);
validateUint32(mode, 'mode');
return new FileHandle(
await binding.openFileHandle(pathModule.toNamespacedPath(path),
stringToFlags(flags),
mode, kUsePromises));
}
src/node_file.cc
static void Open(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
const int argc = args.Length();
if (req_wrap_async != nullptr) { // open(path, flags, mode, req)
AsyncCall(env, req_wrap_async, args, "open", UTF8, AfterInteger,
uv_fs_open, *path, flags, mode);
} else { // open(path, flags, mode, undefined, ctx)
CHECK_EQ(argc, 5);
FSReqWrapSync req_wrap_sync;
FS_SYNC_TRACE_BEGIN(open);
int result = SyncCall(env, args[4], &req_wrap_sync, "open",
uv_fs_open, *path, flags, mode);
FS_SYNC_TRACE_END(open); args.GetReturnValue().Set(result);
}
}
uv_fs
/* Open the destination file. */
dstfd = uv_fs_open(NULL,
&fs_req,
req>new_path,
dst_flags,
statsbuf.st_mode,
NULL); uv_fs_req_cleanup(&fs_req);
Node.js 深入淺出上的一幅圖:
具體來說,當我們呼叫 fs.open 時,Node.js 通過 process.binding 呼叫 C/C++ 層面的 Open 函式,然後通過它呼叫 Libuv 中的具體方法 uv_fs_open,最後執行的結果通過回撥的方式傳回,完成流程。
我們在 Javascript 中呼叫的方法,最終都會通過 process.binding 傳遞到 C/C++ 層面,最終由他們來執行真正的操作。Node.js 即這樣與作業系統進行互動。
單執行緒
在傳統web 服務模型中,大多都使用多執行緒來解決併發的問題,因為I/O 是阻塞的,單執行緒就意味著使用者要等待,顯然這是不合理的,所以建立多個執行緒來響應使用者的請求。
Node.js 對http 服務的模型:
Node.js的單執行緒指的是主執行緒是“單執行緒”,由主要執行緒去按照編碼順序一步步執行程式程式碼,假如遇到同步程式碼阻塞,主執行緒被佔用,後續的程式程式碼執行就會被卡住。實踐一個測試程式碼:
var http = require('http');
function sleep(time) {
var _exit = Date.now() + time * 1000;
while( Date.now() < _exit ) {}
return ;
}
var server = http.createServer(function(req, res{
sleep(10);
res.end('server sleep 10s');
});
server.listen(8080);
下面為程式碼塊的堆疊圖:
先將index.js的程式碼改成這樣,然後開啟瀏覽器,你會發現瀏覽器在10秒之後才做出反應,打出Hello Node.js。
JavaScript是解析性語言,程式碼按照編碼順序一行一行被壓進stack裡面執行,執行完成後移除然後繼續壓下一行程式碼塊進去執行。上面程式碼塊的堆疊圖,當主執行緒接受了request後,程式被壓進同步執行的sleep執行塊(我們假設這裡就是程式的業務處理),如果在這10s內有第二個request進來就會被壓進stack裡面等待10s執行完成後再進一步處理下一個請求,後面的請求都會被掛起等待前面的同步執行完成後再執行。
那麼我們會疑問:為什麼一個單執行緒的效率可以這麼高,同時處理數萬級的併發而不會造成阻塞呢?就是我們下面所說的--------事件驅動。
事件驅動/事件迴圈
Event Loop is a programming construct that waits for and dispatches events or messages in a program.
1、每個Node.js程序只有一個主執行緒在執行程式程式碼,形成一個執行棧(execution context stack)。
2、主執行緒之外,還維護了一個"事件佇列"(Event queue)。當用戶的網路請求或者其它的非同步操作到來時,node都會把它放到Event Queue之中,此時並不會立即執行它,程式碼也不會被阻塞,繼續往下走,直到主執行緒程式碼執行完畢。
3、主執行緒程式碼執行完畢完成後,然後通過Event Loop,也就是事件迴圈機制,開始到Event Queue的開頭取出第一個事件,從執行緒池中分配一個執行緒去執行這個事件,接下來繼續取出第二個事件,再從執行緒池中分配一個執行緒去執行,然後第三個,第四個。主執行緒不斷的檢查事件佇列中是否有未執行的事件,直到事件佇列中所有事件都執行完了,此後每當有新的事件加入到事件佇列中,都會通知主執行緒按順序取出交EventLoop處理。當有事件執行完畢後,會通知主執行緒,主執行緒執行回撥,執行緒歸還給執行緒池。
4、主執行緒不斷重複上面的第三步。
總結:
我們所看到的node.js單執行緒只是一個js主執行緒,本質上的非同步操作還是由執行緒池完成的,node將所有的阻塞操作都交給了內部的執行緒池去實現,本身只負責不斷的往返排程,並沒有進行真正的I/O操作,從而實現非同步非阻塞I/O,這便是node單執行緒和事件驅動的精髓之處了。
Node.js 中的事件迴圈的實現:
Node.js採用V8作為js的解析引擎,而I/O處理方面使用了自己設計的libuv,libuv是一個基於事件驅動的跨平臺抽象層,封裝了不同作業系統一些底層特性,對外提供統一的API,事件迴圈機制也是它裡面的實現。 在src/node.cc中:
Environment* CreateEnvironment(IsolateData* isolate_data,
Local<Context> context,
int argc,
const char* const* argv,
int exec_argc,
const char* const* exec_argv) {
Isolate* isolate = context>GetIsolate();
HandleScope handle_scope(isolate);
Context::Scope context_scope(context);
auto env = new Environment(isolate_data, context,
v8_platform.GetTracingAgent());
env->Start(argc, argv, exec_argc, exec_argv, v8_is_profiling);
return env;
}
這段程式碼建立了一個node執行環境,可以看到第三行的uv_default_loop(),這是libuv庫中的一個函式,它會初始化uv庫本身以及其中的default_loop_struct,並返回一個指向它的指標default_loop_ptr。 之後,Node會載入執行環境並完成一些設定操作,然後啟動event loop:
Environment* CreateEnvironment(IsolateData* isolate_data,
Local<Context> context,
int argc,
const char* const* argv,
int exec_argc,
const char* const* exec_argv) {
Isolate* isolate = context>GetIsolate();
HandleScope handle_scope(isolate);
Context::Scope context_scope(context);
auto env = new Environment(isolate_data, context,
v8_platform.GetTracingAgent());
env->Start(argc, argv, exec_argc, exec_argv, v8_is_profiling); return env;
}
more用來標識是否進行下一輪迴圈。 env->event_loop()會返回之前儲存在env中的default_loop_ptr,uv_run函式將以指定的UV_RUN_DEFAULT模式啟動libuv的event loop。如果當前沒有I/O事件也沒有定時器事件,則uv_loop_alive返回false。
Event Loop的執行順序:
根據Node.js官方介紹,每次事件迴圈都包含了6個階段,對應到 libuv 原始碼中的實現,如下圖所示:
-
timers 階段:這個階段執行timer(
setTimeout
、setInterval
)的回撥 -
I/O callbacks 階段:執行一些系統呼叫錯誤,比如網路通訊的錯誤回撥
-
idle, prepare 階段:僅node內部使用
-
poll 階段:獲取新的I/O事件, 適當的條件下node將阻塞在這裡
-
check 階段:執行
setImmediate()
的回撥 -
close callbacks 階段:執行
socket
的close
事件回撥。
核心函式uv_run:原始碼 核心原始碼
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
//首先檢查我們的loop還是否活著
//活著的意思代表loop中是否有非同步任務
//如果沒有直接就結束
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
//傳說中的事件迴圈,你沒看錯了啊!就是一個大while
while (r != 0 && loop->stop_flag == 0) {
//更新事件階段
uv__update_time(loop);
//處理timer回撥
uv__run_timers(loop);
//處理非同步任務回撥
ran_pending = uv__run_pending(loop);
//沒什麼用的階段
uv__run_idle(loop);
uv__run_prepare(loop);
//這裡值得注意了
//從這裡到後面的uv__io_poll都是非常的不好懂的
//先記住timeout是一個時間
//uv_backend_timeout計算完畢後,傳遞給uv__io_poll
//如果timeout = 0,則uv__io_poll會直接跳過
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
uv__io_poll(loop, timeout);
//就是跑setImmediate
uv__run_check(loop);
//關閉檔案描述符等操作
uv__run_closing_handles(loop);
if (mode == UV_RUN_ONCE) {
/* UV_RUN_ONCE implies forward progress: at least one callback must have
* been invoked when it returns. uv__io_poll() can return without doing
* I/O (meaning: no callbacks) when its timeout expires - which means we
* have pending timers that satisfy the forward progress constraint.
*
* UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
* the check.
*/
uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
/* The if statement lets gcc compile it to a conditional store. Avoids
* dirtying a cache line.
*/
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
程式碼中我已經寫得很詳細了,相信不熟悉c程式碼的各位也能輕易搞懂,沒錯,事件迴圈就是一個大while
而已!神祕的面紗就此揭開。
uv_iopoll階段
這個階段設計得非常巧妙,這個函式第二個引數是一個timeout
引數,而這個timeOut
由來自uv_backend_timeout
函式,我們進去一探究竟!
原始碼: https://github.com/libuv/libuv/blob/v1.x/src/unix/core.c
int uv_backend_timeout(const uv_loop_t* loop) {
if (loop->stop_flag != 0)
return 0;
if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))
return 0;
if (!QUEUE_EMPTY(&loop->idle_handles))
return 0;
if (!QUEUE_EMPTY(&loop->pending_queue))
return 0;
if (loop->closing_handles)
return 0;
return uv__next_timeout(loop);
}
原來是一個多步if函式,我們一個一個分析
- stop_flag:這個標記是 0的時候,意味著事件迴圈跑完這一輪就退出了,返回的時間是0
2. !uv__has_active_handles和!uv__has_active_reqs:看名字都知道,如果沒有任何的非同步任務(包括timer和非同步I/O),那timeOut時間一定就是0了
-
QUEUE_EMPTY(idle_handles)和QUEUE_EMPTY(pending_queue):非同步任務是通過註冊的方式放進了pending_queue中,無論是否成功,都已經被註冊,如果什麼都沒有,這兩個佇列就是空,所以沒必要等了。
-
closing_handles:我們的迴圈進入了關閉階段,沒必要等待了
以上所有條件判斷來判斷去,為的就是等這句話return uv__next_timeout(loop);這句話,告訴了uv__io_poll說:你到底停多久,接下來,我們繼續看這個神奇的uv__next_timeout是怎麼獲取時間的。
int uv__next_timeout(const uv_loop_t* loop) {
const struct heap_node* heap_node;
const uv_timer_t* handle;
uint64_t diff;
heap_node = heap_min((const struct heap*) &loop->timer_heap);
if (heap_node == NULL)
return -1; /* block indefinitely */
handle = container_of(heap_node, uv_timer_t, heap_node);
if (handle->timeout <= loop->time)
return 0;
//這句程式碼給出了關鍵性的指導
diff = handle->timeout - loop->time;
//不能大於最大的INT_MAX
if (diff > INT_MAX)
diff = INT_MAX;
return diff;
}
等待結束以後,就會進入check 階段.然後進入closing_handles階段,至此一個事件迴圈結束。
因為是原始碼解析,所以具體的我就不多說,大家只可以看文件:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
總結:
1、Nodejs與作業系統互動,我們在 Javascript 中呼叫的方法,最終都會通過 process.binding 傳遞到 C/C++ 層面,最終由他們來執行真正的操作。Node.js 即這樣與作業系統進行互動。
2、nodejs所謂的單執行緒,只是主執行緒是單執行緒,所有的網路請求或者非同步任務都交給了內部的執行緒池去實現,本身只負責不斷的往返排程,由事件迴圈不斷驅動事件執行。
3、Nodejs之所以單執行緒可以處理高併發的原因,得益於libuv層的事件迴圈機制,和底層執行緒池實現。
4、Event loop就是主執行緒從主執行緒的事件佇列裡面不停迴圈的讀取事件,驅動了所有的非同步回撥函式的執行,Event loop總共7個階段,每個階段都有一個任務佇列,當所有階段被順序執行一次後,event loop 完成了一個 tick。
本次給大家推薦一個免費的學習群,裡面概括移動應用網站開發,css,html,webpack,vue node angular以及面試資源等。
對web開發技術感興趣的同學,歡迎加入Q群:943129070,不管你是小白還是大牛我都歡迎,還有大牛整理的一套高效率學習路線和教程與您免費分享,同時每天更新視訊資料。
最後,祝大家早日學有所成,拿到滿意offer,快速升職加薪,走上人生巔峰。