高负载tfjs与discord.js应用慢内存泄漏排查及解决方案
- 为满足业务流量需求,该应用的事件函数(存放于
listener.js)每秒执行约14次 listener.js:存储事件处理函数的文件handler.js:负责处理listener.js触发事件的文件sharder.js:实现应用分片能力的启动文件index.js:sharder.js启动每个分片时执行的入口文件gc.js:用于手动调用垃圾回收器的文件(该方案经测试无效,为社区推荐方案)
- Node.js v16.13.1
- discord.js v13.6.0
- @tensorflow/tfjs v3.14.0
- @tensorflow/tfjs-node v3.14.0
机器人所有分片上线后即可检测到内存泄漏,泄漏速度缓慢但可明确观测:宿主机配备64GB内存的情况下,仍需每日重启Node进程才能维持服务。目前所有张量均已按规范释放(张量数量稳定为263,原因是推理模型在事件监听器外部加载,未做释放处理)。已配置专门的事件监听器用于手动触发垃圾回收,但未产生实际效果;在listener.js中已尝试将所有可手动处理的变量赋值为null,不确定该操作是否能起到内存回收作用。
当前代码实现中是否存在被忽略的内存泄漏诱发点?对应的可行修复方案有哪些?
listener.js
const { Readable } = require('stream'); const PImage = require('pureimage'); const tf = require(`@tensorflow/tfjs`) const tfnode = require('@tensorflow/tfjs-node'); let nameArr = [ // array of names here ] let bufferToStream = (binary) => { let readableInstanceStream = new Readable({ read() { this.push(binary); this.push(null); } }); return readableInstanceStream; } const predict = async (imageUrl, model) => { let data = await fetch(imageUrl); let fileType = data.headers.get("Content-Type"); let buffer = await data.buffer(); let stream = bufferToStream(buffer); let image; if ((/png/).test(fileType)) { image = await PImage.decodePNGFromStream(stream); } else if ((/jpe?g/).test(fileType)) { image = await PImage.decodeJPEGFromStream(stream); } else { return `Error. Invalid file type.` } let rawTensor; rawTensor = tf.tidy(() => { let tensorImage; tensorImage = tf.browser.fromPixels(image).toFloat(); tensorImage = tf.image.resizeNearestNeighbor(tensorImage, [model.inputs[0].shape[1], model.inputs[0].shape[2]]); let offset = tf.scalar(127.5); tensorImage = tensorImage.sub(offset).div(offset); offset = null; tensorImage = tensorImage.reshape([1, model.inputs[0].shape[1], model.inputs[0].shape[2], model.inputs[0].shape[3]]); return model.predict(tensorImage); }); let classes = [] for (let i = 1; i < 181; i++) { classes.push(`${i}`) } let sorted = tf.topk(rawTensor, classes.length); let predictions = [ sorted.values.arraySync(), sorted.indices.arraySync() ]; let rawArray; rawArray = await rawTensor.data(); rawArray = Array.from(rawArray); tf.dispose([rawTensor, sorted]) let predInd = predictions[1][0][0]; let predVal = (predictions[0][0][0]*100).toFixed(2); let msg = `${classes[predInd]} (${predVal}%) -`; data = null; fileType = null; buffer = null; image = null; rawTensor = null; classes = null; sorted = null; predictions = null; rawArray = null; predInd = null; predVal = null; i = null; return msg }; module.exports = { event: 'messageCreate', run: async (message, client, Discord, model) => { let mb = message.embeds[0]; if (!mb) return; if (mb.title) { var link = mb.image[`proxyURL`]; let first = Date.now() let prediction = await predict(`${link}`, model) let second = Date.now() let pred1 = prediction.split(` `) let pred2 = nameArr[((pred1[0]*1)-1)] let logPred = `${pred2} ${pred1[1]} ${pred1[2]} ${second-first}ms` console.log(logPred) message.channel.send(logPred) mb = null; link = null; first = null; prediction = null; second = null; pred1 = null; pred2 = null; x = null; logPred = null; } }, };
handler.js
if (err) return console.error(err); files.forEach(async (file) => { const eventFunction = require(`./../events/${folder}${file}`); if (eventFunction.disabled) return; const event = eventFunction.event || file.split('.')[0]; const emitter = (typeof eventFunction.emitter === 'string' ? client[eventFunction.emitter] : eventFunction.emitter) || client; const once = eventFunction.once; try { emitter[once ? 'once' : 'on'](event, (...args) => eventFunction.run(...args, client, Discord, model), ); } catch (error) { console.error(error.stack); } }); };
sharder.js
const { token } = require('./config.json'); const manager = new ShardingManager('./index.js', { token: `${token}` }); manager.on('shardCreate', async shard => { console.log(`Launched shard ${shard.id}`) }); manager.spawn({ amount: 90 , delay: 10000, timeout: 1 * 1000 * 60 })
index.js
const Discord = require('discord.js'); const { token } = require('./config.json'); const client = new Discord.Client({ intents: [ Discord.Intents.FLAGS.GUILDS, Discord.Intents.FLAGS.GUILD_MESSAGES ] }); const db = require("quick.db"); const eco = { bot: new db.table("bot") }; module.exports = { eco }; const folders = [ "interactionCreate/" ] for (let i = 0; i < folders.length; i++) { const folder = folders[i] fs.readdir(`./events/${folder}`, async (err, files) => { const eventHandler = require("./data/eventHandler.js"); const tf = require(`@tensorflow/tfjs-node`); let model = await tf.loadLayersModel(`file://./models/model.json`); eventHandler(err, files, client, Discord, folder, model); }); } client.login(token);
gc.js
module.exports = { event: 'messageCreate', run: async (message, client, Discord) => { if (!message.content.startsWith(`clear`)) return const col = async (client) => { try { if (global.gc) {global.gc();} console.log(`Garbage Collected`) } catch (e) { console.log(`Unable to collect`) } } const exec = async () => { await client.shard.broadcastEval(col) } await exec(); }, };
代码里存在6个明确的内存泄漏/内存占用过高的诱发点,对应修复方式如下:
tfjs张量漏释放
tfjs 3.x版本中,tf.topk()返回的对象包含values、indices两个独立张量,直接tf.dispose(sorted)不会递归释放这两个内部张量。目前代码只释放了rawTensor和sorted本身,每次推理都会漏两个张量的内存,每秒14次调用持续累积就会形成稳定的内存泄漏。
修复:将释放语句修改为tf.dispose([rawTensor, sorted, sorted.values, sorted.indices])即可,不需要额外处理rawTensor.data()返回的TypedArray,张量释放后这部分内存会被自动回收。图片解码与流资源未清理
自定义的bufferToStream方法每次调用都会新建Readable流实例,图片解码完成后既没有销毁流,也没有清理pureimage解码生成的位图对象。pureimage解码出的image对象内部存储了全量像素数组,内存占用不低;未销毁的流会一直持有buffer引用,两者叠加每次请求都会泄漏几百KB到数MB不等的内存。
修复:图片推理完成后手动将image.data属性置空,给创建的Readable流添加end、error事件监听,流消费完成后直接调用stream.destroy()释放内部持有的资源引用。事件监听器重复注册
当前事件注册逻辑没有做去重判断,一旦出现文件重加载、客户端重连等场景,就会给同一个事件重复绑定监听器。每个监听器的闭包都会持有model、client这类大对象引用,重复绑定不仅会导致事件被多次执行,还会让大对象一直被引用无法回收。另外代码在fs.readdir的异步回调里重复加载tfjs、eventHandler依赖,虽然Node.js有模块缓存不会重复加载,但写法不规范,一旦出现路径拼写错误就可能导致模块重复加载占用内存。
修复:注册事件前先判断当前emitter上是否已经存在同事件的监听器,存在则先移除旧监听器再绑定新的;所有依赖的require语句统一放到文件顶部,不要在异步回调内加载大体积依赖。
额外说明:index.js中使用了fs.readdir但顶部没有引入fs模块,运行会直接报错,补上const fs = require('fs')即可。无意义的null赋值干扰GC
代码在每个函数末尾挨个给局部变量赋值为null的操作完全没有正向作用。函数执行完成后局部执行上下文会被销毁,只要不存在闭包引用,这些变量指向的内存本来就会被GC自动回收,手动赋null反而会干扰V8引擎的逃逸分析优化,可能让本该在年轻代就回收的内存被晋升到老年代,反而拉长回收周期、增加整体内存占用。
修复:删掉所有函数末尾无意义的xxx = null赋值语句,只需要手动清理被全局对象、事件监听器、长生命周期闭包持有的对象引用即可。quick.db缓存无上限
quick.db默认会把全量数据库数据加载到内存缓存中,没有设置默认的缓存大小上限,如果存在频繁写入操作,缓存体积会持续膨胀,旧版本quick.db还存在已知的内存泄漏问题,会持续占用内存不释放。
修复:如果业务中用不上quick.db的功能直接删掉相关初始化代码;如果需要使用则升级到最新版本,配置合理的缓存大小上限,不要往数据库中存储过大的对象。手动GC逻辑本身不生效
现有gc.js的逻辑存在两个问题:一是启动Node进程时没有添加--expose-gc启动参数,global.gc本身为undefined,根本无法执行;二是broadcastEval传入的函数是序列化后传输到分片进程的,闭包引用的外部变量不会被一并传递,就算开启了GC权限也容易出现上下文丢失的问题。
修复:确实需要手动触发GC的话,在ShardingManager初始化时添加execArgv: ['--expose-gc']参数,给所有分片进程开启GC调用权限;日常运行时不要频繁手动调用GC,V8的自动GC调度效率远高于手动触发。
内容的提问来源于stack exchange,提问作者Nic Cheng

