强大的异步专家process.nextTick()

在阅读mqtt.js源码的时候,遇到一段很令人疑惑的代码。
nextTickWork中调用process.nextTick(work),其中函数work又调用了nextTickWork。
这怎么这想递归呢?又有点像死循环?
到底是怎么回事啊,下面我们来系统性学习一下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()可用于控制代码执行顺序
      • process.nextTick()可完全异步化API
  • 如何理解process.nextTick()?
  • 为什么说process.nextTick()是更加强大的异步专家?
    • process.nextTick()比setTimeout()更严格的延迟调用
    • process.nextTick()解决的实际问题
  • 为什么要用process.nextTick()?
    • 允许用户处理error,清除不需要的资源,或者在事件循环前再次尝试请求
    • 有时确保callback在call stack unwound(解除)后,event loop继续循环前 调用
  • 回顾一下

初识process.nextTick()

语法(callback和可选args)

process.nextTick(callback[, ...args])
  • callback 回调函数
  • args 调用callback时额外传的参数

process.nextTick()知识点

  • process.nextTick()会将callback添加到”next tick queue“
  • ”next tick queue“会在当前JavaScript stack执行完成后,下一次event loop开始执行前按照FIFO出队
  • 如果递归调用process.nextTick()可能会导致一个无限循环,需要去适时终止递归。
  • process.nextTick()可用于控制代码执行顺序。保证方法在对象完成constructor后但是在I/O发生前调用。
  • process.nextTick()可完全异步化API。API要么100%同步要么100%异步是很重要的,可以通过process.nextTick()去达到这种保证

process.nextTick()使用示例

  • 最简单的例子
  • process.nextTick()对于API的开发很重要
最简单的例子
console.log('start');
process.nextTick(() => {
  console.log('nextTick callback');
});
console.log('scheduled');
// start
// scheduled
// nextTick callback
process.nextTick()可用于控制代码执行顺序

process.nextTick()可用于赋予用户一种能力,去保证方法在对象完成constructor后但是在I/O发生前调用。

function MyThing(options) {
  this.setupOptions(options);
  process.nextTick(() => {
    this.startDoingStuff();
  });
}
const thing = new MyThing();
thing.getReadyForStuff(); // thing.startDoingStuff() 在准备好之后再调用,而不是在初始化就调用
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()的执行顺序无法保证。
const maybeTrue = Math.random() > 0.5;
maybeSync(maybeTrue, () => {
  foo();
});
bar();

如何使得API完全是一个async的API呢?或者说如何保证foo()在bar()之后调用呢?
通过process.nextTick()完全异步化。

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

如何理解process.nextTick()

你也许会发现process.nextTick()不会在代码中出现,即使它是异步API的一部分。这是为什么呢?因为process.nextTick()不是event loop的技术部分。取而代之的是,nextTickQueue会在当前的操作完成后执行,不考虑event loop的当前阶段。在这里,operation的定义是指从底层的C/C++处理程序到处理需要执行的JavaScript的转换。

回过头来看我们的程序,任何阶段你调用process.nextTick(),所有传递进process.nextTick()的callback会在event loop继续前完成解析。这会造成一些糟糕的情况,通过建立一个递归的process.nextTick()调用,它允许你“starve”你的I/O。,这样可以使得event loop不到达poll阶段。

为什么说process.nextTick()是更加强大的异步专家?

process.nextTick()比setTimeout()更精准的延迟调用

为什么说“process.nextTick()比setTimeout()更精准的延迟调用”呢?
不要着急,带着疑问去看下文即可。看懂就能找到答案。

为什么Node.js要设计这种递归的process.nextTick()呢 ?这是因为Node.js的设计哲学的一部分是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最近进行了更新,允许将参数传递到process.nextTick(),从而允许在callback之后传递的任何参数作为回调的参数进行传递,这样就不用嵌套函数了。

我们现在做的是将一个error传递到user,但是必须在我们允许执行的代码执行完之后。通过使用process.nextTick(),我们可以保证apiCall总是在用户代码的其余部分和允许事件循环继续之前运行它的callback。为了实现这一点,JS call stack可以被展开,然后immediately执行提供的回调,从而允许一个人递归调用process.nextTick()而不至于抛出RangeError: Maximum call stack size exceeded from v8.

一句话概括的话就是:process.nextTick()可以保证我们要执行的代码会正常执行,最后再抛出这个error。这个操作是setTimeout()无法做到的,因为我们并不知道执行那些代码需要多长时间。

是怎么做到process.nextTick(callback)比setTimeout()更严格的延迟调用的呢?
process.nextTick(callback)可以保证在这一次事件循环的call stack 解除(unwound)后,在下一次事件循环前,调用callback。

可以把原因再讲得详细一点吗?

process.nextTick()会在这一次event loop的call stack清空后(下一次event loop开始前)再调用callback。而setTimeout()是并不知道什么时候call stack清空的。我们setTimeout(cb, 1000),可能1s后,由于种种原因call 栈中还留存了几个函数没有调用,调大到10秒又很不合适,因为它可能1.1秒就执行完了。

相信有一定开发经验的同学一看就懂,一看就知道process.nextTick()的强大了。
心里默念:“终于不用调坑爹的setTimeout延迟参数了!”

强大的process.nextTick()解决的实际问题

这个哲学会导致一些潜在问题。下面来看下这段代码:

let bar;
//  它是异步,但是同步调用callback
function someAsyncApiCall(callback) { callback(); }
// callback在someAsyncApiCall完成前调用
someAsyncApiCall(() => {
  // 因为someAsyncApiCall还没有完成,bar还未赋值
  console.log('bar', bar); // undefined
});
bar = 1;

用户定义了有一个异步签名的someAsyncApiCall(),但是它实际上同步执行了。当someAsyncApiCall()调用的时候,内部的callback在异步操作还没完成前就调用了,callback尝试获得bar的引用,但是作用域内是没有这个变量的,因为script还没有执行到bar = 1这一步。

有什么办法可以保证在赋值之后再调用这个函数呢?

通过将callback传递进process.nextTick(),script可以成功执行,并且可以访问到所有变量和函数等等,并且在callback调用之前已经初始化好。 它拥有允许不允许事件循环继续的优点。对于用户在event loop想要继续运行之前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', () => {});

当我们传递一个端口号进去时,端口号会被立刻绑定。所以'listening' callback可以被立即调用。问题是.on('listening');这个callback可能还没设置呢?这要怎么办?

为了做到在精准无误的监听到listen的动作将对‘listening’事件的监听操作,队列到nextTick(),从而可以允许代码完全运行完毕。 这可以使得用户设置任何他们想要的事件。

为什么要用process.nextTick()?

  • 允许用户处理error,清除不需要的资源,或者在事件循环前再次尝试请求
  • 有时确保callback在call stack unwound(解除)后,event loop继续循环前 调用

允许用户处理error,清除不需要的资源,或者在事件循环前再次尝试请求

这里有一个匹配用户期望的例子。

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

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

listen()在event.loop循环的开始运行,但是listening callback被放置在setImmediate()中。除非传入hostname,否则立即绑定端口。event loop在处理的时候,它必须在poll阶段,这也就是意味着没有机会接收到连接,从而允许在侦听listen事件前触发connection事件。

有时确保callback在call stack unwound(解除)后,event loop继续循环前 调用

再来看一个例子:
运行一个继承了EventEmitter的function constructor,它想在constructor内部发出一个'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内理解emit一个event,因为script不会运行到用户监听event响应callback的位置。所以在constructor内部,可以使用process.nextTick设置一个callback在constructor完成之后emit这个event,所以最终的代码如下:

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

function MyEmitter() {
  EventEmitter.call(this);
  // 一旦分配了handler处理程序,就使用process.nextTick()发出这个事件
  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函数准确在这一次call stack清空后,下一次event loop开始前调用。

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()的学习以及对源码的理解,我们得出:
流写入本地执行work(),若接收到有效的数据包,开始process.nextTick()递归。

  • 开始nextTick的条件:if(packet)/if (packets.length) 也就是说有接收到websocket包时开始。
  • 递归nextTick的过程:work()->nextTickWork()->process.nextTick(work)。
  • 结束nextTick的条件:packet为空或者packets为空,通过completeParse=null,done()结束递归。
  • 如果对work不加process.nextTick会怎样?
function nextTickWork () {
  if (packets.length) {
    work() // 注意这里
  }
}

会造成当前的event loop永远不会中止,一直处于阻塞状态,造成一个无限循环。
正是因为有了process.nextTick(),才能确保work函数准确在这一次call stack清空后,下一次event loop开始前调用。

参考链接:

期待和大家交流,共同进步,欢迎大家加入我创建的与前端开发密切相关的技术讨论小组:

image

努力成为优秀前端工程师!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容