Announcing anod - bridging the gap between sync and async signals reactivity graphs #2684
visj
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Hey Ryan!
It's been almost 7 years since we spoke last and created the old Gitter channel about signals back in 2019, I don't even know if you remember. Anyway, big congrats on how Solid took off since then, it has really transformed S.js ideas into something truly useful.
Some things came in between in my life personally, so my research in reactive graphs kind of came to a halt. But now when my last contract ended, I decided to try and finish what I started 7 years ago. The result is anod, a new take on async reactivity for signals:
https://github.com/visj/anod/
anod exposes the same reactive primitives that most signals libraries offer: root, signal, compute, effect. But, it introduces three new async native variants of each: resource, task and spawn. Below is a quick sketch of the idea:
You can also suspend an array of tasks, in which case the spawn() will wait for every task to settle, and it resolves a consistent snapshot of some moment in time when all tasks were settled at the same time. anod also exposes a sync guard that acts the same as the async variant:
It has full built-in support for ownership through effect, so it automatically disposes just like how S.js works. Basically, the core idea is still S.js, but I've introduced several meaningful improvements that significantly improves performance. I also extended the idea to introduce a
Channelclass which basically works the same, but it keeps a task and spawn in sync, and uses callbacks to resolve/reject.I've also implemented a lightweight error management system that I think is pretty neat:
I've also created some more advanced async graph primitives, where I think one of the strongest is the
lock/unlockpair. This allows you to create async transactions that guarantee they run to completion and blocks both incoming updates and dispose calls until they settle:I read up on your work on Solid 2.0 and your ideas about colorless async. Basically, solid and anod have completely diverged in how async is treated: solid bridges the gap to make you not have to worry so much about async, it just works. anod instead gives you batteries included async support.
Some implications of your design in solid 2.0 I would assume is that tasks would have to be pure push nodes, since you throw a NotReadyError the moment you read a loading task? So in my example above, if I create two tasks, and read them in a spawn, solid 2.0 would throw on the first task, never reaching the second task, until we are notified by task A, in which case we read task B to check if it's loading, and if that throws, we wait again, have I understood it correctly? To me, it looks like you are constrained to a push-based async reactive graph in that case. With task/spawn, the Compute nodes are fully pull-based even for async. If no one reads them during the next tick, they don't update. Or have I misunderstood your ideas?
I've also spent considerable amount of time researching sync performance. You can check the benchmark section of the readme for runs against alien, solid 2.0 and preact-signals. I'd say the most profound optimization I've used is how to combine array based subs/deps storage, with guaranteed unique deps (without using Set), only using a single stamp. The heuristic is: we use a 2-phase mark and sweep strategy, bumping a STAMP by 2 on each update. All deps are marked stamp - 1 to mean belonging to us, and when we read them, we just bump their version to stamp. The complexity was that an inner compute that we read might be stale, and is triggered to update, which would corrupt the version. So, on each top-level execution scope, I created a fence, which marks the first possible version that could have been used by a currently running node. If we encounter such a node, we store the version of that node in a stack, and reset it once we complete, in order not to corrupt the parent scope.
This approach makes stable graphs completely free. Just sweep the deps, bump the version, and then reuse all of them. In the memory benchmarks, anod often allocates something like 80-90% less memory on updates than alien-signals' linked list approach does (I looked this up after writing this, I think their link() should be full reuse... So the memory must be coming from somewhere else, maybe I've misunderstood alien). To reduce memory pressure further, I dropped the 2-way slot binding, and instead use a tombstone strategy. So, when an Effect removes itself from a Signal, it just nulls out the Signal from its own dep, and bump a tombstone counter on the signal. Then, we enqueue those Signals into the flush() loop when they hit some certain threshold to perform garbage collection. I haven't fully tweaked exactly where the optimum is, it entirely depends on how dynamic your workload is. But, it halves the size of both the subs and deps array, which led to an overall 20-25% lower memory allocation per node.
Anyway, I'm staying in Vietnam with my step father, and I basically have no one to talk to about this. I've built it completely by myself, isolated in a town here. If you have time, I'd love to meet over a video call or similar, because I have had no one to bounce ideas off about this, and I feel like I've become slightly more insane the weeks it took building this.
Looking forward to your response!
Best, Vilhelm
Edited to include a more interesting example of use case with optimistic UI
Beta Was this translation helpful? Give feedback.
All reactions