1

From reading some posts on Stack Overflow regarding synchronous vs async, it seems like async should have small overhead, or be faster than synchronous calls for blocking I/O operations:

Some places I've looked into: Is non-blocking I/O really faster than multi-threaded blocking I/O? How? What is the overhead of Javascript async functions

I've wrote a small benchmark that makes 4 files of 256MB to 1GB to see the performance of 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();

Output:

> 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

So it appears the async version is ~7x slower than the synchronous version. How can this slowdown be explained? When should someone use the synchronous version?

Repl.it link: https://repl.it/repls/VioletredFatherlyDaemons

System: Node 13.9.0 on Arch linux 5.5.4-arch1-1

Bob
  • 689
  • 10
  • 11
  • Synchronous file I/O blocks the event loop so that your node.js can do NOTHING else while the synchronous file operations are processing. That would ruin the scalability and performance of a server. It's not about a difference in performance of a single operation. It's about allowing node.js to do OTHER things while the file I/O is in process and not blocking the entire event loop. – jfriend00 Apr 04 '20 at 18:48
  • So is there a general use case for sync I/O other than initialization/startup procedures? – Bob Apr 04 '20 at 19:11
  • 1
    In a server, not really because anywhere else it wrecks your server scalability. But, there are many uses for node.js that aren't a server. For example, I have build scripts and disk maintenance scripts that are single user and just less complicated to write and debug with sync file I/O. – jfriend00 Apr 04 '20 at 19:21
  • FYI, I ported your script to Windows and got `Sync version took 1502.4960010051727ms Async version took 2460.849498987198ms`. Still Sync was faster, but only 63% faster, not 700% faster. Not sure what's going on when you run it. – jfriend00 Apr 04 '20 at 19:25
  • FYI, I was running node v12.13.1 on Windows 10. – jfriend00 Apr 04 '20 at 19:32
  • It is a bit of a mystery why the run time of the asynchronous operation is so much slower end-to-end time. I get that there's a little bit of overhead for resolving a promise and going back to the event loop to process the next step, but relative to the disk I/O time itself that overhead should be really tiny. It seems like there is something amiss in the asynchronous implementation. – jfriend00 Apr 04 '20 at 19:47
  • You are even running the asynchronous operations in parallel (all in flight at the same time) which should, in theory, allow them to make up a bit of time, but that is not the case (though they are all contending for the same disk head when it comes time to read data from the actual physical disk so that can't actually be parallelized). – jfriend00 Apr 04 '20 at 19:49
  • In running some tests of my own, I notice that there's a lot of memory management going on as you repeatedly load multiple pretty large files into memory. You may be seeing some differences in memory efficiency and how much each implementation is taxing the garbage collector. In fact, I was able to write an async version using streams and a large stream buffer that was nearly as fast as the synchronous version. But, I had to collect the data into an array of chunks or I would run out of memory. So, it was taking a lot of memory to run. – jfriend00 Apr 04 '20 at 20:24

1 Answers1

5

See edits for Version 2 below for a faster version.

Version 1

FYI, in addition to all my comments above, here's the fastest I could get an asynchronous version:

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`);
}

And, here are the results:

And, here are my results on Windows 10, node 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

Note, I modified the scheme a bit to pass an array of filenames into each test rather than create the filenames each time so I could centralize the creation of the files.

The things that helped me speed it up were:

  1. Using a larger highWaterMark which is presumably stream buffer size
  2. Collecting data in an array and then concatenating it at the end (this drastically reduces peak memory consumption and GC work).
  3. Allowing the different files in the loop to run in parallel with each other

With these changes, it's about the same speed as the synchronous version, sometimes a bit slower, sometimes about the same.

I also put a delay of 2 seconds between the running of each test and forced a run of the garbage collector to make sure GC running wasn't messing with my results.

Here's my whole script which can run on any platform. Note that you must use the --expose_gc command line parameter as in 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();

Version 2

Then, I figured out that for files under 2GB, we can pre-allocate a buffer for the whole file and read them in a single read and that can be even faster. This version adds several new options for syncTestSingleRead(), asyncTestSingleReadSerial() and asyncTestSingleReadParallel().

These new options are all faster and, for once, the asynchronous options are consistently faster than the synchronous options:

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

And, the code that matches these:

// 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();
jfriend00
  • 683,504
  • 96
  • 985
  • 979