[PEAK] PyCells and peak.events
Phillip J. Eby
pje at telecommunity.com
Fri May 26 22:39:41 EDT 2006
So I found out today (via James Tauber's blog) about an effort called
PyCells: a Google Summer of Code project to implement a Python version of
the Cells library for Common Lisp.
I had never heard of Cells before, but I checked it out because the
description sounded roughly like peak.binding hierarchies, but with an
event-driven flavor to make them updateable. And the GUI angle mentioned
sounded like something that might make them useful to a possible Chandler
MVC refactoring, so I checked it out some more.
What I discovered is quite cool. The Cells system *automatically
discovers* dynamic dependencies, without having to explicitly specify that
X depends on Y, as long as X and Y are both implemented using cell
objects. The system knows when you are computing a value for X, and
registers the fact that Y was read during this computation, thus allowing
it to automatically invalidate the X calculation if Y changes.
These cell objects are roughly like an events.Value object in peak.events
(and some are more like an events.Broadcaster), in that they are observable
values or "events" (i.e regular or "ephemeral" cells). But that's
basically where the similarity ends. Aside from the automatic dependency
detection, the cells system has another trick that is able to significantly
reduce the complexity of event cascades, similar to what I was trying (but
failing) to do using the "scheduled thread" concept in peak.events.
Specifically, the cells system understands how to make event-based updates
orderly and deterministic, in a way that peak.events cannot. It
effectively divides time into "propagation" and "non-propagation"
states. Instead of simply making callbacks whenever a computed value
changes, the system makes orderly updates by queueing invalidated cells for
updating. Also, if you write code that sets a new value imperatively (as
opposed to it being pulled declaratively), the actual set operation is
deferred until all computed cells are up-to-date with the current state of
the universe.
In other words, imperatively changing values is what separates one logical
"moment" from the next, and it thus avoids some of the thread-like race
conditions that can occur when event dependencies are complex. In
peak.events now, updating a value (e.g. releasing a semaphore) can cause
arbitrary code to run, including other tasks being resumed. But in a
Cells-like system, the change merely indicates that the at the next logical
"moment" of updating, which in most cases will be a moment *after* the
currently executing code in a Task has suspended or exited.
Thus, in Cells one ends up with a kind of CSP (Communicating Sequential
Processes) scenario being simulated. Not in terms of any actual threading,
mind you, but just for the microthreads being run inside of a real thread.
Nonetheless, a CSP model is *much* easier to understand than the "anything
can happen at any time" model of threads or even the "anything can happen
when you touch something" model of events.Task microthreads. Some of the
trickiest subtleties I found in peak.events had to do with race conditions
of updated variables due to this very issue.
Anyway, between the built-in CSP model and the automatic dependency
tracking, implementing something like Cells for peak.events would be a
major win for ease-of-development. One especially interesting bit is that
the Cells system can "optimize away" dependencies or subscriptions when
their subjects are known to be constant values. If you declare something
constant (or set it up so it computes only once like peak.binding
attributes do), then the things that depend on it (including recursive
dependencies) won't actually have any event subscriptions at runtime,
cutting down on the memory usage and the amount of time it takes to update
things.
I don't know whether the planned PyCells implementation will be reusable
for peak.events, although I tend to doubt it because after further study of
the original system, I realized that there are some pieces that could be
added if you base a Cells-like system on generic functions and the
Contextual library, that aren't as easy to do otherwise. For example,
Contextual would make it easy to have an active Action (transaction-like
operation) or Resource (object used within an Action) that would be able to
manage the "virtual clock" so as to multitask e.g. I/O event monitoring
between "ticks", or to do transaction rollback, undo/redo logging, or other
context-global adjustments to the system's behavior.
(By the way, this is one of the ways in which Contextual will be superior
to the peak.binding and config-based machinery for event loops used by
peak.events currently; Contextual makes it easy to scope an object (such as
the event loop in use, e.g. Twisted or an events.EventLoop) as being chosen
according to the current logical thread of invocation, whereas binding
allows only object-hierarchy scoping, which is pretty much irrelevant to
the issue of what event loop you're being run from at the present moment.)
I'm also wondering if a Cells-like system couldn't also be used to
implement STM (Software Transactional Memory) to allow for atomic
operations even in the presence of threads. All reads and writes are
controlled by the cells system, so it can in principle abort and retry a
"transaction", by waiting until *something changes* that would affect the
transaction's ability to succeed.
There's a paper I read in the last couple of years about an STM system like
this for Haskell that was ultra-cool, in that it didn't require anything
like "yields" to do asynchronous programming. If you tried to do something
that would block, it just threw an error and backed out anything you did
back to the previous committed point, and then waited until there was a
change, either in something that the attempted transaction read, or in the
thing that caused it to block.
Among other things, this approach makes deadlocks impossible, because an
atomic operation doesn't sit there holding locks while blocking on another
one. As soon as it would block, boom! It gets killed and the locks are
released. Then, as soon as the lock it *was* waiting for becomes
available, the transaction is rerun. Voila - no deadlocks.
I don't know whether taking it that far is possible or practical with a
Python system. In Haskell, imperative code is wrapped in monads that allow
you to avoid unintended side effects, and static typing allows you to
ensure that an "atomic" computation cannot contain any side-effects. I had
previously thought this would be entirely impractical in Python, because
there's no way to guarantee you're not writing code that produces "side
effects" (such as I/O, or updates to non-transactional state).
However, seeing how the Cells paradigm works, it seems to me that it should
be pretty easy to establish the convention that side-effects should be
confined to non-rule "observer" code.
With Python, there is no way to guarantee that side-effects don't exist, so
you're relying on conventions to ensure that side-effects only occur in
observer functions that get called "between the moments" of change to the
system. But it doesn't seem like that should be too difficult to manage;
experience w/e.g. peak.binding attributes shows that it's rare to want to
put side-effects into pull-oriented rules.
Really, the principal downside to Cells is wrapping your head around the
idea that *everything* should be treated as pull-oriented rules. There are
some operations (such as receiving a command and responding to it) that
seem to be more naturally expressed as pushing operations, where you make
some decisions and then directly update things or send other commands out.
Actually, you can still do that, it's just that those updates or commands
force another "virtual moment" of time into being, where if you had made
them pull-driven they could've happened in the *same* "virtual
moment". So, it's more that pull-based rules are slightly more efficient
than push-based ones, which is nice because that means most developers will
consider it worth learning how to do it the pull way. ;)
Anyway, there is a *lot* of interesting food for thought, here. For
example, you could create object validation rules using cells, and the
results would be automatically recomputed when something they depended on
changed. Not only that, but it would be possible to do atomic updates,
such that the validation wouldn't occur until *after* all the changes were
made -- i.e., no false positives. Of course, you'd get the resulting
validation errors in the *next* "time quantum", so you'd need to make the
response to them event-driven as well.
I'm not 100% positive how that would work, exactly, any more than I am 100%
clear on the STM or CSP cases. And there are many other possible uses that
I'm even less certain of, but are potentially quite exciting
nonetheless. For example, this deterministic model of computation seems to
resemble "object prevalence" (e.g. Prevayler) in that everything (even the
clock) is deterministic, changes are atomic, and I/O occurs between logical
moments of time. I haven't thought this particular link through very much
yet, it's just an intriguing similarity.
For now, though I just wanted to dump my current thoughts on the subject,
and to give a heads-up regarding what will take place when peak.events is
refactored to become Eventual. I had already planned to make it use
Contextual so that the current event loop is dynamically determined rather
than bound using a component hierarchy, but now it's clear to me that it
should also use a quantum scheduling system (ala Cells) to ensure that
system changes can only occur *between* logical moments of the system.
There are still some details to work out, but it seems to me that the
system could make all changes occurring during one "moment" be atomic as
far as the next "moment" is concerned, and also detect any conflicting
changes; i.e., by raising an error if more than one piece of code
imperatively sets a value for the same cell during a given "moment". (Thus
eliminating any nondeterminism due to update order; no race conditions, in
other words.)
The head-exploding part is figuring out how to get errors to propagate
backwards in time, so that validation rules (which run in the "next
moment") could appear to cause an error at the point where the values were
set. It's not unlike the issue one finds in Twisted Deferreds, when you
don't want a callback to happen later, you want it *now*. Probably the
best solution is similar, by replacing a function with a Task and yielding
to a validation ITaskSwitch, so that when your code resumes you are "in" a
different "moment". (I.e., instead of sending events backward in time, you
effectively send code forward.)
The main problem with that is that you may no longer have consistent read
values for other data in the task's state; other atomic operations may have
been interleaved. It's the sort of thing that you really need the full STM
model for -- that is, you need to be able to roll back the system state.
I'm going to have to think through these issues some more, but there seems
to be a lot of potential to be explored here.
More information about the PEAK
mailing list