网站搜索

javascript - 为什么即使从 require.cache 中移除,‘require’ 仍然会不断将文件作为字符串加载到堆中?


我有一个类似任务管理器的 NodeJS 应用程序。每秒(使用 rxjs 定时器),它会从数据库中获取计划的任务。每个任务都有一个文件名,并且该文件是通过 require 加载的。每个文件必须有一个 run() 方法来执行任务。任务完成后,所需的文件会从 require.cache 中移除。应用程序在运行几天后会失败,因为堆内存已满。我使用 node --inspect 检查,发现所需的文件保存在字符串中。有人能解释一下为什么即使文件从 require.cache 中移除,所需文件仍然以字符串形式保存在堆中吗?更好的是,如果有人能解释如何解决这个问题,那就更好了!

以下是我代码的简化版本

    async test() {
        timer(0, 1000).pipe(
            exhaustMap(async () => {
                return await this.executeTasks();
            })
        ).subscribe();
    }

    async executeTasks(): Promise<void> {
        // get all queue items
        const tasks = await this.getTasks();

        // for each item
        for (const task of tasks) {
            // execute item
            await this.executeTask(task);
        }

    }

    async executeTask(item: QueueItem) {

        try {
            const Callback: typeof AutomationCallback = require(item.callbackFile);
            const callback = new Callback();

            await callback.run();
        } catch (error) {
            if (error instanceof Error && (error as any).code === 'MODULE_NOT_FOUND') {
                log_message('Failed executing', item.callback, item.id, `Cannot find file ${item.callbackFile}`);
            } else if (error instanceof RescheduleError) {
                log_message('Failed executing', item.callback, item.id, error.message);
            } else {
                log_message('Failed executing', item.callback, item.id, (error as any).toString());
            }

        } finally {
            // remove require from cache;
            try {
                delete require.cache[require.resolve(item.callbackFile)];
            } catch (error) {
                
            }
        }
    }

下面是快照的截图,高亮的文件每次使用 require 加载时都会被添加到堆中。如你所见,左侧的堆会慢慢填满,快照1和快照2之间的时间大约是5分钟。

答案#1

如果一切都已经事先定义,为什么不在顶部把它们设为必需,然后有条件地调用它们呢?

示例:

class TaskRunnerBase {
  async run() { this.doRun(); }
  async doRun() {} // implemented by specific runners
}
class TaskRunnerA extends TaskRunnerBase {
  async doRun() {...} // custom implementation for TaskRunnerA
}
class TaskRunnerB extends TaskRunnerBase {
  async doRun() {...} // custom implementation for TaskRunnerB
}
class DefaultRunner extends TaskRunnerBase {
  async doRun() { console.log('Default Runner'); } 
}

TaskManager

const TaskRunnerA = require('./task-runners/task-runner-a');
const TaskRunnerB = require('./task-runners/task-runner-b');
// more runners
const DefaultRunner = require('./task-runners/default-runner');

...
...
async executeTask(item: QueueItem) {

        try {
            const Callback: typeof AutomationCallback = this.getRunnerByType(item.callbackFile);
            const callback = new Callback();

            await callback.run();
        } catch (error) {
            ...
        } finally {
            ...
        }
    }
    

getRunnerByType(type) {
  if(type === 'RunnerA') { // "RunnerA" for example
    return TaskRunnerA;
  }
  else if(type === 'RunnerB') { // "RunnerB" for example
    return TaskRunnerB;
  }
  ....
  else {
    return DefaultRunner; // If unknown type is passed, let's keep going, with DefaultRunner.
  }
}

这种方法从长远来看是简单的、可管理的、毫不费脑筋的。

答案#2

require.cache 并不是唯一缓存已加载模块的地方。根据模块类型以及加载方式,还有其他一些缓存位置,其中一些对象甚至没有被 Node.js 暴露,这意味着即使你想删除它们也无法删除。

一个有充分文档记录的例子是 module.children,它是一个已加载模块的数组。如果你查看这个数组的长度,你会看到每次 require 后它都会变得越来越大,因为你实际上是在一次又一次地加载相同的模块,而不是重用缓存的对象:

const Callback: typeof AutomationCallback = require(item.callbackFile);
console.log('Loaded modules:', module.children.length); // <-
const callback = new Callback();

await callback.run();

当然,你可以清除子元素列表(无论如何它并不作为缓存使用)以及 require 缓存:

// No guarantee that this will help.
delete require.cache[require.resolve(item.callbackFile)];
module.children.pop();

我甚至自己写了一个工具来以不会影响程序其余部分的方式做类似的事情。

这样做可能有帮助,也可能没有帮助,实际上,如果唯一的目标是防止内存泄漏,这甚至可能适得其反——因为每次需要模块时都分配资源来加载模块。

你最好的选择可能是去掉 finally 块,并保持 require.cache 不变。这样,每个模块最多只会被加载一次。