Go-like channels in 10 lines of JavaScript
pedrocattori.dev
pedrocattori.dev
Whether that is possible in JavaScript, I don't know. It is possible in a heavy-runtime language to be so locked down that it is either impossible to implement, or impossible to implement with acceptable performance, and I'm not into Node enough to know.
(To be clear, such a thing is not a criticism necessarily. I'm not sure if you could implement select efficiently or correctly from within pure Go, either, if it did not already exist. There's a lot of runtime integration it has that is not exposed any other way. It is s perfectly viable design decision to build a runtime environment that does not give that level of access to the CPU without custom assembly or C or something.)
Select is similar to a function in plan 9's threading library[1] (which is part of the Go lineage) called alt(). To use it you stick it in a switch like so: switch(alt(alts)){}. Select{} is syntactic sugar for some alt like functionality. Of course the programmer is responsible for setting up the alt structure and the channels it contains. Overall its a great library and I love working with thread(2).
See this wonderful article on Go' history in code for comparisons between Go, Limbo, Plan 9 C and Alef: https://seh.dev/go-legacy/
[1] http://man.postnix.pw/9front/2/thread
Might as well add a link to the source (that 9front repo is outdated, don't touch it): https://github.com/mischief/9problems/tree/master/sys/src/li...
A few days ago, I ported ocaml/Event [2] to JavaScript, which provides concurrent ML-style synchronization operations.
It is possible to implement `Channel` and `select` in JS, but it is not easy to provide an idiomatic API and integrate it with the Promise ecosystem.
[1]: https://github.com/dhcmrlchtdj/sync-op [2]: https://ocaml.org/api/Event.html
Ultimately though, I don't believe that channels are an abstraction that makes sense in JavaScript's concurrency model. Go's contexts, on the other hand, would be a huge improvement over AbortController and AbortSignal.
let channel = new EventEmitter()
await Promise.all([
compile.browser(channel),
compile.server(channel)
])
// in compile.browser
channel.emit("manifest", assetsManifest)
// in compile.server
channel.once("manifest", (assetsManifest) => ... )
A new emitter is used each time, so the end result is the same. Curious to hear what others think. const manifest = await createAssetsManifest();
compileBrowser(manifest);
compileServer(manifest);
I am wondering if since the creation of async/await that the idea behind callbacks and async operations in general in Node has been forgotten.Not referring to the author or anyone in particular. Just a thought since these keywords might used so often by engineers new to Node that they might not have ever learned what came before?
I _could_ refactor to have "browser compilation phase 1", then assets manifest, and then "browser compilation phase 2", but that's not how I model it in my head. Plus it would mean a diverging interface for `compileBrowser` and `compileServer` which doesn't fit my mental model either.
So prefer to use channels instead.
I think this is the central matter when it comes to primitives for asynchronous programming.
There exist many ways we can think about async tasks. The JavaScript ecosystem provides for multiple. i.e., event callbacks, async/await, generators, and more.
Programmer reach for tools which best match how we have learned to model these problems in our heads.
It's okay for people to use what works best for them.
const manifest = await createAssetsManifest();
await Promise.all([
compileBrowser(manifest),
compileServer(manifest)
]);
That makes for such nice, clear code!Yes and no.
At the “ECMAScript”-level, JavaScript has no built-in concurrency, but every serious JavaScript implementation enables proper concurrency via the async primitives and the internal scheduling.
Put simply, if you know how to write async code properly, your JavaScript code can achieve high concurrency!
Highly concurrent code need not execute in parallel. Concurrency enables parallelism; concurrency does NOT mandate parallelism.
On top of that, it actually is possible in JavaScript environments like Node.js to write code that can, in fact, run in parallel.
Promise.all([new Promise(t => setTimeout(t, 3000)), new Promise(t => setTimeout(t, 3000))]).then(() => console.log('done'))
You'll notice it prints `done` after 3 seconds, not after 6.It just happened to be executed one by one but the VM handles the switching for us.
What you're talking about is parallelism, which JavaScript indeed does lack and you'd use cluster or workers for that. That's when things happen at the same time, outsourced to different cores. This is what JavaScript cannot do (yet?).
If you could perform 2 tasks (e.g. console.log(Array(1e8).fill(0).map((a, i) => i)), and have both run to completion without a significant delay between the two tasks, that'd be impressively concurrent.
- Concurrency is making overlapping progress on two or more tasks.
- Parallelism is making simultaneous progress on two or more tasks.
Concurrent tasks can run in parallel, but they can also run not in parallel and still make overlapping progress by either cooperative or preemptive scheduling. You can imagine that in some larger tasks you have these 3 second sleeps, but all tasks are able to make overlapping progress without blocking the other ones -- that's concurrency!
Javascript has concurrency (which async/await and promises are).
It doesn’t have parallelism at the language level (but see, e.g., the Web Workers API).
I would attack this one of two ways, both of which I feel are more idiomatic than trying to emulate Go in JS:
1) Factor out the intermediate computation and promise:
const manifestPromise = buildManifest();
await Promise.all([
compileBrowser({manifestPromise});
compileServer({manifestPromise});
]);
2) Just return the two promises from compilerBrowser(): function compilerBrowser() {
let resolveManifest;
const manifest = new Promise((res) => resolveManifest = res);
const result = (async () => {
// Compute manifest and resolve it before the final result:
resolveManifest(manifest);
// Compute the result of the result...
return finalResult;
}());
return {
manifest,
result;
};
}
IOW, decompose things into smaller pieces (1) and/or compose them into values that match what you need (2) and remember that a function can return a group of promises instead of a single one.Correct me if I don't understand is this only concurrent with respect to IO is that right? If you run a IO operation or network call (fetch api) in those promises, those shall be concurrent with the CPU execution of your Javascript?
I think async/await is a great primitive. I've been working on implementing a async/await switch statement based state machine* in Java for multithreaded async/await. I want to handle the scheduling of multiple promises eagerly, so when you call async task1(); async task2(); async task3(); it schedules them all independently on different threads.
* if you've used protothreads in C, this is what it is similar to.
To do interleaved concurrency in Javascript, you could do the same pattern (switch based state machine) or do my concurrent looping approach. You break up your long CPU task into lots of microtasks and switch between them concurrently with a switch statement. You switch between tasks with a scheduler loop, so you do a bit of work on each task independently. It's concurrent but not parallel.
here's my writeup of concurrent loops which takes arbitrarily nested loops and turns them into an iterator https://github.com/samsquire/ideas4#133-concurrent-loops---l...
here's my java state machine of multithreaded async/await in Java, that could be adapted to Javascript except it wouldn't be parallel.
https://github.com/samsquire/multiversion-concurrency-contro...
8 years ago I wrote a npm package that turned code that looks like this:
tcp.send("syn", function(syn) {
console.log("received", syn);
tcp.send("syn-ack", function(synack) {
console.log("received", synack);
tcp.send("ack", function(ack) {
console.log("received", ack);
});
});
});
into this: seq([tcp, console], function(tcp, console) {
var syn = {}; var synack = {}; var ack = {};
tcp.send("syn", this(syn));
console.log("received %s", syn);
tcp.send("syn-ack", this(synack));
console.log("received %s", synack);
tcp.send("ack", this(ack));
console.log("received %s", ack);
});
it was never ready for production use, but it showed that synchronous code illusion can be created from callbacks.How would you prefer to write asynchronous code?
How does Loom handle resource starvation problem? If a thread puts a while (true) {} in there somewhere, can that thread be preempted away from that kernel thread?
If you put while (true) { } in an asynchronous function, wouldn't you also have a resource starvation problem? For what it's worth though, Loom threads are planned to be fully preemptible.
The worst part of Rust, too, is that async/await is tacked on— and it’s just a bad primitive to start with.
I have many more thoughts on this that I might like to blog about, but it can be improved by having a async runtime of some kind (we don’t call malloc a runtime, but it is— and you need something similar for ergonomic async) — to wit, the de facto pseudo-answer for this in Rust is tokio (which should just be promoted to the stdlib, at this point; the vast majority of async code depends on it’s particularities).
It’s also causes quote-unquote “function coloring problems” left, right, and center — which is also avoidable. Just for kicks, consider a language with “call-async”/“yield”. Tie that in with a runtime and this whole topic becomes much, much, cleaner. Async at the callsite! (This is the blog I’ve been meaning to write)
type Sender<T, E = unknown> = ((e: null, v: T) => void) & ((e: E) => void);
function oneshot<T, E = unknown>(): [Promise<T>, Sender<T, E>] {
let send: Sender<T, E>;
const recv = new Promise<T>((resolve, reject) => {
send = (err, v?: T) => err == null ? resolve(v!) : reject(err);
});
return [recv, send!];
}[0] https://blog.logrocket.com/comparing-the-stream-api-and-asyn...
But it wasn’t. Despite the author calling it a channel, it only supports one value.
await makeManifest().then(manifest => Promise.all([moreWork(manifest), compileServer(manifest)]))
If, however, "more work" is synchronous then the promise would not resolve until the next tick which puts you in the same place as before + extra overhead from a pair of extra promises.See this talk for some more background (ideally, watch the video). https://go.dev/talks/2012/concurrency.slide#1
Be warned, you will never look at async/await in the same way again.
Perhaps a sign that it’s finally over, and typescript has won in the community.
But actually:
> Ignoring the Typescript type definitions, its only 10 lines of Javascript!
So removing the types (JavaScript), it is 10 lines, but if you wanna write it in TypeScript it's 21 lines. Proof how verbose TypeScript can make codebases/examples?
I do see your point though. Typescript has taken off in the last few years.
Now that you do know about it, please use it sparingly if at all, i.e. when you’re absolutely sure you know more than the type checker, or when you’re in a context where it’ll be caught by other means. My typical lint setup disallows it in source code without an explanatory comment, and allows it in tests under the assumptions that either they’ll fail if wrong or that a reviewer will call out the test as overly complicated.
Of course, you don't need a channel abstraction to do that. To me, channels are the most intuitive and self-contained way to solve the problem, so I wanted to use that model of concurrency in JS.