Does calling fs.writeFileSync trigger a synchronous write to the file system?

If you are familiar with Node.js, or have at least heard of it, you've most likely heard that it uses non-blocking IO, and lets you do work asynchronously. One of the most basic APIs that Node provides is for the file system; With this API, you can read, write, remove, etc. files and do other file system related tasks and modifications.

This API follows a standard pattern of exposing 2 functions for each operation: one for asynchronous work, and the other for synchronous work. For example, if you want to read a file in Node you can do so asynchronously:

var fs = require('fs');
fs.readFile('/etc/passwd', function(err, buf) {
  console.log(buf.toString());
});

Node will continue executing any javascript code it encounters while reading the file. Once all javascript is done being executed and the file is ready, it will run the anonymous function and print the file contents.

You can do the same task synchronously:

var fs = require('fs');
var contents = fs.readFileSync('/etc/passwd').toString();
console.log(contents);

In this example, contents will be set to the contents of the file, and no javascript code will be executed while the file is being read.

The first approach is done asynchronously, and will return immediately to not block your code from running. The second is done synchronously, and will halt execution until the task has completed. The same 2 types of functions exist for writing, renaming, deleting, etc. files.

Synchronous Writes

So the question is, does calling fs.writeFileSync actually trigger a synchronous write to the file system? In the userland Node process, it's synchronous in the sense that execution of any javascript is halted, but what about in the Kernel? An asynchronous write is a very different thing from a synchronous write to a file system.

For the rest of this blog post I'll be speaking within the context of the Illumos Kernel, and the ZFS File System.

There are a couple ways to answer this question. The most obvious way is to pull the Node.js source code, find the functions that talk to the file system that fs.js uses, and see how they are called. I haven't done much work on the Node core, and know it could (and most likely would) take a long time to find the code I was looking for. Instead, I'll just use DTrace to answer the question, and see exactly what Node is doing.

DTrace to the Rescue

I wrote a couple test programs that exercise these file system functions. Using DTrace, we'll be able to see what flags a file is opened with, which will show whether the operations are synchronous or not.

fs.writeFile()

// writefile.js
var fs = require('fs');
fs.writeFile('/tmp/fs.tmp', '', function(err) {
  if (err) throw err;
});

This script exercises Node's asynchronous file writing mechanism. Using DTrace, we can print the flags that were passed to open(2) for that specific file. Then, using fileflags, we can turn that decimal into the symbolic names that make up the decimal (see open(2) for more information).

$ sudo dtrace -qn 'syscall::open*:entry /pid == $target && copyinstr(arg0) == "/tmp/fs.tmp"/ { printf("%s: %d", probefunc, arg1); }' -c 'node writefile.js'
open64: 769
$ fileflags 769
  769: O_WRONLY|O_CREAT|O_TRUNC

The first command tells DTrace to run node writefile.js, and look for any of the open family of syscalls. If the first argument to open (the pathname) matches the file we are writing to, print out the exact syscall fired, and the flags decimal.

It turns out that open64(2) was called for our file, given the following options.

  • O_WRONLY: open write-only
  • O_CREAT: create the file if it doesn't exist
  • O_TRUNC: truncate the file

Fairly standard options to open a file. Since none of the options are for synchronous IO (O_SYNC, O_DSYNC, etc.) this file write is asynchronous to ZFS, and the call to write(2) returns before the data is guaranteed to be sitting on stable storage.

Node's asynchronous fs.writeFile does indeed do an asynchronous write to the file system.

fs.writeFileSync()

So what about Node's synchronous file writing mechanism, is it an actual synchronous write to the file system?

// writefilesync.js
var fs = require('fs');
fs.writeFileSync('/tmp/fs.tmp', '');

This script will block the event loop while the data is written to the file (or so we think), as it uses Node's synchronous file writing mechanism.

$ sudo dtrace -qn 'syscall::open*:entry /pid == $target && copyinstr(arg0) == "/tmp/fs.tmp"/ { printf("%s: %d", probefunc, arg1); }' -c 'node writefilesync.js'
open64: 769
$ fileflags 769
  769: O_WRONLY|O_CREAT|O_TRUNC

Same commands as above, and the same output.

Node's fs.writeFileSync does NOT initiate a synchronous write to the file system.

From the perspective of a Node program, we know the same thing when a call to fs.writeFileSync returns, as we know when the callback to fs.writeFile is fired. We know the underlying call, write(2) has returned; We do NOT know that the data has made it to stable storage. The only difference then, is that one function blocks Node's event loop, while the other allows it to continue processing events.

fs.createWriteStream()

Another mechanism that allows file IO is to create, and write to, a Node WritableStream.

// writestream.js
var fs = require('fs');
fs.createWriteStream('/tmp/fs.tmp');
$ sudo dtrace -qn 'syscall::open*:entry /pid == $target && copyinstr(arg0) == "/tmp/fs.tmp"/ { printf("%s: %d", probefunc, arg1); }' -c 'node writestream.js'
open64: 769
$ fileflags 769
  769: O_WRONLY|O_CREAT|O_TRUNC

Same output as above, again. This mechanism opens the file with the same flags as both fs.writeFile and fs.writeFileSync.

fs.appendFile()

So writing to a file uses the same flags for opening the file, what about appending? Same drill as above

// apendfile.js
var fs = require('fs');
fs.appendFile('/tmp/fs.tmp', '', function(err) {
  if (err) throw err;
});
$ sudo dtrace -qn 'syscall::open*:entry /pid == $target && copyinstr(arg0) == "/tmp/fs.tmp"/ { printf("%s: %d", probefunc, arg1); }' -c 'node appendfile.js'
open64: 265
$ fileflags 265
  265: O_WRONLY|O_APPEND|O_CREAT

So the flags are different, that's a good sign. O_TRUNC has been swapped out for O_APPEND, since we are no longer truncating the file to 0 bytes and instead are appending to it.

Again, like all the commands above, fs.appendFile opens the file for asynchronous IO.

fs.appendFileSync()

Last but not least let's test out the synchronous version of appendFile.

// appendfilesync.js
var fs = require('fs');
fs.appendFileSync('/tmp/fs.tmp', '');
$ sudo dtrace -qn 'syscall::open*:entry /pid == $target && copyinstr(arg0) == "/tmp/fs.tmp"/ { printf("%s: %d", probefunc, arg1); }' -c 'node appendfilesync.js'
open64: 265
$ fileflags 265
  265: O_WRONLY|O_APPEND|O_CREAT

Same as fs.appendFile; the file is NOT opened for synchronous writes.

Common Flags

Let's use a simple C program to open a file using fopen(3C) to see what flags it uses.

    /* fs.c */
    #include <stdio.h>
    int main(int argc, char **argv) {
            FILE *file = fopen("/tmp/fs-c.tmp", "w");
    }

Then run it with the same command as above to see what flags the file was opened with.

$ gcc fs.c -o fs
$ sudo dtrace -qn 'syscall::open*:entry /pid == $target && copyinstr(arg0) == "/tmp/fs-c.tmp"/ { printf("%s: %d", probefunc, arg1); }' -c './fs'
open: 769
$ fileflags 769
  769: O_WRONLY|O_CREAT|O_TRUNC

Sure enough, the same flags as opening a file for writing in Node land.

Results

fs.writeFileSync is synchronous in the sense that it blocks the event loop while it executes. It does NOT ask the Kernel to do a synchronous write to the underlying file system.

This script will block the event loop while the data is written to the file (or so we think)...

None of the functions above open files for synchronous IO. Because of this, all we know is that the call to write(2) returns, not that the data has been written to the file system and flushed to stable storage. Don't get tripped up on the names, fs.writeSync doesn't synchronously write to the file system.

If you want to open a file for synchronous IO, you'll have to use the lower level fs functions that Node offers such as fs.open() and fs.fsync().