[eggjs/egg][bug] loader.loadFile 内存泄漏
回答
额,为啥会反复调用 loader 呢? loader 只是初始化的时候调用的。
额,为啥会反复调用 loader 呢? loader 只是初始化的时候调用的。
因为 loadFile 是公开的 api,用 loadFile 就可以用 egg 模块的写法。 反复调用是因为,自定义的中间件里有调用,每个请求都会调用,谁知道 ……
那就是你的逻辑有问题了,模块一般不会太多,直接在初始化的时候加载就好了,会 lazy init 的,没必要在每次请求都去加载啊。
1、为什么非要在初始化时加载?你怎么知道模块不会太多呢?这个假设有点想当然了吧?NodeJS 的 require 也没有在启动时就把所有模块都加载一遍啊! 2、我看过源码,没看到哪里有 lazy init。而且,loadFile 实际调用的也是 NodeJS 的 require,require 本身就有缓存机制,就算每次请求都调用,也会用到缓存,性能问题不大。 3、就算不调 loadFile,timing 模块也有内存泄漏,这是潜在的隐患,作为一个被广泛使用的开源框架,逻辑应该严谨!
不要那么激动,先把场景沟通清楚。我们有一些业务也是有几千个模块的加载,也是在初始化的时候加载的,日常 alinode 也没有发现这块有什么问题。
因为之前的设计上考虑的 loader 都在在初始化的时候就加载好了(这是 plugin 独立于 middleware 的出发点),timing 这里没考虑过会被请求期调用的场景。
现在内存泄露的原因,不在于加载的模块多,而在于你的用法里面反复加载同个模块了吧?我认为这个是不太合理的,什么情况下,会每一个用户请求,都需要去加载一次文件?
你是在做动态挂载和卸载模块的场景么?能否具体介绍下?
cc @killagu 如果场景合理的话,我们可以加一个 timing 的 clear 方法出来。
初始化就加载所有模块,我认为是不合理的,缺点是应用启动时间长,而且占用内存,可能很多模块很少用,也加载到内存了,没必要浪费宝贵的服务器资源。
我的场景,就是对请求和响应格式进行了统一封装,写模块时按标准模块开发就行,不需要处理任何协议。然后,由中间件根据请求,按需动态加载和运行模块。之所以反复加载同个模块,是因为看到 NodeJS require 有缓存机制,所以每次请求都加载也没什么性能问题。
权衡取舍后,还是决定每次请求都加载,既可以缩短启动时间,减少内存占用,同时也不会对性能有很大影响,所以,我认为是合理的。
了解了,再问个问题,你的场景是 loadFile 后挂载到某个对象下?如 app.module.xx 这样?那是不是可以在这里加个判断做 lazy init,如果没有挂载过,才去调用 loadFile。
timing 那里讨论了下,loadFile 的耗时还是需要记录的,框架层面可以提供一个 timing 的 clear 方法,然后你在动态加载的时候调用下。
不过我更倾向于上面说的做法,判断有没有挂载过再调用 loadFile。
cc @killagu 回头可以考虑下内置的 lazy load 实现,我们 function 那块也有点类似?
嗯,判断条件也会加上,没有加载过再调用 loadFile。
你可以参考下 context loader 里面的几个 lazy init 的类的实现,直接往 app 上挂个多级的 lazy init 对象,遵循一套约定,这样的话,在 Controller 等地方就可以直接调用了,不需要在 middleware 里面来反复调用。
timing 的那个欢迎提个 PR 给我们,加个 clear 方法。
timing 的作用是什么?为什么要记录时间?在什么场景下用?能否解释下
上面提了,Loader 设计之初就是在启动期去加载文件的,Timing 用来采集启动过程的一些数据,用于分析用的,最终会 dump 到 timing.json 中。
Loader 的 API 也会给到上层框架去扩展他们的约定,后面出了 customLoader 的配置后,框架和插件其实可以不用调用这个 API,直接配置就好了,相当于收敛了这个 API,属于半内部的 API 了。
其实从你的需求来看,自己 require 文件也是可以的,loadFile 里面没啥逻辑,不一定需要调用它,app 的引用,还有 app.config.baseDir 你都是可以拿得到的,自己封装个方法也挺简单。
我理解 Timing 只是框架内部采集分析用的,那理应由框架自身来解决 clear 的事情,不应该由上层应用来处理。 自己 require 是可以,但如果要用 TS,自己 require 就很麻烦,而用 loadFile 就很容易支持。
从框架的角度来看, Timing 不会太多,采集后就在内存里面了,启动成功后会输出一份到文件,内存里面还会保存一份(某些插件用来上报用),框架也不知道啥时候需要清理掉啊。
即使框架在启动完毕后清理,但你后续还是会一直持续的调用,框架怎么知道啥时来清理?如果每次加载都清理 timing,那还打啥 timing。实在要框架层来处理,也只能是我刚才说的,提供一个 clear 的方法给上层自己判断调用。
但如果要用 TS,自己 require 就很麻烦,而用 loadFile 就很容易支持。
具体指?
即使框架在启动完毕后清理,但你后续还是会一直持续的调用,框架怎么知道啥时来清理?如果每次加载都清理 timing,那还打啥 timing。
所以 timing 不是必须要的。上层应用不用,大部分插件也不用,只是某些插件用,那为什么不在插件里处理,而非要加在框架里呢?显得框架很重。
require 只能 require js。但本地开发时,只有 ts,js 在哪里?
采集是整体的,插件是上报,不是采集,采集只能在核心层做。你可以理解为 egg 的内部逻辑,loader api 也算是私有 API 吧。
require 只能 require js。但本地开发时,只有 ts,js 在哪里?
可以再看下 loadFile 那块的逻辑,你会发现其实也只是 require,本地开发的 ts 其实是 ts-node/register 做的,它本身就 hack 掉了 require 的逻辑了。
loadFile -> resolveModule -> requireFile -> utils.loadFile (这里其实也没太多逻辑,我晚点看下有没有必要抽个方法,没的话自己写个其实也不会太麻烦)
先不发散了,每次请求都重复加载文件这个逻辑 先处理了吧,判断下再加载。
https://github.com/eggjs/egg-core/blob/master/lib/utils/timing.js#L15
我看了下 timing 的逻辑,如果没有
const timingKey = `Require(${this[REQUIRE_COUNT]++}) ${utils.getResolvedFilename(filepath, this.options.baseDir)}`;
这里的 this[REQUIRE_COUNT]++ 拼接模块名称其实就算多次调用也不会造成内存泄露,毕竟记录 time 的对象本身是很小的,这里为啥要多这个自增的 COUNT 设置到 key 里 @atian25
如果需要记录 require 的模块数,直接 ++ 写到 time 对象里就行了,不需要放到记录模块调用的唯一 key 上
主要还是看 timing 的定义,一个文件被 2 个不同的地方 require 了,算 2 条还是 1 条。我们认为是 2 条,实打实的记录加载过程做了什么事。
可以在启动完成后把 timing 清理了,主要还是为了统计启动耗时情况,运行时调用 loadFile 已经脱离了 timing 原始需求,不属于这个范畴了,可以不用再统计。
采集是整体的,插件是上报,不是采集,采集只能在核心层做。你可以理解为 egg 的内部逻辑,loader api 也算是私有 API 吧。
require 只能 require js。但本地开发时,只有 ts,js 在哪里?
可以再看下 loadFile 那块的逻辑,你会发现其实也只是 require,本地开发的 ts 其实是 ts-node/register 做的,它本身就 hack 掉了 require 的逻辑了。
loadFile -> resolveModule -> requireFile -> utils.loadFile (这里其实也没太多逻辑,我晚点看下有没有必要抽个方法,没的话自己写个其实也不会太麻烦)
core 里面打点,只产生打点数据,数据具体是放内存、存盘、上传、丢弃交给插件来做,这样是不是会比较好。虽然是私有的 api 而且默认 timing 数据很小,但是后面没啥用了一直放内存,会不会不符合 egg 精益求精的特质
@blueaqua2000 egg-core 已经有对应的 API 了,你可以 clear 和 disable 掉。
egg 的那个自动 clear 的单测还有点问题,晚点我修下
@hsiaosiyuan0 上面的 PR 已经支持了。
@blueaqua2000 egg 发版本了。
@blueaqua2000 准备部分回滚,仅 disable,而不 clear 启动期收集的 timing,对你的场景应该没影响,不会新增了。 你有需要的话可以自己调用一次 clear。
https://github.com/eggjs/egg/pull/4497