The node-webworker module aims to implement as much of the HTML5 Web Workers API as is practical and useful in the context of NodeJS. Extensions to the HTML5 API are provided where it makes sense (e.g. to allow file descriptor passing).
Why bother to implement Web Workers for NodeJS? After all, child process
support is already provided by the
- A set of standard (well, emerging standard anyway)
platform-independent concurrency APIs is a useful abstraction.
are likely to familiar with Web Workers from doing browser
development. The set of NodeJS primitives for managing processes,
child_processprovides a lot of utility, but is easily misunderstood by developers who have not developed for a UNIX platform before (e.g. why does
child_process(e.g. one can get a stack trace, etc).
- Existing communication mechanisms with child processes
involve communicating over
stdout. Use of these built-in streams prevents
sys.puts()and friends from working as expected. Further, these are opaque byte streams and require the application to implement their own framing logic to discern message boundaries.
- HTML5 Shared Workers (also part of the same spec) provide a useful naming service for communicating with other workers by name. Without this, the application must maintain its own metadata for routing messages between workers. Note that shared workers are not yet implemented.
The design that follows for Web Workers is motivated by a handful of underlying assumptions / philosophies:
- Worker instances should be relatively long-lived. That is, it is not considered an important workload to be able to create and destroy thousands of workers as quickly as possible. Passing messages to existing workers to dispatch work items is favored over creating a new worker for each work item.
- In the future, it will be desirable to run workers off-box, and to implement workers in other application frameworks / languages. This is particularly relevant in the choice of communication medium.
Each worker executes in its own self-contained
node process rather
than as a separate thread and V8 context within the master process.
The benefits of this approach include fault isolation (any worker running out of memory or triggering some buggy C++ code will not take down other workers); avoiding the complexity of managing multiple event loops in a single process; and typical OSes are more likely to schedule different processes on different CPUs (this may not always happen for multiple threads within the same process), allowing the application to utilize multiple CPUs.
Of course, there are drawbacks including the cost of context switching between workers being more expensive when using a process-per-worker model than it would be in a thread-per-worker model; passing messages between processes typically requires a data copy and always requires serializing data; and the overhead of spawning a new process.
The worker context
Each worker is launched by
lib/webworker-child.js, which is handed
paths to the UNIX socket to use for communication with the parent
process (see below) and the worker application itself.
This script is passed to
node as the entry point for the process and
is responsible for constructing a V8 script context populated with
bits relevant to the Web Worker API (e.g. the
location primitives, etc). This also establishes communication
with the parent process and wires up the message send/receive listeners.
It's important to note that all of this happens in a context entirely
separate from the one in which the worker application will be executing;
the worker gets a seemingly plane-Jane Node runtime with the Web Worker
API bolted on. The worker application doesn't need to
additional libraries or anything.
The Web Workers spec describes a simple message passing API.
Under the covers, this is implemented by connecting each dedicated
worker to its parent process with a UNIX domain socket. This is lower
overhead than TCP, and allows for UNIX goodies like file descriptor
passing. Each master process creates dedicated UNIX socket for each
worker the path
is the PID of the process doing the creating, and
<worker-id> is an ID
of the worker being created. Although muddying up the filesystem
namespace doesn't thrill me, this makes the implementation easier than
listening on a single socket for all workers.
Message passing is done over this UNIX socket by negotiating an HTML5
Web Socket connection over this transport. This is done to provide
a reasonably-performant standards-based message framing implementation
and to lay the groundwork or communicating with off-box workers via HTTP
over TCP, which may be implemented in another application stack entirely
(e.g. Java, etc). The overhead of negotiating and maintaining the Web
Socket connection is 1 round trip for handshaking and the overhead of
maintaining HTTP state objects (
http_parser and such). The handshaking
overhead is not considered an undue burden given that workers are
expected to be relatively long-lived and the HTTP state overhead
The format of the messages themselves is JSON, serialized
JSON.stringify() and de-serialized using
Significantly, the use of a framing protocol allows the Web Workers
implementation to wait for an entire, complete JSON blob to arrive
JSON.parse(). Although not implemented, it should be
possible to negotiate supported content encoding (e.g. to support
MsgPack, BERT, etc) when setting up the Web Socket connection. The
JSON object is relatively performant
is quite a bit
Each object passed to
postMessage() is wrapped in an array like so
[<msg-type>, <object>]. This allows the receiving end of the message
to distinguish control messages (
ERROR, etc) from
Sending file descriptors
As mentioned above, this Web Workers implementation can take advantage
of node's ability to send file descriptors using UNIX sockets. As a
nonstandard extension to the
postMessage(obj [,<fd>]) API, an optional
file descriptor can be specified. On symmetric API extension was made on
the receiving end, where the
onmessage(obj [,<fd>]) handler is passed
a fd parameter if a file descriptor was received along with the
Unfortunately, UNIX sockets seem to allow file descriptors to arrive
out-of-band with respect to the data payload with which they were sent.
To tie a received file descriptor to the message with which it was sent,
all messages are wrapped in an array of the form
<obj> is the object passed to
parameter starts off at 0 and is incremented for every file descriptor
sent (the first file descriptor sent has a
<fd-seqno> of 1). This
provides the receiving end with enough metadata to tie out-of-band
descriptors together with their originating message.