Node子程式async/await方法不正常執行的思考和解決

pagecao發表於2018-10-15

前段時間,我做了一個node模組node-multi-worker ,希望通過這個模組讓node能夠脫離單執行緒的限制,具體的使用可以看一下上面的連結。其思路就是註冊任務後,分出子程式,然後在主程式需要執行任務時,向reactor子程式傳送命令,而reactor收到命令後分配到worker子程式在執行完成後返回結果到主程式。這篇文章主要是為了跟大家分享一下我在開發過程中,遇到的一個問題,如何解決以及對相關知識的一個挖掘。

不執行的async/await

在第一次完成了該工程後,我做了一些簡單的測試,比如在子程式執行的方法中做一些加減乘除或者字元運算,當然都是沒問題的。而對於一些非同步的情況,我通過bluebird的處理也能夠處理,於是我開始嘗試起了aysnc/await的情況,結果發現這個的執行只要遇到await,await後面的語句能夠執行,但是在下面的語句就再也不能執行了。這個情況頓時讓我摸不著了頭腦,我一度以為是v8核心中對於這種子程式的情況不支援(確實v8對你fork出子程式的支援是有問題的,不過跟這個問題沒關,具體在模組的Readme中提到了),於是看了v8內部對async/await的實現,並沒有什麼發現有跟子程式有什麼關係,但是卻讓我的思路多了一條路,原來我之前用的Promise一直是bluebird的,並沒有使用js原生的Promise,於是我通過原生的promise再來執行之前使用bluebird做的非同步呼叫,這次果然也是卡主了,甚至是這樣不是非同步的操作呼叫了Promise都會卡主:

new Promise(function(resolve,reject){
        resolve(1);
}).then(function(data){
	console.log(data);
})
複製程式碼

這個時候我意識到,這個問題可能是在Promise身上,於是我查了Promise的規範文件,這上面有這樣一句話:

promise.then(onFulfilled, onRejected)

2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code. [3.1].

Here “platform code” means engine, environment, and promise implementation code. In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack. This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate, or with a “micro-task” mechanism such as MutationObserver or process.nextTick. Since the promise implementation is considered platform code, it may itself contain a task-scheduling queue or “trampoline” in which the handlers are called.
複製程式碼

這段規範比較晦澀,不過可以總結出一點,setTimeout和setImmediate屬於macro-task,而promise的決議回撥以及process.nextTick的回撥則是在micro-task中執行的,於是我在v8.h中搜尋關於microtask的關鍵詞,果然被我找到了一個方法Isolate::RunMicrotasks,這個時候我趕緊在我的程式碼中,也就是子程式begin_uv_run函式改成這樣:

bool more;
do {
    more = uv_run(loop, UV_RUN_ONCE);
    if (more == false) {
        more = uv_loop_alive(loop);
        if (uv_run(loop, UV_RUN_NOWAIT) != 0)
            more = true;
    }
    Isolate::GetCurrent()->RunMicrotasks();
} while (more == true);
_exit(0);
複製程式碼

這個時候,await後面的語句也執行了起來,Promise也不會出現then後的語句不執行的情況了,但卻發現process.nextTick還是不能執行,於是我到了node的核心中尋求結果,看了一番恍然大悟,原來node的nextTick是自己實現的,並不在micro-task中,只是通過程式碼的方式實現了標準中的執行順序。下面我們通過node的原始碼來解釋一番這其中的問題以及我通過對這些的瞭解後做出的最後解決方案。

node中對macrotask和microtask的呼叫實現

要了解這個我們首先來看看libuv的啟動函式uv_ru那種的程式碼:

...
uv__update_time(loop);
uv__run_timers(loop);//處理timer

...

uv__io_poll(loop, timeout);//處理io事件
uv__run_check(loop); //處理check回撥
...

if (mode == UV_RUN_ONCE) {  						uv__update_time(loop);
	uv__run_timers(loop);//處理timer
}
複製程式碼

可以從上面看到,主要是三個大事件的順序,timer,io,check這樣的順序,timer當然就是呼叫我們setTimeout註冊回撥所用,io自然就是處理我們註冊的一些非同步io任務,比如fs的讀取檔案,以及網路請求這些任務。而check中通過src/env.cc中的程式碼

uv_check_start(&immediate_check_handle_, CheckImmediate);
複製程式碼

註冊了呼叫setImmediate回撥方法的CheckImmediate函式。好了現在,setTimeoutsetImmediate都找到了出處,那process.nextTickPromise.then呢?這個答案就在uv__io_poll中,因為我們所有的io的回撥函式最後都是通過 src/node.cc中的函式InternalMakeCallback完成的,在其中通過這樣的語句來完成整個回撥函式的呼叫過程:

...
InternalCallbackScope scope(env, recv, asyncContext);
...
ret = callback->Call(env->context(), recv, argc, argv);
...
scope.Close();
複製程式碼

其中的scope.Close()是執行process.nextTickPromise.then的關鍵,因為它會執行到程式碼:

....
 if (IsInnerMakeCallback()) {
 	//上一個scope還沒釋放不會執行
	return;
}
Environment::TickInfo* tick_info = env_->tick_info();

if (tick_info->length() == 0) {
	//沒有tick任務執行microtasks後返回
	env_->isolate()->RunMicrotasks();
}
...
if (tick_info->length() == 0) {
	tick_info->set_index(0);
	return;
}
...
if (env_->tick_callback_function()->Call(process, 0, nullptr).IsEmpty()) {
	//執行tick任務
	failed_ = true;
}
複製程式碼

從上面我們可以知道,在io任務註冊的callback執行完了以後便會呼叫tick任務和microtasks,其中env_->tick_callback_function()就是lib/internal/process/next_tick.js中的函式_tickCallback,其程式碼:

do {
	while (tickInfo[kIndex] < tickInfo[kLength]) {
		...
		_combinedTickCallback(args, callback);//執行註冊的回撥函式
		...
	}
	...
	_runMicrotasks();//執行microtasks
	...
}while (tickInfo[kLength] !== 0);
複製程式碼

可以看到在執行完process.nextTick註冊的所有回撥後,就會執行_runMicrotasks()來執行microtask。這裡我不禁產生了疑惑,回撥我也執行了啊,為何沒有執行process.nextTick和microtask,唯一不會執行的情況只能在這裡:

if (IsInnerMakeCallback()) {
 	//上一個scope還沒釋放不會執行
	return;
}
複製程式碼

帶著這個明確的目的,我找到了原因所在,在src/node.cc中通過以下程式碼來執行js程式碼的:

{
	Environment::AsyncCallbackScope callback_scope(&env);
	env.async_hooks()->push_async_ids(1, 0);
	LoadEnvironment(&env); //在這裡執行js
	env.async_hooks()->pop_async_id(1);
}
複製程式碼

在AsyncCallbackScope物件的建構函式中會執行如下語句:

env_->makecallback_cntr_++;
複製程式碼

IsInnerMakeCallback判斷標準就是env_->makecallback_cntr_>1,在callback_scope析構時會將該值復原,但是我們的子程式在js執行中就分配出來了,並且通過uv_run後直接就exit所以並沒有機會析構該物件,當然無法呼叫tick函式和microtask。不過肯定有讀者現在產生疑惑了,那假如我不註冊io事件 只執行process.nextTickPromise.then呢,從上面講解來看豈不是不能執行,但是我明明執行了的啊,莫急各位看官,因為還有個地方我還沒說到,就是node的js啟動檔案lib/internal/bootstrap_node.js中的命令列互動式啟動使用的evalScript方法還是直接檔案啟動的runMain中都會在最後執行到_tickCallback,也符合js語句執行也是macrotask的一種,在執行完js語句後第一時間執行microtask的原則。所以這個問題的結果就不言而喻了:

(function test() {
	setTimeout(function() {console.log(4)}, 0);
	new Promise(function executor(resolve) {
    	console.log(1);
    	for( var i=0 ; i<10000 ; i++ ) {
        	i == 9999 && resolve();
    	}
    	console.log(2);
	}).then(function() {
    	console.log(5);
	});
	console.log(3);
})()
複製程式碼

首先js先執行所以肯定1,2,3是按順序執行,而js執行到最後一步就是_tickCallback,所以就是5,而執行完了js以後uv_run,自然就是執行timer,當然在node中setTimeout的時間為0時,實際為1,所以在第一次呼叫uv__run_timers(loop);不一定會執行,不過不影響這個函式的結果為 1,2,3,5,4。而如果是這樣:

(function test() {
	setTimeout(function() {console.log(1)}, 0);
	setImmediate(function() {console.log(2)});
})()
複製程式碼

順序就是不確定的,理由已經講過了就是第一次timer的呼叫對time為1的執行與否是不確定的。

清楚了為什麼不執行的原因後解決該問題的方法就已經出來了,有兩個方法,一個是等js執行完了以後,再分出子程式,可以通過註冊了一個timer任務來做,另外一個自然就是在裡面分出,但是自己來做 tick,我選擇了第二個方式,比較簡單粗暴,直接通過在子程式的函式中這樣寫:

bool more;
do {
    more = uv_run(loop, UV_RUN_ONCE);
    if (more == false) {
        more = uv_loop_alive(loop);
        if (uv_run(loop, UV_RUN_NOWAIT) != 0)
            more = true;
    }
    v8::HandleScope scope(globalIsolate);
    Local<Object> global = globalIsolate->GetCurrentContext()->Global();
    Local<Object> process = Local<Object>::Cast(global->ToObject()->Get(String::NewFromUtf8(globalIsolate, "process")));
    Local<Function> tickFunc = Local<Function>::Cast(process->ToObject()->Get(String::NewFromUtf8(globalIsolate, "_tickCallback")));
    tickFunc->Call(process,0,NULL);
} while (more == true);
_exit(0);
複製程式碼

這樣就不會再有問題了,通過_tickCallback將tick回撥和microtask都執行了。

總結

通過這個模組的開發,瞭解到了microtaskmacrotask的概念並清晰了了解了各個方法的執行順序,也算是收穫滿滿了。有想法去實行才能獲得成長真是真知灼見啊。

相關文章