Threading model¶
The threading model is fundamentally cooperative rather than
pre-emptive. The Skulpt compiler has been modified to automatically
insert ‘cede control’ calls in the bodies of ‘for’ and ‘while’ loops.
This is a pragmatic approach, and gives behaviour close to Scratch’s:
people are used to saying forever: …
, and so we wanted the Python
equivalent while True: …
to behave similarly.
Thread groups¶
The entire set of threads is held in a collection of ‘thread groups’. Each one contains one or more threads. Each thread is in a particular state, such as running, or sleeping while waiting to be woken by some external condition.
The primary motivation for collecting threads into groups was to ease the implementation of the Scratch-like ‘broadcast and wait’ mechanism, whereby the thread performing the broadcast then sleeps until all threads created in response to that broadcast have run to completion.
Automatically-inserted cede-control points¶
To give semi-automatic ceding of control, in support of familiar Scratch
idioms like forever: do-once-per-frame-stuff
, we want to have the
Skulpt compiler insert ‘yield-until-next-frame’ (‘YNF’) calls into loop
bodies, but only for ‘Pytch programs’. A ‘Pytch program’ is detected by
the presence of an import pytch
statement; after seeing such an
import, all for and while loops have YNF calls inserted as the first
statement of their bodies. The effect of this is that there is a yield
point before each run of a loop body. Inserting the YNF call at the
end of the loop body would have the undesired effect that continue
statements would bypass the yield.
Sometimes the user has a legitimate need for a loop to run ‘all at
once’, such as when checking a list of conditions without updating the
Sprite’s state. Pytch provides a context manager which lets multiple
loop iterations run in one frame; see LoopIterationsPerFrame
and
the decorator non_yielding_loop
.
There is precedent for this behaviour, whereby a magic import changes
the language’s semantics, in Python’s various
from __future__import something
mechanisms.
Time is sliced by ‘frame’¶
Every frame, all thread-groups are given the chance to run. A
thread-group has a one_frame()
method to do this. It returns a list
— which might be empty — of the thread groups which are now live. This
list is found by calling all the thread-group’s contained threads’
one_frame()
methods, and concatenating the results. E.g., if a
broadcast is sent by a thread, we return a one-element list with the new
thread-group. If the current thread-group has at least one live thread,
then it adds itself to the returned list.
The Project
then does a similar process for its thread-groups,
collecting a new list of thread-groups ready for the following frame. In
practice, it will very often be the case that the same thread-groups are
in this list across many frames.
One big assumption is that the computations within the running threads can all complete in under 1/60 second. If this fails to hold, project rendering will be jittery, and the project’s sense of timing, as shown in the wait-seconds system call, will be wrong.
A thread becomes a zombie once it runs to completion at the Python level. Or if it’s running on an object which ceases to be a registered Pytch actor-instance.
Scheduling¶
All execution is driven by the one_frame()
method. Every running
thread gets to resume its suspended execution. There are no guarantees
about which order various threads run in.
Thread¶
On construction, we run a Python callable, supplying one argument. This
technique is used to run an unbound method, passing a particular
instance as the argument; the effect is obj.method()
. In fact this
is not quite true: the method call is stored as a pseudo-suspension; see
below.
All threads are running ‘on’ some instance of some actor — this is the python object passed in on construction of the thread, and the single argument of the callable.
Threads run until either they complete at the Python level, or they invoke a Pytch syscall (qv) using the Skulpt suspension mechanism.
The suspension is stored in the Thread
as skulpt_susp
. This
process is bootstrapped by duck-typing a suspension whose resume()
runs the obj.method()
call as noted above.
Each frame, the thread gets a chance to run: see ‘Time-slice’ below.
Diagnostics: Each registered instance (PytchActorInstance
) is given
a global numeric ID. Threads have an info bundle with the instance
they’re running on, state, and, if waiting, a human friendly summary of
what they’re waiting on. Currently unused but could be a ‘top’-style
extra pane next to output/error panes.
Time-slice¶
Each thread gets a call to its one_frame()
. That calls into Python
via the resume()
of its suspension. Three things can happen as a
result:
Runs to completion, returning a Python object (usually
None
but doesn’t have to be). The thread is done; turned into aZombie
.Calls a Pytch syscall, creating a Skulpt suspension of type Pytch. Handled on the JavaScript side, e.g., broadcast message will usually create new thread-groups. Most syscalls act as co-operative yield points.
Returns a non-Pytch suspension. E.g., if
time.sleep()
is called. This is treated as an error byone_frame()
.Throws a Python exception, which is turned into a JavaScript exception. Thread is killed and error message reported.
Thread state¶
A thread is in one of various states:
- Running
This thread is ready to be
resume()
’d at next one-frame.- Zombie
No further work will be done, but this thread hasn’t been cleaned up yet (can become zombie either because Python code has run to completion, i.e., not invoked syscall; or because object it is running on has become de-registered).
- Sleeping
The thread is waiting for some condition to become true. See Types of sleep below.
- Raised-Exception
The user’s code (or possibly some internal code) raised a Python exception or threw a JavaScript error during its most recent scheduled run. The scheduler (top-level
one_frame()
function) halts the program if this happens.- Requested-Stop
The thread invoked a syscall requesting the program stop. The scheduler stops the program if this happens.
Waking paused threads¶
Thread has maybe_wake()
and should_wake()
which test whether the
condition for resumption of that thread has occurred. Done by polling to
avoid callbacks into a project. If the live project changed between
setting up a thread and a completion callback firing, confusion could
result.
Culling zombies¶
The thread-group lets all its threads run, collecting new thread-groups as noted elsewhere. Some of its threads might have run to completion on the Python side, i.e., the function / method call returned. Such a thread becomes a Zombie; the thread-group culls zombies. Doing so might mean that there are no threads any more; in that case the thread-group does not include itself in the list of for-next-frame thread groups it returns.
Types of sleep¶
The following kinds of sleeping can happen, caused by the given user-level calls.
Passage of time:
wait_seconds()
Thread group completion:
broadcast_and_wait()
Sound completion:
play_sound_until_done()
Answer to question:
ask_and_wait()