How Mitogen Works¶
Some effort is required to accomplish the seemingly magical feat of bootstrapping a remote Python process without any software installed on the remote machine. The steps involved are unlikely to be immediately obvious to the casual reader, and they required several iterations to discover, so we document them thoroughly below.
The UNIX First Stage¶
To allow delivery of the bootstrap compressed using zlib, it is
necessary for something on the remote to be prepared to decompress the payload
and feed it to a Python interpreter 1. Since we would like to avoid
writing an error-prone shell fragment to implement this, and since we must
avoid writing to the remote machine’s disk in case it is read-only, the Python
process started on the remote machine by Mitogen immediately forks in order to
implement the decompression.
Python Command Line¶
The Python command line sent to the host is a zlib-compressed 2 and
base64-encoded copy of mitogen.parent.Connection._first_stage(),
which is carefully written to maximize it compatibility and minimize its size.
A simplified illustration of the bootstrap command is
python -c 'exec(sys.argv[1].decode("base64").decode("zlib"))' <base64> ...
The command-line arranges for the Python interpreter to decode the base64’d component, decompress it and execute it as Python code. Base64 is used since to protect against any special characters that may be interpreted by the system shell in use.
Forking The First Stage¶
The first stage creates a UNIX pipe and saves a copy of the process’s real
stdin file descriptor (used for communication with the master) so that it
can be recovered by the bootstrapped process later. It then forks into a new
process.
After fork, the parent half overwrites its stdin with the read end of the
pipe, and the child half writes the string MITOGEN0\n, then begins reading the
zlib-compressed payload supplied on stdin by the master, and
writing the decompressed result to the write-end of the UNIX pipe.
To allow recovery of stdin for reuse by the bootstrapped process for
parent<->child communication, it is necessary for the first stage to avoid
closing stdin or reading from it until EOF. Therefore, the master
sends the zlib-compressed payload prefixed with an integer size,
allowing reading by the first stage of exactly the required bytes.
Configuring argv[0]¶
Forking provides an excellent opportunity to tidy up the eventual Python interpreter, in particular, restarting it using a fresh command-line to get rid of the large base64-encoded first stage parameter, and to replace argv[0] with something descriptive.
After configuring its stdin to point to the read end of the pipe, the
fork parent re-executes Python with argv[0] composed of the Python
interpreter path and a remote name supplied by the Mitogen parent. As no
arguments are provided to this new execution of Python, and since stdin is
connected to a pipe (whose write end is connected to the first stage), the
Python interpreter begins reading source code to execute from the pipe
connected to stdin.
Bootstrap Preparation¶
Now we have the mechanism in place to send a zlib-compressed script
to the remote Python interpreter, it is time to choose what to send.
The script sent is simply the source code for mitogen.core, with a
single line suffixed to trigger execution of the
mitogen.core.ExternalContext.main() function. The encoded arguments
to the main function include some additional details, such as the logging package
level that was active in the parent process, and whether debugging or profiling
are enabled.
After the script source code is prepared, it is passed through
mitogen.master.minimize_source() to strip it of docstrings and
comments, while preserving line numbers. This reduces the compressed payload
by around 20%.
Preserving The mitogen.core Source¶
One final trick is implemented in the first stage: after bootstrapping the new
child, it writes a duplicate copy of the mitogen.core source it just
used to bootstrap it back into another pipe connected to the child. The child’s
module importer cache is initialized with a copy of the source, so that
subsequent bootstraps of children-of-children do not require the source to be
fetched from the master a second time.
Signalling Success¶
Once the first stage has signalled MITO000\n, the master knows it is ready
to receive the compressed bootstrap. After decompressing and writing the
bootstrap source to its parent Python interpreter, the first stage writes the
string MITO001\n to stdout before exiting. The master process waits for
this string before considering bootstrap successful and the child’s stdio
ready to receive messages.
The signal value is 8 bytes to match the minimum chunk size required to disambiguate between lines containing an interesting token during SSH password authentication, a debug message from the SSH client itself, or a message from the first stage.
ExternalContext.main()¶
Generating A Synthetic mitogen Package¶
Since the bootstrap consists of the mitogen.core source code, and
this code is loaded by Python by way of its main script (__main__
module), initially the module layout in the child will be incorrect.
The first step taken after bootstrap is to rearrange sys.modules slightly
so that mitogen.core appears in the correct location, and all
classes defined in that module have their __module__ attribute fixed up
such that cPickle correctly serializes instance module names.
Once a synthetic mitogen package and mitogen.core module
have been generated, the bootstrap deletes sys.modules[‘__main__’], so
that any attempt to import it (by cPickle) will cause the import to
be satisfied by fetching the master’s actual __main__ module. This is
necessary to allow master programs to be written as a self-contained Python
script.
Reaping The First Stage¶
After the bootstrap has called os.dup() on the copy of the stdin
file descriptor saved by the first stage, it is closed.
Additionally, since the first stage was forked prior to re-executing the Python
interpreter, it will exist as a zombie process until the parent process reaps
it. Therefore the bootstrap must call os.wait() soon after startup.
Setup Logging¶
The child’s logging package root logger is configured to have the
same log level as the root logger in the master, and
mitogen.core.LogHandler is installed to forward logs to the master
context’s FORWARD_LOG handle.
The log level is copied into the child to avoid generating a potentially large amount of network IO forwarding logs that will simply be filtered away once they reach the master.
The Module Importer¶
An instance of mitogen.core.Importer is installed in
sys.meta_path, where Python’s import statement will
execute it before attempting to find a module locally.
Standard IO Redirection¶
Two instances of mitogen.core.IoLogger are created, one for
stdout and one for stderr. This class creates a UNIX pipe whose read
end is added to the IO multiplexer, and whose write end is used to overwrite
the handles inherited during process creation.
Even without IO redirection, something must replace stdin and stdout,
otherwise it is possible for the stream used for communication between parent
and child to be accidentally corrupted by subprocesses run by user code.
The inherited stdin is replaced by a file descriptor pointing to
/dev/null.
Finally Python’s sys.stdout is reopened to ensure line buffering is
active, so that print statements and suchlike promptly appear in the logs.
Function Call Dispatch¶
After all initialization is complete, the child’s main thread sits in a loop
reading from a Channel connected to the
CALL_FUNCTION handle. This handle is
written to by
call()
and call_async().
CALL_FUNCTION only accepts requests
from the context IDs listed in mitogen.parent_ids, forming a chain
of trust between the master and any intermediate context leading to the
recipient of the message. In combination with Source Verification, this
is a major contributor to ensuring contexts running on compromised
infrastructure cannot trigger code execution in siblings or any parent.
Shutdown¶
When a context receives SHUTDOWN from its
immediate parent, it closes its own CALL_FUNCTION Channel before
sending SHUTDOWN to any directly connected
children. Closing the channel has the effect of causing
ExternalContext._dispatch_calls() to exit and begin joining on the
broker thread.
During shutdown, the master waits up to 5 seconds for children to disconnect
gracefully before force disconnecting them, while children will use that time
to call socket.shutdown(SHUT_WR) on their
IoLogger socket’s write ends before
draining any remaining data buffered on the read ends, and ensuring any
deferred broker function callbacks have had a chance to complete, necessary to
capture for example forwarding any remaining logging records.
An alternative approach is to wait until the IoLogger socket is completely
closed, with some hard timeout, but this necessitates greater discipline than
is common in infrastructure code (how often have you forgotten to redirect
stderr to /dev/null when starting a daemon process?), so needless
irritating delays would often be experienced during program termination.
If the main thread (responsible for function call dispatch) fails to shut down
gracefully, because some user function is hanging, it will still be cleaned up
since as the final step in broker shutdown, the broker sends
signal.SIGTERM to its own process.
Stream Protocol¶
Once connected, a basic framing protocol is used to communicate between parent and child. Integers use big endian in their encoded form.
Field |
Size |
Description |
|---|---|---|
magic |
2 |
Integer 0x4d49 ( |
dst_id |
4 |
Integer target context ID. |
src_id |
4 |
Integer source context ID. Used as the target of replies if any are generated. |
auth_id |
4 |
The context ID under whose authority the message is acting. See Source Verification. |
handle |
4 |
Integer target handle in the destination context. This is one of the Standard Handles, or a dynamically generated handle used to receive a one-time reply, such as the return value of a function call. |
reply_to |
4 |
Integer target handle to direct any reply to this message. Used to receive a one-time reply, such as the return value of a function call, or to signal a special condition for the message. See below for special values for this field. |
length |
4 |
Length of the data part of the message. |
data |
n/a |
Message data, which may be raw or pickled. |
Standard Handles¶
Masters listen on the following handles:
-
mitogen.core.FORWARD_LOG¶ Receives (logger_name, level, msg) 3-tuples and writes them to the master’s
mitogen.ctx.<context_name>logger.
-
mitogen.core.GET_MODULE¶ Receives the name of a module to load fullname, locates the source code for fullname, and routes one or more
LOAD_MODULEmessages back towards the sender of theGET_MODULErequest. If lookup fails,Noneis sent instead.See Import Preloading for a deeper discussion of
GET_MODULE/LOAD_MODULE.
-
mitogen.core.ALLOCATE_ID¶ Replies to any message sent to it with a newly allocated range of context IDs, to allow children to safely start their own contexts. Presently IDs are allocated in batches of 1000 from a 32 bit range, allowing up to 4.2 million parent contexts to be created and destroyed before the associated Router must be recreated.
This is handled by
mitogen.master.IdAllocatorin the master process, and messages are sent to it frommitogen.parent.ChildIdAllocatorin children.
Children listen on the following handles:
-
mitogen.core.LOAD_MODULE¶ Receives (pkg_present, path, compressed, related) tuples, composed of:
pkg_present: Either
Nonefor a plain.pymodule, or a list of canonical names of submodules existing witin this package. For example, aLOAD_MODULEfor themitogenpackage would return a list like: [“mitogen.core”, “mitogen.fakessh”, “mitogen.master”, ..]. This list is used by children to avoid generating useless round-trips due to Python 2.x’simportstatement behavior.path: Original filesystem where the module was found on the master.
compressed:
zlib-compressed module source code.related: list of canonical module names on which this module appears to depend. Used by children that have ever started any children of their own to preload those children with
LOAD_MODULEmessages in response to aGET_MODULErequest.
-
mitogen.core.CALL_FUNCTION¶ Receives (chain_id, mod_name, class_name, func_name, args, kwargs) 6-tuples from
mitogen.parent.CallChain, importsmod_name, then attempts to execute class_name.func_name(*args, **kwargs).chain_id: if not
None, an identifier unique to the originatingmitogen.parent.CallChain. When set, if an exception occurs during a call, future calls with the same ID automatically fail with the same exception without ever executing, and failed calls with no reply_to set are not dumped to the logging framework as they otherwise would. This is used to implement pipelining.
When this channel is closed (by way of receiving a dead message), the child’s main thread begins graceful shutdown of its own
BrokerandRouter.
-
mitogen.core.SHUTDOWN¶ When received from a child’s immediate parent, causes the broker thread to enter graceful shutdown, including sending a dead message to the child’s main thread, causing it to join on the exit of the broker thread.
The final step of a child’s broker shutdown process sends
signal.SIGTERMto itself, ensuring the process dies even if the main thread was hung executing user code.Each context is responsible for sending
SHUTDOWNto each of its directly connected children in response to the master sendingSHUTDOWNto it, and arranging for the connection to its parent to be closed shortly thereafter.
Masters, and children that have ever been used to create a descendent child also listen on the following handles:
-
mitogen.core.ADD_ROUTE¶ Receives target_id integer from downstream, describing an ID allocated to a recently constructed child. The receiver verifies no existing route exists to target_id before updating its local table to route messages for target_id via the stream from which the
ADD_ROUTEmessage was received.
-
mitogen.core.DEL_ROUTE¶ Receives target_id integer from downstream, verifies a route exists to target_id via the stream on which the message was received, removes that route from its local table, triggers the
disconnectsignal on anymitogen.core.Contextinstance in the local process, then propagates the message upward towards its own parent.
-
mitogen.core.DETACHING¶ Sent to inform a parent that user code has invoked
ExternalContext.detach()to decouple the lifecycle of a directly connected context and its subtree from the running program.A child usually shuts down immediately if it loses its parent connection, and parents usually terminate any related Python/SSH subprocess on disconnection. Receiving
DETACHINGinforms the parent the connection will soon drop, but the process intends to continue life independently, and to avoid terminating the related subprocess if that subprocess is the child itself.
Non-master parents also listen on the following handles:
-
mitogen.core.GET_MODULE As with master’s
GET_MODULE, except this implementation (mitogen.master.ModuleForwarder) serves responses usingmitogen.core.Importer’s cache before forwarding the request to its parent context. The response is cached by each context in turn before being forwarded on to the child context that originally made the request. In this way, the master need never re-send a module it has already sent to a direct descendant.
-
mitogen.core.FORWARD_MODULE¶ Receives (context, fullname) tuples from its parent and arranges for a
LOAD_MODULEto be sent towards context for the module fullname and any related modules. The module must already have been delivered to the current context by its parent in a priorLOAD_MODULEmessage.If the receiver is the immediate parent of context, then only
LOAD_MODULEis sent to the child. OtherwiseLOAD_MODULEis sent to the next closest parent if the module has not previously been sent on that stream, followed by a copy of theFORWARD_MODULEmessage.This message is used to recursively preload indirect children with modules, ensuring they are cached and deduplicated at each hop in the chain leading to the target context.
Additional handles are created to receive the result of every function call
triggered by call_async().
Use of Pickle¶
The current implementation uses the Python cPickle module, with a
restrictive class whitelist to prevent triggering undesirable code execution.
The primary reason for using cPickle is that it is computationally
efficient, and avoids including a potentially large body of serialization code
in the bootstrap.
The pickler will instantiate only built-in types and one of 3 constructor
functions, to support unpickling CallError, mitogen.core.Sender,and
Context.
The choice of Pickle is one area to be revisited later. All accounts suggest it cannot be used securely, however few of those accounts appear to be expert, and none mention any additional attacks that would not be prevented by using a restrictive class whitelist.
The IO Multiplexer¶
Since we must include our IO multiplexer as part of the bootstrap, off-the-shelf implementations are for the most part entirely inappropriate. For example, a minimal copy of Twisted weighs in at around 440KiB and is composed of approximately 115 files. Even if we could arrange for an entire Python package to be transferred during bootstrap, this minimal configuration is massive in comparison to Mitogen’s solution, multiplies quickly in the presence of many machines, and would require manually splitting up the parts of Twisted that we would like to use.
Message Routing¶
Routing assumes it is impossible to construct a tree such that one of a context’s parents will not know the ID of a target the context is attempting to communicate with.
When mitogen.core.Router receives a message, it checks the IDs
associated with its directly connected streams for a potential route. If any
stream matches, either because it directly connects to the target ID, or
because the master sent an ADD_ROUTE
message associating it, then the message will be forwarded down the tree using
that stream.
If the message does not match any ADD_ROUTE
message or stream, instead it is forwarded upwards to the immediate parent, and
recursively by each parent in turn until one is reached that knows how to
forward the message down the tree.
When a parent establishes a new child, it sends a corresponding
ADD_ROUTE message towards its parent, which
recursively forwards it up towards the root.
Parents keep note of all routes associated with each stream they connect with,
and trigger DEL_ROUTE messages propagated upstream for each route
associated with that stream if the stream is disconnected for any reason.
Example¶
In the diagram, when node12b is creating the sudo:node12b:webapp
context, it must send ADD_ROUTE messages to rack12, which will
propagate it to dc1, and recursively to bastion, and master;
node12b does not require an ADD_ROUTE message since it has a stream
directly connected to the new context.
Since Mitogen streams are strictly ordered, it is never possible for a parent
to receive a message from a newly constructed child before receiving a
corresponding ADD_ROUTE sent by the child’s parent, describing how to reply
to it.
When sudo:node12b:webapp wants to send a message to
sudo:node22a:webapp, the message will be routed as follows:
sudo:node12b:webapp -> node12b -> rack12 -> dc1 -> bastion -> dc2 -> rack22 -> node22a -> sudo:node22a:webapp
Disconnect Propagation¶
To ensure timely shutdown when a failure occurs, where some context is awaiting
a response from another context that has become disconnected,
mitogen.core.Router additionally records the destination context ID of
every message received on a particular stream.
When DEL_ROUTE is generated locally or received on some other stream,
mitogen.parent.RouteMonitor uses this to find every stream that ever
communicated with the route that is about to go away, and forwards the message
to each found.
The recipient DEL_ROUTE handler in turn uses the message to find any
mitogen.core.Context in the local process corresponding to the
disappearing route, and if found, fires a disconnected event on it.
Any interested party, such as mitogen.core.Receiver, may subscribe to
the event and use it to abort any threads that were asleep waiting for a reply
that will never arrive.
Source Verification¶
Before forwarding or dispatching a message it has received,
mitogen.core.Router first looks up the corresponding
mitogen.core.Stream it would use to send responses towards the
context ID listed in the auth_id field, and if the looked up stream does not
match the stream on which the message was received, the message is discarded
and a warning is logged.
This creates a trust chain leading up to the root of the tree, preventing
downstream contexts from injecting messages appearing to be from the master or
any more trustworthy parent. In this way, privileged functionality such as
CALL_FUNCTION can base trust decisions
on the accuracy of auth_id.
The auth_id field is separate from src_id in order to support granting
privilege to contexts that do not follow the tree’s natural trust chain. This
supports cases where siblings are permitted to execute code on one another, or
where isolated processes can connect to a listener and communicate with an
already established established tree, such as where a mitogen.unix
client receives the same privilege as the process it connects to.
Differences Between Master And Child Brokers¶
The main difference between mitogen.core.Broker and
mitogen.master.Broker is that when the stream connection to the
parent is lost in a child, the broker will trigger its own shutdown.
The Module Importer¶
mitogen.core.Importer is still a work in progress, as there
are a variety of approaches to implementing it, and the present implementation
is not perfectly efficient in every case.
It operates by intercepting import statements via
sys.meta_path, asking Python if it can satisfy the import by itself,
and if not, indicating to Python that it is capable of loading the module.
In load_module() an RPC is
started to the parent context, requesting the module source code by way of a
GET_MODULE. If the parent context does not
have the module available, it recursively forwards the request upstream, while
avoiding duplicate requests for the same module from its own threads and any
child contexts.
Neutralizing __main__¶
To avoid accidental execution of the __main__ module’s code in a
slave context, when serving the source of the main module, Mitogen removes any
code occurring after the first conditional that looks like a standard
__main__ execution guard:
# Code that looks like this is stripped from __main__.
if __name__ == '__main__':
run_some_code()
To further avoid accidental execution, Mitogen will refuse to serve
__main__ to children if no execution guard is found, as it is common
that no guard is present during early script prototyping.
These are hacks, but they are the safest and least annoying found to solve the problem.
Avoiding Negative Imports¶
In Python 2.x where relative imports are the default, a large number of import requests will be made for modules that do not exist. For example:
# mypkg/__init__.py
import sys
import os
In Python 2.x, Python will first try to load mypkg.sys and
mypkg.os, which do not exist, before falling back on sys
and os.
These negative imports present a challenge, as they introduce a large number of
pointless network round-trips. Therefore in addition to the
zlib-compressed source, for packages the master sends along a list of
child modules known to exist.
Before indicating it can satisfy an import request,
mitogen.core.Importer first checks to see if the module belongs to
a package it has previously imported, and if so, ignores the request if the
module does not appear in the enumeration of child modules belonging to the
package that was provided by the master.
Import Preloading¶
To further avoid round-trips, when a module or package is requested by a child,
its bytecode is scanned in the master to find all the module’s
import statements, and of those, which associated modules appear to
have been loaded in the master’s sys.modules.
The sys.modules check is necessary to handle various kinds of
conditional execution, for example, when a module’s code guards an
import statement based on the active Python runtime version,
operating system, or optional third party dependencies.
Before replying to a child’s request for a module with dependencies:
If the request is for a package, any dependent modules used by the package that appear within the package itself are known to be missing from the child, since the child requested the top-level package module, therefore they are pre-loaded into the child using
LOAD_MODULEmessages before sending theLOAD_MODULEmessage for the requested package module itself. In this way, the child will already have dependent modules cached by the time it receives the requested module, avoiding one round-trip for each dependency.For example, when a child requests the
djangopackage, and the master determines thedjangomodule code in the master hasimportstatements fordjango.utils,django.utils.lru_cache, anddjango.utils.version, and that execution of the module code on the master caused those modules to appear in the master’ssys.modules, there is high probability execution of thedjangomodule code in the child will cause the same modules to be loaded. Since all those modules exist within thedjangopackage, and we already know the child lacks that package, it is safe to assume the child will make follow-up requests for those modules too.In the example, 4 round-trips are replaced by 1 round-trip.
For any package module ever requested by a child, the parent keeps a note of the name of the package for one final optimization:
If the request is for a sub-module of a package, and it is known the child loaded the package’s implementation from the parent, then any dependent modules of the requested module at any nesting level within the package that is known to be missing are sent using
LOAD_MODULEmessages before sending theLOAD_MODULEmessage for the requested module, avoiding 1 round-trip for each dependency within the same top-level package.For example, when a child has previously requested the
djangopackage module, the parent knows the package was completely absent on the child. Therefore when the child subsequently requests thedjango.dbpackage module, it is safe to assume the child will generate subsequentGET_MODULErequests for the 2django.conf, 3django.core, 2django.db, 3django.dispatch, and 7django.utilsindirect dependencies fordjango.db.In the example, 17 round-trips are replaced by 1 round-trip.
The method used to detect import statements is similar to the standard library
modulefinder module: rather than analyze module source code,
IMPORT_NAME opcodes are extracted from the module’s
bytecode. This is since clean source analysis methods (ast and
compiler) are an order of magnitude slower, and incompatible across
major Python versions.
Concurrency¶
Duplicate requests must never be issued to the parent, either due to a local
import or any GET_MODULE originating from a child. This lets parents
assume a module requested once by a downstream connection need never be
re-sent, for example, if it appears as a preloading dependency in a subsequent
GET_MODULE, or had been requested immediately after being sent as a
preloading dependency for an unrelated request by a descendent.
Therefore each tree layer must deduplicate GET_MODULE requests, and
synchronize their descendents and local threads on corresponding
LOAD_MODULE responses from the parent.
In each context, pending requests are serialized by a
threading.Lock within mitogen.core.Importer, which may
only be held for operations that cannot block, since ModuleForwarder must acquire it while synchronizing
GET_MODULE requests from children on the IO multiplexer thread.
Requests From Local Threads¶
When Mitogen begins satisfying an import, it is known the module has never been
imported in the local process. Importer
executes under the runtime importer lock, ensuring import
statements executing in local threads are serialized.
Note
In Python 2, ImportError is raised when import is
attempted while the runtime import lock is held by another thread,
therefore imports must be serialized by only attempting them from the main
(CALL_FUNCTION) thread.
The problem is most likely to manifest in third party libraries that lazily import optional dependencies at runtime from a non-main thread. The workaround is to explicitly import those dependencies from the main thread before initializing the third party library.
This was fixed in Python 3.5, but Python 3.x is not yet supported. See Python Issue #9260.
While holding its own lock, Importer
checks if the source is not yet cached, determines if an in-flight
GET_MODULE exists for it, starting one if none exists, adds itself
to a list of callbacks fired when a corresponding LOAD_MODULE
arrives from the parent, then sleeps waiting for the callback.
When the source becomes available, the module is constructed on the calling thread using the best practice documented in PEP 302.
Requests From Children¶
As with local imports, when GET_MODULE is received from a child,
while holding the Importer lock,
ModuleForwarder checks if the
source is not yet cached, determines if an in-flight GET_MODULE
toward the parent exists for it, starting one if none exists, then adds a
completion handler to the list of callbacks fired when a corresponding
LOAD_MODULE arrives from the parent.
When the source becomes available, the completion handler issues corresponding
LOAD_MODULE messages toward the child for the requested module after
any required for dependencies known to be absent from the child.
Since intermediaries do not know a module’s dependencies until the module’s
source arrives, it is not possible to preemptively issue LOAD_MODULE
for those dependencies toward a requesting child as they become available from
the parent at the intermediary. This creates needless network serialization and
latency that should be addressed in a future design.
Child Module Enumeration¶
Package children are enumerated using pkgutil.iter_modules().
Use Of Threads¶
The package always runs the IO multiplexer in a thread. This is so the multiplexer retains control flow in order to shut down gracefully, say, if the user’s code has hung and the master context has disconnected.
While it is possible for the IO multiplexer to recover control of a hung
function call on UNIX using for example signal.SIGALRM, this
mechanism is not portable to non-UNIX operating systems, and does not work in
every case, for example when Python blocks signals during a variety of
threading package operations.
At some point it is likely Mitogen will be extended to support children running on Windows. When that happens, it would be nice if the process model on Windows and UNIX did not differ, and in fact the code used on both were identical.
Waking Sleeping Threads¶
Due to fundamental deficiencies in Python 2’s threading implementation, it is not possible to block waiting on synchronization objects sanely. Two major problems exist:
Sleeping with no timeout set causes signals to be blocked, preventing the user from terminating the process using CTRL+C.
Sleeping with a timeout set internally makes use of polling, with an exponential backoff that eventually results in the thread sleeping unconditionally in 50ms increments. . This is a huge source of latency that quickly multiplies.
As the UNIX self-pipe trick must already be employed to wake the broker thread
from its select loop, Mitogen reuses this technique to wake any thread
synchronization primitive exposed by the library, embodied in a queue-like
abstraction called a mitogen.core.Latch.
Unfortunately it is commonplace for hosts to enforce severe per-process file descriptors limits, so aside from being inefficient, it is impossible in the usual case to create a pair of descriptors for every waitable object, which for example includes the result of every single asynchronous function call.
For this reason self-pipes are created on a per-thread basis, with their
associated socketpairs kept in thread-local
storage. When a latch wishes to sleep its thread, this pair is created
on-demand and temporarily associated with it only for the duration of the
sleep.
Python’s garbage collector is relied on to clean up by calling the pair’s destructor on thread exit. There does not otherwise seem to be a robust method to trigger cleanup code on arbitrary threads.
To summarize, file descriptor usage is bounded by the number of threads rather than the number of waitables, which is a much smaller number, however it also means that Mitogen requires twice as many file descriptors as there are user threads, with a minimum of 4 required in any configuration.
Latch Internals¶
Attributes:
lock –
threading.Lock.queue – items waiting to be dequeued.
sleeping – write sides of the socketpairs for each sleeping thread, and threads in the process of waking from sleep.
waking – integer number of sleeping threads in the process of waking up.
closed – boolean defaulting to
False. Every time lock is acquired, closed must be tested, and if it isTrue,LatchErrormust be thrown.
Latch.put()¶
Latch.put() operates by:
Acquiring lock.
Appending the item on to queue.
If waking is less than the length of sleeping, write a byte to the socket at sleeping[waking] and increment waking.
In this way each thread is woken only once, and receives each element according to when its socket was placed on sleeping.
Latch.close()¶
Latch.close() acquires lock, sets closed to True, then
writes a byte to every sleeping[waking] socket, while incrementing waking,
until no more unwoken sockets exist. Per above, on waking from sleep, after
removing itself from sleeping, each sleeping thread tests if closed is
True, and if so throws LatchError.
It is necessary to ensure at most one byte is delivered on each socket, even if the latch is being torn down, as the sockets outlive the scope of a single latch, and must never have extraneous data buffered on them, as this will cause unexpected wakeups if future latches sleep on the same thread.
Latch.get()¶
Latch.get() is far more intricate, as there are many outcomes to
handle. Queue ordering is strictly first-in first-out, and threads always
receive items in the order they are requested, as they become available.
- 1. Non-empty, No Waiters, No sleep
On entry lock is taken, and if queue is non-empty, and sleeping is empty, it is safe to return queue’s first item without blocking.
- 2. Non-empty, Waiters Present, Queue > Waiters, No sleep
When sleeping is non-empty but there are more items than sleeping threads, it is safe to pop queue[len(sleeping)] without blocking.
- 3. Non-empty, Waiters Present, Queue <= Waiters
In this case sleeping is non-empty and there are no surplus items. It is not safe to pop any item even though we are holding lock, as it would starve waking threads of their position in favour of the calling thread, since scheduling uncertainty exists between a thread waking from
select.select()and re-acquiring lock.This avoids the need for a retry loop for waking threads, and a thread being continually re-woken to discover queue drained by a thread that never slept.
- 4. Sleep
Since no surplus items existed, the thread adds its socket to sleeping before releasing lock, and sleeping in
select.select()waiting for timeout, or a write fromLatch.put()orLatch.close().If
select.select()throws an exception, the exception must be caught and re-raised only after some of the wake steps below have completed.- 5. Wake, Non-empty
On wake lock is re-acquired, the socket is removed from sleeping after noting its index, and
TimeoutErroris thrown if waking indicatesLatch.put()norLatch.close()have yet to send a wake byte to that index. The byte is then read off,LatchErroris thrown if closed isTrue, otherwise the queue item corresponding to the thread’s index is popped and returned.It is paramount that in every case, if a byte was written to the socket, that the byte is read away. The socket is reused by subsequent latches sleeping on the same thread, and unexpected wakeups are triggered if extraneous data remains buffered on the socket.
It is also necessary to favour the synchronized waking variable over the return value of
select.select(), as scheduling uncertainty introduces a race between the select timing out, andLatch.put()orLatch.close()writing a wake byte beforeLatch.get()has re-acquired lock.
Footnotes
- 1
Although some connection methods such as SSH support compression, and Mitogen enables SSH compression by default, there are circumstances where disabling SSH compression is desirable, and many scenarios for future connection methods where transport-layer compression is not supported at all.
- 2
Compression may seem redundant, however it is basically free and reducing IO is always a good idea. The 33% / 200 byte saving may mean the presence or absence of an additional frame on the network, or in real world terms after accounting for SSH overhead, around a 2% reduced chance of a stall during connection setup due to a dropped frame.