Почему асин c производительность хуже синхронна при блокировке файлового ввода-вывода в Node.js? - PullRequest
1 голос
/ 04 апреля 2020

При чтении некоторых сообщений о переполнении стека относительно синхронного и асинхронного c кажется, что асин c должен иметь небольшие издержки или быть быстрее, чем синхронные вызовы для блокировки операций ввода-вывода:

Некоторые места, которые я изучил: Действительно ли неблокирующий ввод-вывод быстрее, чем многопоточный блокирующий ввод-вывод? Как? Каковы издержки Javascript asyn c функций

Я написал небольшой тест, который показывает 4 файла размером от 256 МБ до 1 ГБ, чтобы увидеть производительность fs.readFile().

const {performance} = require('perf_hooks');
const fs = require('fs');
const {execSync} = require("child_process");

const sizes = [512, 1024, 256, 512]; //file sizes in MiB
function makeFiles() {
    for (let i = 0; i < sizes.length; i++) {
        execSync(`dd if=/dev/urandom of=file-${i}.txt bs=1M count=${sizes[i]}`, (error, stdout, stderr) => {
            console.log(`stdout: ${stdout}`);
        });
    }
}

function syncTest() {
    const startTime = performance.now();
    const results = [];

    for (let i = 0; i < sizes.length; i++) {
        results.push(fs.readFileSync(`file-${i}.txt`));
    }
    console.log(`Sync version took ${performance.now() - startTime}ms`);
}

async function asyncTest() {
    const startTime = performance.now();
    const results = [];

    for (let i = 0; i < sizes.length; i++) {
        results.push(fs.promises.readFile(`file-${i}.txt`));
    }
    await Promise.all(results);

    console.log(`Async version took ${performance.now() - startTime}ms`);
}

makeFiles();
syncTest();
asyncTest();

Вывод:

> makeFiles();

512+0 records in
512+0 records out
536870912 bytes (537 MB, 512 MiB) copied, 4.28077 s, 125 MB/s
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 8.45918 s, 127 MB/s
256+0 records in
256+0 records out
268435456 bytes (268 MB, 256 MiB) copied, 1.96678 s, 136 MB/s
512+0 records in
512+0 records out
536870912 bytes (537 MB, 512 MiB) copied, 4.32488 s, 124 MB/s
undefined
> syncTest();
Sync version took 1055.9131410121918ms
undefined
> asyncTest();
Promise { <pending> }
> Async version took 6991.523499011993ms

Таким образом, версия asyn c оказывается в 7 раз медленнее, чем синхронная версия. Чем можно объяснить это замедление? Когда кто-то должен использовать синхронную версию?

Ссылка Repl.it: https://repl.it/repls/VioletredFatherlyDaemons

Система: Узел 13.9.0 в Arch linux 5.5.4-arch1 -1

1 Ответ

1 голос
/ 04 апреля 2020

См. Правки для Версии 2 ниже для более быстрой версии.

Версия 1

К вашему сведению, в дополнение ко всем моим комментариям выше, вот самый быстрый, который я мог получить асинхронная версия:

async function asyncTestStreamParallel(files) {
    const startTime = performance.now();
    let results = [];

    for (let filename of files) {
        results.push(new Promise((resolve, reject) => {
            const stream = fs.createReadStream(filename, {highWaterMark: 64 * 1024 * 10});
            const data = [];
            stream.on('data', chunk => {
                data.push(chunk);
            }).on('end', () => {
                resolve(Buffer.concat(data));
            }).on('error', reject);
        }));
    }
    await Promise.all(results);

    console.log(`Async stream parallel version took ${performance.now() - startTime}ms`);
}

И вот результаты:

И вот мои результаты на Windows 10, узел v12.13.1:

node --expose_gc temp
Sync version took 1175.2680000066757ms
Async version took 2315.0439999699593ms
Async stream version took 1600.0085990428925ms
Async stream parallel version took 1111.310200035572ms
Async serial version took 4387.053400993347ms

Обратите внимание, я немного изменил схему, чтобы передать массив имен файлов в каждый тест, а не создавать имена файлов каждый раз, чтобы я мог централизовать создание файлов.

Вещи, которые помогли мне ускорить его были:

  1. Использование большего highWaterMark, который предположительно имеет размер потокового буфера
  2. Сбор данных в массиве и последующее объединение их в конце (это резко снижает пиковое потребление памяти и G C работа).
  3. Разрешение различным файлам в l oop работать параллельно друг другу

С этими изменениями скорость примерно такая же, как у синхронного версия, иногда немного медленнее, иногда Время примерно одинаковое.

Я также установил задержку в 2 секунды между запуском каждого теста и принудительно запустил сборщик мусора, чтобы убедиться, что запуск G C не мешает моим результатам.

Вот весь мой скрипт, который может работать на любой платформе. Обратите внимание, что вы должны использовать параметр командной строки --expose_gc, как в node --expose_gc temp.js:

// Run this with the --expose_gc command line option

const {performance} = require('perf_hooks');
const fs = require('fs');
const path = require('path')

const sizes = [512, 1024, 256, 512];   // file sizes in MB
const data = "0123456789\n";
const testDir = path.join(__dirname, "bigfile"); 

function makeFiles() {
    // make a bigger string to make fewer disk writes
    const bData = [];
    for (let i = 0; i < 1000; i++) {
        bData.push(data);
    }
    const biggerData = bData.join("");
    try {
        fs.mkdirSync(testDir);    // ignore errors if it already exists
    } catch(e) {
        // do nothing if it already exists
    }
    const files = [];

    for (let i = 0; i < sizes.length; i++) {
        let targetLen = sizes[i] * 1024 * 1024;
        let f;
        try {
            let fname = `${path.join(testDir, "test")}-${i}.txt`;
            f = fs.openSync(fname, 'w');
            files.push(fname);
            let len = 0;
            while (len < targetLen) {
                fs.writeSync(f, biggerData);
                len += biggerData.length;
            }
        } catch(e) {
            console.log(e);
            process.exit(1);
        } finally {
            if (f) fs.closeSync(f);
        }
    }
    return files;
}

function clearFiles(files) {
    for (let filename of files) {
        fs.unlinkSync(filename);
    }
    fs.rmdirSync(testDir);

}

function syncTest(files) {
    const startTime = performance.now();
    const results = [];

    for (let filename of files) {
        results.push(fs.readFileSync(filename));
    }
    console.log(`Sync version took ${performance.now() - startTime}ms`);
}

async function asyncTest(files) {
    const startTime = performance.now();
    const results = [];

    for (let filename of files) {
        results.push(fs.promises.readFile(filename));
    }
    await Promise.all(results);

    console.log(`Async version took ${performance.now() - startTime}ms`);
}

async function asyncTestStream(files) {
    const startTime = performance.now();

    for (let filename of files) {
        await new Promise((resolve, reject) => {
            let stream = fs.createReadStream(filename, {highWaterMark: 64 * 1024 * 10});
            let data = [];
            stream.on('data', chunk => {
                data.push(chunk);
            }).on('close', () => {
                resolve(Buffer.concat(data));
            }).on('error', reject);
        });
    }

    console.log(`Async stream version took ${performance.now() - startTime}ms`);
}

async function asyncTestStreamParallel(files) {
    const startTime = performance.now();
    let results = [];

    for (let filename of files) {
        results.push(new Promise((resolve, reject) => {
            const stream = fs.createReadStream(filename, {highWaterMark: 64 * 1024 * 100});
            const data = [];
            stream.on('data', chunk => {
                data.push(chunk);
            }).on('end', () => {
                resolve(Buffer.concat(data));
            }).on('error', reject);
        }));
    }
    await Promise.all(results);

    console.log(`Async stream parallel version took ${performance.now() - startTime}ms`);
}

async function asyncTestSerial(files) {
    const startTime = performance.now();
    const results = [];

    for (let filename of files) {
        results.push(await fs.promises.readFile(filename));
    }

    console.log(`Async serial version took ${performance.now() - startTime}ms`);
}

function delay(t) {
    return new Promise(resolve => {
        global.gc();
        setTimeout(resolve, t);
    });
}

// delay between each test to let any system stuff calm down
async function run() {
    const files = makeFiles();
    try {
        await delay(2000);
        syncTest(files);

        await delay(2000);
        await asyncTest(files)

        await delay(2000);
        await asyncTestStream(files);

        await delay(2000);
        await asyncTestStreamParallel(files);

        await delay(2000);
        await asyncTestSerial(files);
    } catch(e) {
        console.log(e);
    } finally {
        clearFiles(files);
    }
}

run();

Версия 2

Затем я выяснил, что для файлов в 2 ГБ, мы можем предварительно выделить буфер для всего файла и прочитать их за одно чтение, и это может быть еще быстрее. В этой версии добавлено несколько новых опций для syncTestSingleRead(), asyncTestSingleReadSerial() и asyncTestSingleReadParallel().

Все эти новые опции работают быстрее, и на этот раз асинхронные опции всегда быстрее, чем синхронные:

node --expose_gc temp
Sync version took 1602.546700000763ms
Sync single read version took 680.5937000513077ms
Async version took 2337.3639990091324ms
Async serial version took 4320.517499983311ms
Async stream version took 1625.9839000105858ms
Async stream parallel version took 1119.7469999790192ms
Async single read serial version took 580.7244000434875ms
Async single read parallel version took 360.47460001707077ms

И код, который соответствует этим:

// Run this with the --expose_gc command line option

const {performance} = require('perf_hooks');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path')

const sizes = [512, 1024, 256, 512];   // file sizes in MB
const data = "0123456789\n";
const testDir = path.join(__dirname, "bigfile"); 

function makeFiles() {
    // make a bigger string to make fewer disk writes
    const bData = [];
    for (let i = 0; i < 1000; i++) {
        bData.push(data);
    }
    const biggerData = bData.join("");
    try {
        fs.mkdirSync(testDir);    // ignore errors if it already exists
    } catch(e) {
        // do nothing if it already exists
    }
    const files = [];

    for (let i = 0; i < sizes.length; i++) {
        let targetLen = sizes[i] * 1024 * 1024;
        let f;
        try {
            let fname = `${path.join(testDir, "test")}-${i}.txt`;
            f = fs.openSync(fname, 'w');
            files.push(fname);
            let len = 0;
            while (len < targetLen) {
                fs.writeSync(f, biggerData);
                len += biggerData.length;
            }
        } catch(e) {
            console.log(e);
            process.exit(1);
        } finally {
            if (f) fs.closeSync(f);
        }
    }
    return files;
}

function clearFiles(files) {
    for (let filename of files) {
        fs.unlinkSync(filename);
    }
    fs.rmdirSync(testDir);
}

function readFileSync(filename) {
    let handle = fs.openSync(filename, "r");
    try {
        let stats = fs.fstatSync(handle);
        let buffer = Buffer.allocUnsafe(stats.size);
        let bytesRead = fs.readSync(handle, buffer, 0, stats.size, 0);
        if (bytesRead !== stats.size) {
            throw new Error("bytesRead not full file size")
        }
    } finally {
        fs.closeSync(handle);
    }

}

// read a file in one single read
async function readFile(filename) {
    let handle = await fsp.open(filename, "r");
    try {
        let stats = await handle.stat();
        let buffer = Buffer.allocUnsafe(stats.size);
        let {bytesRead} = await handle.read(buffer, 0, stats.size, 0);
        if (bytesRead !== stats.size) {
            throw new Error("bytesRead not full file size")
        }
    } finally {
        handle.close()
    }
}



function syncTest(files) {
    const startTime = performance.now();
    const results = [];

    for (let filename of files) {
        results.push(fs.readFileSync(filename));
    }
    console.log(`Sync version took ${performance.now() - startTime}ms`);
}

function syncTestSingleRead(files) {
    const startTime = performance.now();
    const results = [];

    for (let filename of files) {
        readFileSync(filename);
    }
    console.log(`Sync single read version took ${performance.now() - startTime}ms`);
}

async function asyncTest(files) {
    const startTime = performance.now();
    const results = [];

    for (let filename of files) {
        results.push(fs.promises.readFile(filename));
    }
    await Promise.all(results);

    console.log(`Async version took ${performance.now() - startTime}ms`);
}

async function asyncTestStream(files) {
    const startTime = performance.now();

    for (let filename of files) {
        await new Promise((resolve, reject) => {
            let stream = fs.createReadStream(filename, {highWaterMark: 64 * 1024 * 10});
            let data = [];
            stream.on('data', chunk => {
                data.push(chunk);
            }).on('close', () => {
                resolve(Buffer.concat(data));
            }).on('error', reject);
        });
    }

    console.log(`Async stream version took ${performance.now() - startTime}ms`);
}

async function asyncTestStreamParallel(files) {
    const startTime = performance.now();
    let results = [];

    for (let filename of files) {
        results.push(new Promise((resolve, reject) => {
            const stream = fs.createReadStream(filename, {highWaterMark: 64 * 1024 * 100});
            const data = [];
            stream.on('data', chunk => {
                data.push(chunk);
            }).on('end', () => {
                resolve(Buffer.concat(data));
            }).on('error', reject);
        }));
    }
    await Promise.all(results);

    console.log(`Async stream parallel version took ${performance.now() - startTime}ms`);
}

async function asyncTestSingleReadSerial(files) {
    const startTime = performance.now();
    let buffer;
    for (let filename of files) {
        let handle = await fsp.open(filename, "r");
        try {
            let stats = await handle.stat();
            if (!buffer || buffer.length < stats.size) {
                buffer = Buffer.allocUnsafe(stats.size);
            }
            let {bytesRead} = await handle.read(buffer, 0, stats.size, 0);
            if (bytesRead !== stats.size) {
                throw new Error("bytesRead not full file size")
            }
        } finally {
            handle.close()
        }
    }
    console.log(`Async single read serial version took ${performance.now() - startTime}ms`);
}

async function asyncTestSingleReadParallel(files) {
    const startTime = performance.now();

    await Promise.all(files.map(readFile));

    console.log(`Async single read parallel version took ${performance.now() - startTime}ms`);
}

async function asyncTestSerial(files) {
    const startTime = performance.now();
    const results = [];

    for (let filename of files) {
        results.push(await fs.promises.readFile(filename));
    }

    console.log(`Async serial version took ${performance.now() - startTime}ms`);
}

function delay(t) {
    return new Promise(resolve => {
        global.gc();
        setTimeout(resolve, t);
    });
}

// delay between each test to let any system stuff calm down
async function run() {
    const files = makeFiles();
    try {
        await delay(2000);
        syncTest(files);

        await delay(2000);
        syncTestSingleRead(files);

        await delay(2000);
        await asyncTest(files)

        await delay(2000);
        await asyncTestSerial(files);

        await delay(2000);
        await asyncTestStream(files);

        await delay(2000);
        await asyncTestStreamParallel(files);

        await delay(2000);
        await asyncTestSingleReadSerial(files);

        await delay(2000);
        await asyncTestSingleReadParallel(files);
    } catch(e) {
        console.log(e);
    } finally {
        clearFiles(files);
    }
}

run();
...