A Go-inspired async library based on ES6 generators
Ceci's take on concurrency is inspired by Go's channels and goroutines, and by the core.async library for Clojure. Both are strongly influenced by Tony Hoare's theory of communicating sequential processes (CSP), and somewhat related to the classical Unix concept of pipes. The common feature of all these approaches is the idea of providing a single communication mechanism, usually called a channel, between concurrent threads of execution (processes, threads, goroutines etc) with semantics that make it both practical and comparatively easy to reason about.
While the implementation of channels is left to the ceci-channels library, the aim of ceci-core is to build a solid foundation for this task while providing features that are also useful on their own. It is in some ways similar to libraries such as co which integrate asynchronous, non-blocking calls into a more traditional control flow through the use of ES6 generators, but puts a higher emphasis on composability and seamless concurrency.
Install as a Node package:
npm install ceci-core
For easier integration, precompiled code (via regenerator) is included that runs on ES5 engines without generator support. To use this version, require it as follows:
var cc = require'ceci-core';
When running on a JS engine that supports generators directly, such as NodeJS 0.11.x with the
--harmony option, use the following line instead:
var cc = require'ceci-core/es6';
Find the full API documentation here.
var cc = require'ceci-core';console.log"I am main";ccgoyield console.log"I am go block 1";yield console.log"I am go block 1";;ccgoyield console.log"I am go block 2";yield console.log"I am go block 2";;console.log"I am also main";
The output looks like this:
I am main I am also main I am go block 1 I am go block 2 I am go block 1 I am go block 2
Two go blocks are created by calling the
go function with a generator argument (using the
function* keyword). The blocks run after the main program is finished. Whenever an expression preceded by
yield is encountered, the current go block pauses after evaluating the expression, so that the other one can run.
Things get more interesting when we add asynchronous calls to the mix. The following code wraps a Node-style callback into a deferred value:
var fs = require'fs';var cc = require'ceci-core';varvar result = ccdefer;fsreadFilenameif errresultrejecterr;elseresultresolveval;;return result;;
This pattern will look quite familiar to those who have worked with promises, but Ceci's deferreds are much simpler. Here's how we can use them in go blocks:
ccgoconsole.logyield readFile'package.json'length;console.logyield readFile'LICENSE'length;console.logyield readFile'README.md'length;;
The output looks something like this:
885 1090 8753
yield with an expression that evaluates to a deferred suspends the current go block. When the deferred is resolved, the block is scheduled to be resumed with the resulting value. From inside the block, this looks exactly like a blocking function call, except for the fact that we needed to add the
The code above reads the three files sequentially. We can instead read in parallel while still keeping the output in order by separating the function calls from the
yield statements that force the results:
ccgovar a = readFile'package.json';var b = readFile'LICENSE';var c = readFile'README.md';console.logyield alength;console.logyield blength;console.logyield clength;;
Finally, we can split the code into independent go routines that run concurrently:
varccgoconsole.logfilename + ':' yield readFilefilenamelength;;;showLength'package.json';showLength'LICENSE';showLength'README.md';
The order of the output lines now depends on which reads finished first and can be different between runs.
Another point worth noting is that Ceci's deferreds are not meant to be passed along and shared like promises. They are basically throw-away objects with the single purpose of decoupling the producer and consumer of a value. This is because Ceci's higher-level facilities for composing asynchronous computations are based on blocking channels as in Go rather than promises, and the extra functionality such as support for multiple callbacks or chaining is not needed at this level. That said, Ceci also lets us apply a
yield directly to a promise, which can come in handy when working with libraries that already provide these. To demonstrate, here's a drop-in replacement for the
readFile function above using the q library:
var Q = require'q';var fs = require'fs';var readFile = QnbindfsreadFile fs;
To be useful in practice, go blocks need to be able to return values, so that we can reuse smaller building blocks to form larger ones and finally whole programs. The return value of a
go call is simply a deferred that will resolve to the return value of the generator that defines the go block. To see this in action, let's write a
fileLength function based on
varreturn ccgoreturn yield readFilenamelength;;;
This allows us to rewrite the original 'main' function like this:
ccgoconsole.logyield fileLength'package.json';console.logyield fileLength'LICENSE';console.logyield fileLength'README.md';;
Note that the value returned from within the go block will always be wrapped in a deferred, even if it already is a deferred. It is therefore not uncommon to see a return statement of the form
return yield x;.
If you've tried any of the examples above, you may have noticed that we don't see anything like a top-level stack trace when things go wrong, for example when a file to be read does not exist. Instead of working with fixed file names in our example, we can try taking a command line argument to see this more clearly:
Now if we run the program with an existing file, we get a number. For a non-existent file, we get no output and no error messages whatsoever. Let's fix this:
On my system, this produces something like this:
Error: Error: ENOENT, open 'package.jsonx'at /home/olaf/Projects/Ceci/ceci-core/test.js:9:21at fs.js:195:20at Object.oncomplete (fs.js:97:15)
In my version of the code, line 9 happens to be where
readFile rejects the deferred it returns in case of an error. So we see that rejected deferreds manifest as exceptions when forced via a
yield. We also see that errors can bubble up through a chain of nested go blocks. More precisely, an uncaught exception within a go block causes the deferred result of that block to be rejected, which in turn leads to an exception in the calling go block when that result is forced, and so on.
Ceci provides a little utility wrapper for handling uncaught exceptions on a 'top level' deferred:
This produces the same stack trace as above.
Ceci's error handling has a few subtleties: first, errors can only be propagated outward if each nested go block in the chain is actually forced with a
fileLength or the 'main' go block in the above.
To fix the last problem, ceci-core has a global option
longStackSupport (named after the analogous option for the q library) which can be used as follows:
cclongStackSupport = true;cctopccgoconsole.logyield fileLengthprocessargv2;;
With this switch on, I see something like this:
Error: Error: ENOENT, open 'package.jsonx'at /home/olaf/Projects/Ceci/ceci-core/test.js:9:21at fs.js:195:20at Object.oncomplete (fs.js:97:15)at Object.Ceci.go (/home/olaf/Projects/Ceci/ceci-core/lib/src/core.js:49:45)at fileLength (/home/olaf/Projects/Ceci/ceci-core/test.js:18:13)at /home/olaf/Projects/Ceci/ceci-core/test.js:26:21at Object.Ceci.go (/home/olaf/Projects/Ceci/ceci-core/lib/src/core.js:49:45)at Object.<anonymous> (/home/olaf/Projects/Ceci/ceci-core/test.js:25:4)[...]
Much more useful!
longStackSupport incurs some extra memory and runtime costs for each go block execution, so it is probably best to only use it in development.
Ceci provides a few helpers that make interoperating with libraries that use NodeJS-style callback conventions easier. First, there is
ncallback which takes a deferred and returns a callback that resolves or rejects that deferred depending on its argument. This allows us to simplify the original
readFile function from the Deferreds section like this:
var fs = require'fs';var cc = require'ceci-core';varvar result = ccdefer;fsreadFilename ccncallbackresult;return result;;
Going one step further, the
nbind function takes a function that accepts a callback and returns one that produces a deferred:
var readFile = ccnbindfsreadFile;
Additional arguments can be given, which work just like in
Going the other direction,
nodeify take a deferred and an optional callback. If used with no callback, it simply returns the deferred. Otherwise, it executes the callback accordingly when the deferred is resolved or rejected:
ccnodeifyfileLengthprocessargv2if errconsole.log'Oops:' err;elseconsole.logval;;
Go blocks and deferreds get us out of "callback hell" and avoid the typical fragmentation of program logic associated with asynchronous programming. The next layer of the library, ceci-channels provides blocking channels, borrowed from the Go language, as a clean way for concurrent go blocks to exchange information.
Copyright (c) 2014 Olaf Delgado-Friedrichs.
Distributed under the MIT License.