fs.createWriteStream не использует обратное давление при записи данных в файл, что приводит к высокому использованию памяти

проблема

Я пытаюсь просканировать каталог диска (рекурсивно пройтись по всем путям) и записать все пути в файл (как он их находит), используя fs.createWriteStream, чтобы сохранить низкое использование памяти, но это не работает, использование памяти достигает 2 ГБ во время сканирования.

ожидаемый

Я ожидал, что fs.createWriteStream будет автоматически обрабатывать использование памяти/диска в любое время, сохраняя использование памяти на минимальном уровне с обратным давлением.

Код

const fs = require('fs')
const walkdir = require('walkdir')

let dir = 'C:/'

let options = {
  "max_depth": 0,
  "track_inodes": true,
  "return_object": false,
  "no_return": true,
}

const wstream = fs.createWriteStream("C:/Users/USERNAME/Desktop/paths.txt")

let walker = walkdir(dir, options)

walker.on('path', (path) => {
  wstream.write(path + '\n')
})

walker.on('end', (path) => {
  wstream.end()
})

Это потому, что я не использую .pipe()? Я попытался создать new Stream.Readable({read{}}) а затем внутри .on('path' эмиттер проталкивает пути в него с помощью readable.push(path) но это не сработало.

ОБНОВИТЬ:

Способ 2:

Я попробовал предложенный метод drain ответов, но он мало помогает, он уменьшает использование памяти до 500 МБ (что все еще слишком много для потока), но значительно замедляет код (с секунд до минут)

Способ 3:

Я также попытался использовать readdirp, он использует еще меньше памяти (~ readdirp) и работает быстрее, но я не знаю, как его приостановить и использовать метод drain для дальнейшего сокращения использования памяти:

const readdirp = require('readdirp')

let dir = 'C:/'
const wstream = fs.createWriteStream("C:/Users/USERNAME/Desktop/paths.txt")

readdirp(dir, {alwaysStat: false, type: 'files_directories'})
  .on('data', (entry) => {
    wstream.write('${entry.fullPath}\n')
  })

Способ 4:

Я также попытался выполнить эту операцию с пользовательским рекурсивным обходчиком, и хотя он использует только 30 МБ памяти, что я и хотел, но он примерно в 10 раз медленнее, чем метод readdirp и он synchronous что нежелательно:

const fs = require('fs')
const path = require('path')

let dir = 'C:/'
function customRecursiveWalker(dir) {
  fs.readdirSync(dir).forEach(file => {
    let fullPath = path.join(dir, file)
    // Folders
    if (fs.lstatSync(fullPath).isDirectory()) {
      fs.appendFileSync("C:/Users/USERNAME/Desktop/paths.txt", '${fullPath}\n')
      customRecursiveWalker(fullPath)
    } 
    // Files
    else {
      fs.appendFileSync("C:/Users/USERNAME/Desktop/paths.txt", '${fullPath}\n')
    }  
  })
}
customRecursiveWalker(dir)

Ответ 1

Предварительное наблюдение: вы пытались получить желаемые результаты, используя несколько подходов. При сравнении подходов, которые вы использовали, сложность заключается в том, что они не все выполняют одинаковую работу. Если вы запускаете тесты для файлового дерева, которое содержит только обычные файлы, это дерево не содержит точек монтирования, вы, вероятно, сможете сравнить подходы справедливо, но когда вы начнете добавлять точки монтирования, символические ссылки и т.д., Вы можете получить различную статистику памяти и времени просто из-за того, что один подход исключает файлы, которые включает другой подход.

Первоначально я пытался readdirp с использованием readdirp, но, к сожалению, эта библиотека кажется мне readdirp. Запустив его в моей системе, я получил противоречивые результаты. Один прогон выдаст 10 Мб данных, другой прогон с теми же входными параметрами выведет 22 Мб, затем я получу другое число и т.д. Я посмотрел на код и обнаружил, что он не учитывает возвращаемое значение push:

_push(entry) {
    if (this.readable) {
      this.push(entry);
    }
}

Согласно документации, метод push может возвращать false значение, и в этом случае поток Readable должен прекратить производить данные и ждать _read вызова _read. readdirp полностью игнорирует эту часть спецификации. Крайне важно обратить внимание на возвращаемое значение push чтобы получить правильную обработку противодавления. Есть и другие вещи, которые казались сомнительными в этом коде.

Поэтому я отказался от этого и работал над проверкой концепции, показывающей, как это можно сделать. Важнейшие части:

  1. Когда метод push возвращает false необходимо прекратить добавление данных в поток. Вместо этого мы записываем, где мы были, и останавливаемся.

  2. Мы начинаем снова только тогда, когда _read вызывается.

Если вы раскомментируете операторы console.log которые выводят START и STOP. Вы увидите их распечатанные последовательно на консоли. Мы начинаем, производим данные до тех пор, пока Node не скажет нам остановиться, а затем мы остановимся, пока Node не скажет нам начать снова, и так далее.

const stream = require("stream");
const fs = require("fs");
const { readdir, lstat } = fs.promises;
const path = require("path");

class Walk extends stream.Readable {
  constructor(root, maxDepth = Infinity) {
    super();

    this._maxDepth = maxDepth;

    // These fields allow us to remember where we were when we have to pause our
    // work.

    // The path of the directory to process when we resume processing, and the
    // depth of this directory.
    this._curdir = [root, 1];

    // The directories still to process.
    this._dirs = [this._curdir];

    // The list of files to process when we resume processing.
    this._files = [];

    // The location in 'this._files' were to continue processing when we resume.
    this._ix = 0;

    // A flag recording whether or not the fetching of files is currently going
    // on.
    this._started = false;
  }

  async _fetch() {
    // Recall where we were by loading the state in local variables.
    let files = this._files;
    let dirs = this._dirs;
    let [dir, depth] = this._curdir;
    let ix = this._ix;

    while (true) {
      // If we've gone past the end of the files we were processing, then
      // just forget about them. This simplifies the code that follows a bit.
      if (ix >= files.length) {
        ix = 0;
        files = [];
      }

      // Read directories until we have files to process.
      while (!files.length) {
        // We've read everything, end the stream.
        if (dirs.length === 0) {
          // This is how the stream API requires us to indicate the stream has
          // ended.
          this.push(null);

          // We're no longer running.
          this._started = false;
          return;
        }

        // Here, we get the next directory to process and get the list of
        // files in it.
        [dir, depth] = dirs.pop();

        try {
          files = await readdir(dir, { withFileTypes: true });
        }
        catch (ex) {
          // This is a proof-of-concept. In a real application, you should
          // determine what exceptions you want to ignore (e.g. EPERM).
        }
      }

      // Process each file.
      for (; ix < files.length; ++ix) {
        const dirent = files[ix];
        // Don't include in the results those files that are not directories,
        // files or symbolic links.
        if (!(dirent.isFile() || dirent.isDirectory() || dirent.isSymbolicLink())) {
          continue;
        }

        const fullPath = path.join(dir, dirent.name);
        if (dirent.isDirectory() & depth < this._maxDepth) {
          // Keep track that we need to walk this directory.
          dirs.push([fullPath, depth + 1]);
        }

        // Finally, we can put the data into the stream!
        if (!this.push('${fullPath}\n')) {
          // If the push returned false, we have to stop pushing results to the
          // stream until _read is called again, so we have to stop.

          // Uncomment this if you want to see when the stream stops.
          // console.log("STOP");

          // Record where we were in our processing.
          this._files = files;
          // The element at ix *has* been processed, so ix + 1.
          this._ix = ix + 1;
          this._curdir = [dir, depth];

          // We're stopping, so indicate that!
          this._started = false;
          return;
        }
      }
    }
  }

  async _read() {
    // Do not start the process that puts data on the stream over and over
    // again.
    if (this._started) {
      return;
    }

    this._started = true; // Yep, we've started.

    // Uncomment this if you want to see when the stream starts.
    // console.log("START");

    await this._fetch();
  }
}

// Change the paths to something that makes sense for you.
stream.pipeline(new Walk("/home/", 5),
                fs.createWriteStream("/tmp/paths3.txt"),
                (err) => console.log("ended with", err));

Когда я запускаю первую попытку, которую вы сделали здесь с помощью walkdir, я получаю следующую статистику:

  • Истекшее время (настенные часы): 59 сек
  • Максимальный размер резидентного набора: 2,90 ГБ

Когда я использую код, который я показал выше:

  • Истекшее время (настенные часы): 35 сек
  • Максимальный размер резидентного набора: 0,1 ГБ

Дерево файлов, которое я использую для тестов, выдает список файлов размером 792 МБ

Ответ 2

Вы можете использовать возвращаемое значение из WritableStream.write(): оно, по сути, заявляет, продолжать читать или нет. WritableStream имеет внутреннее свойство, которое хранит порог, после которого буфер должен обрабатываться ОС. drain будет сгенерировано событие, когда буфер был покраснело, то есть вы можете позвонить смело называть WritableStream.write() без риска чрезмерного заполнения буфера (что означает RAM). К счастью для вас, walkdir позволяет вам контролировать процесс: вы можете выдать pause (приостановить прогулку. Больше событий не будет отправлено до возобновления) и resume (возобновить прогулку) событие из объекта walkdir, приостановив и возобновив процесс записи на вас. поток соответственно. Попробуйте с этим:

let is_emitter_paused = false;
wstream.on('drain', (evt) => {
    if (is_emitter_paused) {
        walkdir.resume();
    }
});

walkdir.on('path', function(path, stat) {
    is_emitter_paused = !wstream.write(path + '\n');

    if (is_emitter_paused) {
        walkdir.pause();
    }
});

Ответ 3

Вот реализация, вдохновленная ответом @Louis. Я думаю, что немного легче следовать, и в моем минимальном тестировании он работает примерно так же.

const fs = require('fs');
const path = require('path');
const stream = require('stream');

class Walker extends stream.Readable {
    constructor(root = process.cwd(), maxDepth = Infinity) {
        super();

        // Dirs to process
        this._dirs = [{ path: root, depth: 0 }];

        // Max traversal depth
        this._maxDepth = maxDepth;

        // Files to flush
        this._files = [];
    }

    _drain() {
        while (this._files.length > 0) {
            const file = this._files.pop();
            if (file.isFile() || file.isDirectory() || file.isSymbolicLink()) {
                const filePath = path.join(this._dir.path, file.name);
                if (file.isDirectory() && this._maxDepth > this._dir.depth) {
                    // Add directory to be walked at a later time
                    this._dirs.push({ path: filePath, depth: this._dir.depth + 1 });
                }
                if (!this.push('${filePath}\n')) {
                    // Hault walking
                    return false;
                }
            }
        }
        if (this._dirs.length === 0) {
            // Walking complete
            this.push(null);
            return false;
        }

        // Continue walking
        return true;
    }

    async _step() {
        try {
            this._dir = this._dirs.pop();
            this._files = await fs.promises.readdir(this._dir.path, { withFileTypes: true });
        } catch (e) {
            this.emit('error', e); // Uh oh...
        }
    }

    async _walk() {
        this.walking = true;
        while (this._drain()) {
            await this._step();
        }
        this.walking = false;
    }

    _read() {
        if (!this.walking) {
            this._walk();
        }
    }

}

stream.pipeline(new Walker('some/dir/path', 5),
    fs.createWriteStream('output.txt'),
    (err) => console.log('ended with', err));