Why coroutines won't work on the web
The Little Calculist 2013-03-15
The topic of coroutines (orfibers, or continuations) for JavaScript comes up from time to time,so I figured I’d write down my thoughts on the matter. I admit tohaving a soft spot for crazy control-flow features like continuations,but they’re unlikely ever to make it into ECMAScript. With goodreason.
The big justification for coroutines in JavaScript is non-blockingI/O. As we all know, asynchronous I/O leads to callback API’s, whichlead to nested lambdas, which lead to… the pyramid of doom:
1234567
range.on("preheat", function() { pot.on("boil", function() { rice.on("cooked", function() { dinner.serve(rice); }); });});
Whereas, if you look at the README fornode-fibers, you’ll seethis pleasant-looking example:
12345
Fiber.run(function() { console.log('wait...' + new Date); sleep(1000); console.log('ok...' + new Date);});
That looks pretty sweet. It’s a synchronous version of setTimeout
that doesn’t block the main thread. This seems like a nice combinationof the sequential style of synchronous code but with theresponsiveness of non-blocking I/O. Why wouldn’t we want somethinglike this in ECMAScript?
Coroutines are almost as pre-emptive as threads
Part of the beauty of JavaScript’s event loop is that there’s a veryclear synchronization point for reaching a stable state in yourprograms: the end of the current turn. You can go ahead and leavethings in a funky intermediate state for as long as you like, and aslong as you stitch everything back up in time for the next spin of theevent loop, no other code can run in the meantime. That means you canbe sure that while your object is lying in pieces on the floor, nobodyelse can poke at it before you put it back together again.
Once you add coroutines, you never know when someone might callyield
. Any function you call has the right to pause and resume youwhenever they want, even after any number of spins of the eventloop. Now any time you find yourself modifying state, you startworrying that calling a function might interrupt some code youintended to be transactional. Take something as simple as swapping acouple fields of an object:
123
var tmp = obj.foo;obj.foo = obj.bar;obj.bar = munge(tmp);
What happens if munge
does a yield
and only resumes your codeafter a few other events fire? Those events could interact with obj
,and they’d see it in this intermediate state where both obj.foo
andobj.bar
are the same value, because obj.bar
hasn’t yet beenupdated.
We’ve seen this movie before. This is just like Java’s threads, whereany time you’re working with state, you have to worry about who mighttry to touch your data before it reaches a stable point. To be fair,life is actually far worse in Java, where almost every single basicoperation of the language can be pre-empted. But still, withcoroutines, every function call becomes a potential pre-emption point.
Host frames make coroutines unportable
And then there’s the implementation problem. Unless your JavaScriptengine doesn’t use a stack (and they all do), coroutines would have tobe able to save a stack on the heap and restore it back on the stacklater. But what if JavaScript code calls into code implemented in thehost language (usually C++)? Some engines implement functions likeArray.prototype.forEach
in C++. How would they handle code likethis?
1234567
Fiber.run(function() { array.forEach(function(x) { console.log('wait: ' + x); sleep(1000); console.log('ok: ' + x); });});
Other languages with coroutines take different approaches. Lua allowsimplementations to throw an errorif user code tries to suspend host activations. This would simply beunportable, since different engines would implement different standardlibraries in C++.
The Scheme community tends to demand a lot from their continuations,so they expect functions like for-each
and map
to besuspended. This could mean either forcing all the standard librariesto be self-hosted, or using more complicated implementation strategiesthan traditional stacks.
Simply put: browser vendors are not going to do this. Modern JSengines are extraordinary feats of engineering, and rearchitectingtheir entire stack mechanism is just not realistic. Then when youconsider that these changes could hurt performance of ordinaryfunction calls, well… end of discussion.
Shallow coroutines to the rescue
OK, back to the pyramid of doom. It really does kind of suck. I mean,you could name and lift out your functions, but then you break up thesequential flow even worse, and you get a combinatorial explosion offunction arguments for all those upvars.
This is why I’m excited aboutgenerators. Generatorsare a lot like coroutines, with one important difference: they onlysuspend their own function activation. In ES6, yield
isn’t afunction that anyone can use, it’s a built-in operator that only agenerator function can use. With generators, calling a JS function isas benign as it ever was. You never have to worry that a function callmight yield
and stop you from doing what you were trying to do.
But it’s still possible to build an API similar to node-fibers. Thisis the idea of task.js. Thefibers example looks pretty similar in task.js:
12345
Task(function() { console.log('wait... ' + new Date); yield sleep(1000); console.log('ok... ' + new Date);}).run();
The big difference is that the sleep
function doesn’t implicitlyyield; instead, it returns apromise. Thetask then explicitly yield
s the promise back to the task.jsscheduler. When the promise is fulfilled, the scheduler wakes the taskback up to continue. Hardly any wordier than node-fibers, but with thebenefit that you can always tell when and what you’re suspending.
Coroutines no, generators yes
Coroutines are not going to happen in JavaScript. They would break oneof the best features of JavaScript: the simplicity of the event loopexecution model. And the demands they would place on current enginesfor portability are simply unrealistic. But generator functions areeasy to add to existing engines, they have none of the portabilityissues of coroutines, and they give you just enough power to writenon-blocking I/O in a synchronous style without being “threads lite.”