強大的異步專家process.nextTick()

在閱讀mqtt.js源碼的時候,遇到一段很令人疑惑的代碼。
nextTickWork中調(diào)用process.nextTick(work),其中函數(shù)work又調(diào)用了nextTickWork。
這怎么這想遞歸呢?又有點像死循環(huán)?
到底是怎么回事啊,下面我們來系統(tǒng)性學(xué)習(xí)一下process.nextTick()。

writable._write = function (buf, enc, done) {
    completeParse = done
    parser.parse(buf)
    work() // 開始nextTick
}
function work () {
  var packet = packets.shift()
  if (packet) {
    that._handlePacket(packet, nextTickWork) // 注意這里
  } else {
    var done = completeParse
    completeParse = null
    if (done) done()
  }
}
function nextTickWork () {
  if (packets.length) {
    process.nextTick(work) // 注意這里
  } else {
    var done = completeParse
    completeParse = null
    done()
  }
}
  • 初識process.nextTick()
    • 語法(callback和可選args)
    • process.nextTick()知識點
    • process.nextTick()使用示例
      • 最簡單的例子
      • process.nextTick()可用于控制代碼執(zhí)行順序
      • process.nextTick()可完全異步化API
  • 如何理解process.nextTick()?
  • 為什么說process.nextTick()是更加強大的異步專家?
    • process.nextTick()比setTimeout()更嚴(yán)格的延遲調(diào)用
    • process.nextTick()解決的實際問題
  • 為什么要用process.nextTick()?
    • 允許用戶處理error,清除不需要的資源,或者在事件循環(huán)前再次嘗試請求
    • 有時確保callback在call stack unwound(解除)后,event loop繼續(xù)循環(huán)前 調(diào)用
  • 回顧一下

初識process.nextTick()

語法(callback和可選args)

process.nextTick(callback[, ...args])
  • callback 回調(diào)函數(shù)
  • args 調(diào)用callback時額外傳的參數(shù)

process.nextTick()知識點

  • process.nextTick()會將callback添加到”next tick queue“
  • ”next tick queue“會在當(dāng)前JavaScript stack執(zhí)行完成后,下一次event loop開始執(zhí)行前按照FIFO出隊
  • 如果遞歸調(diào)用process.nextTick()可能會導(dǎo)致一個無限循環(huán),需要去適時終止遞歸。
  • process.nextTick()可用于控制代碼執(zhí)行順序。保證方法在對象完成constructor后但是在I/O發(fā)生前調(diào)用。
  • process.nextTick()可完全異步化API。API要么100%同步要么100%異步是很重要的,可以通過process.nextTick()去達到這種保證

process.nextTick()使用示例

  • 最簡單的例子
  • process.nextTick()對于API的開發(fā)很重要
最簡單的例子
console.log('start');
process.nextTick(() => {
  console.log('nextTick callback');
});
console.log('scheduled');
// start
// scheduled
// nextTick callback
process.nextTick()可用于控制代碼執(zhí)行順序

process.nextTick()可用于賦予用戶一種能力,去保證方法在對象完成constructor后但是在I/O發(fā)生前調(diào)用。

function MyThing(options) {
  this.setupOptions(options);
  process.nextTick(() => {
    this.startDoingStuff();
  });
}
const thing = new MyThing();
thing.getReadyForStuff(); // thing.startDoingStuff() 在準(zhǔn)備好之后再調(diào)用,而不是在初始化就調(diào)用
API要么100%同步要么100%異步時很重要的

API要么100%同步要么100%異步是很重要的,可以通過process.nextTick()去使得一個API完全異步化達到這種保證。

// 可能是同步,可能是異步的API
function maybeSync(arg, cb) {
  if (arg) {
    cb();
    return;
  }
  fs.stat('file', cb);
}
// maybeTrue可能為false可能為true,所以foo(),bar()的執(zhí)行順序無法保證。
const maybeTrue = Math.random() > 0.5;
maybeSync(maybeTrue, () => {
  foo();
});
bar();

如何使得API完全是一個async的API呢?或者說如何保證foo()在bar()之后調(diào)用呢?
通過process.nextTick()完全異步化。

// 完全是異步的API
function definitelyAsync(arg, cb) {
  if (arg) {
    process.nextTick(cb);
    return;
  }
  fs.stat('file', cb);
}

如何理解process.nextTick()

你也許會發(fā)現(xiàn)process.nextTick()不會在代碼中出現(xiàn),即使它是異步API的一部分。這是為什么呢?因為process.nextTick()不是event loop的技術(shù)部分。取而代之的是,nextTickQueue會在當(dāng)前的操作完成后執(zhí)行,不考慮event loop的當(dāng)前階段。在這里,operation的定義是指從底層的C/C++處理程序到處理需要執(zhí)行的JavaScript的轉(zhuǎn)換。

回過頭來看我們的程序,任何階段你調(diào)用process.nextTick(),所有傳遞進process.nextTick()的callback會在event loop繼續(xù)前完成解析。這會造成一些糟糕的情況,通過建立一個遞歸的process.nextTick()調(diào)用,它允許你“starve”你的I/O。,這樣可以使得event loop不到達poll階段。

為什么說process.nextTick()是更加強大的異步專家?

process.nextTick()比setTimeout()更精準(zhǔn)的延遲調(diào)用

為什么說“process.nextTick()比setTimeout()更精準(zhǔn)的延遲調(diào)用”呢?
不要著急,帶著疑問去看下文即可??炊湍苷业酱鸢?。

為什么Node.js要設(shè)計這種遞歸的process.nextTick()呢 ?這是因為Node.js的設(shè)計哲學(xué)的一部分是API必須是async的,即使它沒有必要。 看下下面的例子:

function apiCall(arg, callback) {
    if(typeof arg !== 'string'){
        return process.nextTick(callback, new TypeError('argument should be string'));
    }
}

代碼片段做了argument的檢查,如果它不是string類型的話,它會將一個error傳遞進callback中。這個API最近進行了更新,允許將參數(shù)傳遞到process.nextTick(),從而允許在callback之后傳遞的任何參數(shù)作為回調(diào)的參數(shù)進行傳遞,這樣就不用嵌套函數(shù)了。

我們現(xiàn)在做的是將一個error傳遞到user,但是必須在我們允許執(zhí)行的代碼執(zhí)行完之后。通過使用process.nextTick(),我們可以保證apiCall總是在用戶代碼的其余部分和允許事件循環(huán)繼續(xù)之前運行它的callback。為了實現(xiàn)這一點,JS call stack可以被展開,然后immediately執(zhí)行提供的回調(diào),從而允許一個人遞歸調(diào)用process.nextTick()而不至于拋出RangeError: Maximum call stack size exceeded from v8.

一句話概括的話就是:process.nextTick()可以保證我們要執(zhí)行的代碼會正常執(zhí)行,最后再拋出這個error。這個操作是setTimeout()無法做到的,因為我們并不知道執(zhí)行那些代碼需要多長時間。

是怎么做到process.nextTick(callback)比setTimeout()更嚴(yán)格的延遲調(diào)用的呢?
process.nextTick(callback)可以保證在這一次事件循環(huán)的call stack 解除(unwound)后,在下一次事件循環(huán)前,調(diào)用callback。

可以把原因再講得詳細一點嗎?

process.nextTick()會在這一次event loop的call stack清空后(下一次event loop開始前)再調(diào)用callback。而setTimeout()是并不知道什么時候call stack清空的。我們setTimeout(cb, 1000),可能1s后,由于種種原因call 棧中還留存了幾個函數(shù)沒有調(diào)用,調(diào)大到10秒又很不合適,因為它可能1.1秒就執(zhí)行完了。

相信有一定開發(fā)經(jīng)驗的同學(xué)一看就懂,一看就知道process.nextTick()的強大了。
心里默念:“終于不用調(diào)坑爹的setTimeout延遲參數(shù)了!”

強大的process.nextTick()解決的實際問題

這個哲學(xué)會導(dǎo)致一些潛在問題。下面來看下這段代碼:

let bar;
//  它是異步,但是同步調(diào)用callback
function someAsyncApiCall(callback) { callback(); }
// callback在someAsyncApiCall完成前調(diào)用
someAsyncApiCall(() => {
  // 因為someAsyncApiCall還沒有完成,bar還未賦值
  console.log('bar', bar); // undefined
});
bar = 1;

用戶定義了有一個異步簽名的someAsyncApiCall(),但是它實際上同步執(zhí)行了。當(dāng)someAsyncApiCall()調(diào)用的時候,內(nèi)部的callback在異步操作還沒完成前就調(diào)用了,callback嘗試獲得bar的引用,但是作用域內(nèi)是沒有這個變量的,因為script還沒有執(zhí)行到bar = 1這一步。

有什么辦法可以保證在賦值之后再調(diào)用這個函數(shù)呢?

通過將callback傳遞進process.nextTick(),script可以成功執(zhí)行,并且可以訪問到所有變量和函數(shù)等等,并且在callback調(diào)用之前已經(jīng)初始化好。 它擁有允許不允許事件循環(huán)繼續(xù)的優(yōu)點。對于用戶在event loop想要繼續(xù)運行之前alert一個error是很有用的。

下面是通過process.nextTick()改進的上面的代碼:

let bar;
function someAsyncApiCall(callback) {
    process.nextTick(callback);
}
someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});
bar = 1;

還有一個真實世界的例子:

const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});

當(dāng)我們傳遞一個端口號進去時,端口號會被立刻綁定。所以'listening' callback可以被立即調(diào)用。問題是.on('listening');這個callback可能還沒設(shè)置呢?這要怎么辦?

為了做到在精準(zhǔn)無誤的監(jiān)聽到listen的動作,將對‘listening’事件的監(jiān)聽操作,隊列到nextTick(),從而可以允許代碼完全運行完畢。 這可以使得用戶設(shè)置任何他們想要的事件。

為什么要用process.nextTick()?

  • 允許用戶處理error,清除不需要的資源,或者在事件循環(huán)前再次嘗試請求
  • 有時確保callback在call stack unwound(解除)后,event loop繼續(xù)循環(huán)前 調(diào)用

允許用戶處理error,清除不需要的資源,或者在事件循環(huán)前再次嘗試請求

這里有一個匹配用戶期望的例子。

const server = net.createServer();
server.on('connection', (conn) => { });

server.listen(8080);
server.on('listening', () => { });

listen()在event.loop循環(huán)的開始運行,但是listening callback被放置在setImmediate()中。除非傳入hostname,否則立即綁定端口。event loop在處理的時候,它必須在poll階段,這也就是意味著沒有機會接收到連接,從而允許在偵聽listen事件前觸發(fā)connection事件。

有時確保callback在call stack unwound(解除)后,event loop繼續(xù)循環(huán)前 調(diào)用

再來看一個例子:
運行一個繼承了EventEmitter的function constructor,它想在constructor內(nèi)部發(fā)出一個'event'事件。

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
  this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!'); // nothing happens
});

無法在constructor內(nèi)理解emit一個event,因為script不會運行到用戶監(jiān)聽event響應(yīng)callback的位置。所以在constructor內(nèi)部,可以使用process.nextTick設(shè)置一個callback在constructor完成之后emit這個event,所以最終的代碼如下:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
  // 一旦分配了handler處理程序,就使用process.nextTick()發(fā)出這個事件
  process.nextTick(() => {
    this.emit('event');
  });
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!'); // an event occurred!'
});

回顧一下

回過頭來看下mqtt.js用于接收消息的message event源碼中的process.nextTick()

process.nextTick()確保work函數(shù)準(zhǔn)確在這一次call stack清空后,下一次event loop開始前調(diào)用。

writable._write = function (buf, enc, done) {
    completeParse = done
    parser.parse(buf)
    work() // 開始nextTick
}
function work () {
  var packet = packets.shift()
  if (packet) {
    that._handlePacket(packet, nextTickWork) // 注意這里
  } else {
    // 中止process.nextTick()的遞歸
    var done = completeParse
    completeParse = null
    if (done) done()
  }
}
function nextTickWork () {
  if (packets.length) {
    process.nextTick(work) // 注意這里
  } else {
   // 中止process.nextTick()的遞歸
    var done = completeParse
    completeParse = null
    done()
  }
}

通過對process.nextTick()的學(xué)習(xí)以及對源碼的理解,我們得出:
流寫入本地執(zhí)行work(),若接收到有效的數(shù)據(jù)包,開始process.nextTick()遞歸。

  • 開始nextTick的條件:if(packet)/if (packets.length) 也就是說有接收到websocket包時開始。
  • 遞歸nextTick的過程:work()->nextTickWork()->process.nextTick(work)。
  • 結(jié)束nextTick的條件:packet為空或者packets為空,通過completeParse=null,done()結(jié)束遞歸。
  • 如果對work不加process.nextTick會怎樣?
function nextTickWork () {
  if (packets.length) {
    work() // 注意這里
  }
}

會造成當(dāng)前的event loop永遠不會中止,一直處于阻塞狀態(tài),造成一個無限循環(huán)。
正是因為有了process.nextTick(),才能確保work函數(shù)準(zhǔn)確在這一次call stack清空后,下一次event loop開始前調(diào)用。

參考鏈接:

期待和大家交流,共同進步,歡迎大家加入我創(chuàng)建的與前端開發(fā)密切相關(guān)的技術(shù)討論小組:

image

努力成為優(yōu)秀前端工程師!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容