performance.timerify 的 bugs

1,019 阅读6分钟

Node.js实现W3C performance API已经有一段时间了,最近我发现Node.js还提供了方便的Histogram API,可得到平均值、最小值、最大值,中位数或指定的百分位、标准差等。对于常见的函数执行时间的统计需求,可以:

import {performance, createHistogram} from 'perf_hooks'

const histogram = createHistogram()
const wrapped_fn = performance.timerify(fn, {histogram})

doSth(wrapped_fn) // 内部可能多次调用 wrapped_fn

console.log(
  histogram.count,          // 采样次数 
  histogram.min,            // 最小值
  histogram.percentile(50), // 中位值
  histogram.mean,           // 平均值
  histogram.stddev,         // 标准差
)

performance.timerify(fn, {histogram})(Node.js v16+)生成一个包装函数,每次调用会对fn的执行计时(单位为纳秒)并将耗时写入histogram。看上去这个API用来做microbenchmark还是很方便的。

然而我在使用的时候遇到了bug——fn的返回值如果是primitive值,包装函数的返回值会变成一个空对象。我当时写了个fn会返回null,它给我偷换成了个对象,自然把程序搞挂了。

研究了一番后,我发现如果fn是普通函数(即function fn() {}),会总是以new fn方式调用。

到Node.js仓库里查找了一番,已经有人发了Issue #40623。也有试图修复的PR #40625,但一直没有被合进去,因为其修复方式并不合理。

从讨论中可见,原作者的意图是,如果是构造器,那么就new之,于是写了类似IsConstructor(fn) ? new fn(...args) : fn(...args)的逻辑,但忘记了普通函数也是构造器。

【所以有个workaround就是写成箭头函数——箭头函数不是构造器。】

PR则改为了类似IsClass(fn)。但这导致传统的非class的构造器就不会以new方式调用了。尽管ES6之后绝大部分新代码都已经用class了,但总还是有老代码。另外还有一种情况是,代码本身是以class写的,但是可能发的包仍然是被编译成ES5了。

【此外,该PR的IsClass的判断是通过/^\s*class/.test(fn.toString())这样的hack方式,并不靠谱。比如内建构造器的toString()结果并不会以"class"开头;又比如,按照目前stage3的decorator提案,被decorator所修饰的class的toString()结果会包含decorator(也就是以"@deco class"开头);未来也可能包含其他修饰关键字(比如abstractasyncfinalstatic等)。 】

实际上,合理的逻辑并不是检查fn是否是构造器,而应是原样传递语义——包装函数在这里应该是一个代理。

假如用Proxy实现的话是很简单的,大体如下:

function timerify(fn) {
  return new Proxy(fn, {
    construct(...args) {
      const start = now()
      const result = Reflect.construct(...args)
      processComplete(start)
      return result
    },
    apply(...args) {
      const start = now()
      const result = Reflect.apply(...args)
      processComplete(start)
      return result
    },
  }
}

不过我们可能并不想用proxy。(比如担心proxy的性能?可能阻止内联?)

如果直接写包装函数应该怎么写呢?

逻辑上是IsNew ? new fn(...args) : fn(...args),IsNew表示当前执行函数是否是以new调用的,但IsNew如何写?

传统上,我们可以用instanceof来判定:

function timerify(fn) {
  return function timerified(...args) {
    const start = now()
    const result = this instanceof timerified
      ? new fn(...args) : fn.call(this, ...args)
    processComplete(start)
    return result
  }
}

不过现在可以祭出更精确的new.target这个元属性(meta property):

function timerify(fn) {
  return function (...args) {
    const start = now()
    const result = new.target
      ? Reflect.construct(fn, args, new.target)
      : Reflect.apply(fn, this, args)
    processComplete(start)
    return result
  }
}

【注意Reflect.construct的第三个参数,在当前实现中是没有传递的。这意味着当前实现也不能正确处理子类继承如class X extends timerify(Base)的情形。】

更进一步说,timerify最好和Function.prototype.bind一样,如果fn不是构造器,返回的包装函数也不是构造器。

【要返回一个非构造器的函数,可以使用一个偏门小技巧——简写形式方法不是构造器,所以可以写成:return {fn() { ... }}.fn。】


PS. 在研究这个bug时,我查看了timerify源码,并发现了另外两个bug 😂 ,于是去开了issue。

第一个issue是performance.timerify(fn, options) always return the same timerifed function · Issue #42742 · nodejs/node

当前实现画蛇添足地做了缓存,即多次timerify(fn)的结果返回同一个函数。 然而我们可能有需求要为同一个fn产生多个包装函数,比如为相同函数在不同场景的使用生成不同的统计函数:

let h1 = perf_hooks.createHistogram()
let h2 = perf_hooks.createHistogram()
let f1 = perf_hooks.performance.timerify(f, {histogram: h1})
let f2 = perf_hooks.performance.timerify(f, {histogram: h2})
f1 !== f2 // expect true, actual false

结果调用f2的用时数据并不会写入h2,而是也写入了h1

第二个issue是performance.timerify(fn) behave inconsistently for sync/async funct…

timerify对异步函数(或所有返回promise的函数)做了特殊处理,计时不是到函数调用结束(返回promise)之时,而是到promise完成之后。这符合大部分使用者的直觉。但当前实现不是使用then调用,而是再次画蛇添足地使用了finally调用。Promise.prototype.finally会确保无论成功失败总是调用,看上去似乎更「安全」,但实际上在这里使用finally,会导致异步函数和非异步函数调用结果不一致。因为包装函数调用fn时并没有使用try ... finally构造,如果throw,则并不会对本次调用完成计时。

为了确保一致,要么都不用finally,要么都用finally。事实上,之所以promise上的这个方法命名为finally,也是在提示这个方法和try ... finally的对应性。然而在本例中还是被无视了……

那么到底是否应该用finally呢?不应该用。因为我们计时是希望测量函数的运行时间,throw或reject表明并没有完成函数的正常计算逻辑,不符合我们的统计目标,不应该被计时。

【即使要用finally,当前实现中的逻辑if (result?.finally) result.finally(...)也是有问题的。因为promise或所谓thenable的标志是then方法而不是finally方法。依赖finally方法就和上面提到的依赖toString的结果一样不严谨。】


总结:写代码要做到严谨是不容易的。即使是Node.js这样的明星项目,即使是出自James M Snell这样的资深程序员之手,即使是一个并不算太复杂的API,即使只有100行代码……也可能潜藏各种问题。

【当然,我们可以喷Node.js的代码质量也不过尔尔;其实就算JS引擎代码,也经常出bug(如github.com/hax/hax.git…);JS标准规范,也有很多bug,比如前面提到的finally方法,就有和then方法存在行为不一致的bug:github.com/tc39/ecma26…,而且因为涉及潜在的安全性问题,委员会还没就如何修这个bug达成一致意见……总之,是人类的产物,就会有bug。而且相比人类复杂系统中的各种bug——大到战争,小到团购,程序bug算是最容易处理了,一篇文章就能写清楚。】

【题图盗自《Measure execution times in browsers & Node.js》,是一篇不错的入门文章。】

【本文首发于我的知乎专栏:zhuanlan.zhihu.com/p/498708739