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_fnconsole.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"
开头);未来也可能包含其他修饰关键字(比如abstract
、async
、final
、static
等)。 】
实际上,合理的逻辑并不是检查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 functions · Issue #42743 · nodejs/node。
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(如https://github.com/hax/hax.github.com/issues/51);JS标准规范,也有很多bug,比如前面提到的finally
方法,就有和then
方法存在行为不一致的bug:https://github.com/tc39/ecma262/issues/2222,而且因为涉及潜在的安全性问题,委员会还没就如何修这个bug达成一致意见……总之,是人类的产物,就会有bug。而且相比人类复杂系统中的各种bug——大到战争,小到团购,程序bug算是最容易处理了,一篇文章就能写清楚。】
【题图盗自《Measure execution times in browsers & Node.js》,是一篇不错的入门文章。】
【本文首发于我的知乎专栏:https://zhuanlan.zhihu.com/p/498708739 】
原文:https://juejin.cn/post/7096387106297085988