Gevent has tormented me a thousand times, yet I keep coming back for more. This library is proof of that love.
Process isolation for gevent applications. Run any object in a clean subprocess, call its methods transparently via ZMQ IPC.
gevent's monkey.patch_all() replaces stdlib modules globally. Some libraries (database drivers, native async frameworks, etc.) break under monkey-patching. gisolate spawns a clean child process — no monkey-patching — and proxies method calls over ZMQ, so incompatible code runs in isolation while your gevent app stays cooperative.
pip install gisolateRequires Python 3.12+.
Proxy method calls to an object living in an isolated subprocess:
import gevent.monkey
gevent.monkey.patch_all()
from gisolate import ProcessProxy
# Define a factory (must be importable / picklable)
def create_client():
from some_native_lib import Client
return Client(host="localhost")
# Option 1: inline
proxy = ProcessProxy.create(create_client, timeout=30)
result = proxy.query("SELECT 1") # runs in child process
proxy.shutdown()
# Option 2: subclass
class ClientProxy(ProcessProxy):
client_factory = staticmethod(create_client)
timeout = 30
with ClientProxy() as proxy:
result = proxy.query("SELECT 1")A ProcessProxy spawns a worker at a random address it never advertises, so N
client processes pay for N copies of the isolated library. Where that matters (several gunicorn workers in
one container, say), one process hosts the worker and the rest attach to it:
# Host process — becomes the worker; blocks.
import os
from gisolate import serve
os.makedirs("/run/myapp", exist_ok=True)
os.chmod("/run/myapp", 0o700) # mode= on makedirs only applies when it creates
serve(create_client, "ipc:///run/myapp/client.sock")# Every client process — no spawn, no factory.
from gisolate import ProcessProxy
proxy = ProcessProxy.attach("ipc:///run/myapp/client.sock", timeout=10)
result = proxy.query("SELECT 1") # runs in the host
proxy.shutdown() # closes this client only; the host keeps servingPut the socket in a directory you control. The wire is unauthenticated
pickle and an empty frame stops the worker, so anyone who can connect can run
code in the host or shut it down for every client. The socket file's own mode
follows the host's umask — a world-writable one in a shared directory like
/tmp is reachable by any local user — whereas a 0700 directory is not,
whatever the umask. ipc:// only, and same host only: a call carries the
caller's monotonic deadline, which means nothing on another machine's clock.
Attaching is asynchronous: it connects to an address nobody has bound yet just as
happily, a restarted host is picked up by the DEALER's own reconnect, and an
absent one surfaces as an RPC timeout. The proxy is process-local like any ZMQ
socket — a forking server attaches after the fork, once per worker. serve()'s
max_concurrency bounds the worker, so it is shared by every client.
Run a single function in a subprocess and get the result:
from gisolate import run_in_subprocess
def heavy_compute(n):
return sum(range(n))
result = run_in_subprocess(heavy_compute, args=(10_000_000,), timeout=60)ZMQ-based RPC bridge for server/client architectures. Server side uses gevent, client side uses asyncio:
from gisolate import ProcessBridge
# Server (gevent side)
server = ProcessBridge("ipc:///tmp/rpc.sock", mode=ProcessBridge.Mode.SERVER)
server.start()
# Client (asyncio side)
import asyncio
async def main():
client = ProcessBridge("ipc:///tmp/rpc.sock", mode=ProcessBridge.Mode.CLIENT)
result = await client.call(lambda x, y: x + y, 3, 4, timeout=5)
print(result) # 7
client.close()
asyncio.run(main())
server.close()ZMQ PUB/SUB for one-way data streaming (snapshots, signals, heartbeats). Use this when message loss is acceptable; use ProcessBridge when you need request/response with delivery guarantees.
Both ends take a runtime= kwarg (a PubSubRuntime enum, also accepts the strings "gevent" / "asyncio") selecting the concurrency backend:
| Class | Default runtime | publish / close |
|---|---|---|
ProcessPublisher |
PubSubRuntime.GEVENT |
sync in GEVENT, awaitable in ASYNC |
ProcessSubscriber |
PubSubRuntime.ASYNC |
close sync in GEVENT, awaitable in ASYNC; handlers must be sync in GEVENT and async def in ASYNC |
The wire format is identical across runtimes, so a gevent publisher pairs with an asyncio subscriber (and vice versa) without any adapter.
# Producer (gevent side — default runtime)
from gisolate import ProcessPublisher
pub = ProcessPublisher("ipc:///tmp/stream.sock").start()
pub.publish("v1.snapshot.AAPL", {"price": 150.0})
pub.publish("v1.heartbeat.gevent", {"ts_ns": 1234567890})
pub.close()
# Consumer (asyncio side — default runtime)
import asyncio
from gisolate import ProcessSubscriber
async def main():
sub = ProcessSubscriber("ipc:///tmp/stream.sock")
async def on_snapshot(topic, payload):
print(topic, payload)
async def on_heartbeat(topic, payload):
print("heartbeat", payload)
sub.subscribe("v1.snapshot.", on_snapshot)
sub.subscribe("v1.heartbeat.", on_heartbeat)
sub.start()
await asyncio.sleep(10)
await sub.close()
asyncio.run(main())Asyncio publisher / gevent subscriber — same wire format, just flip the runtime=:
# Producer (asyncio side)
from gisolate import ProcessPublisher, PubSubRuntime
async def producer():
async with ProcessPublisher(addr, runtime=PubSubRuntime.ASYNC) as pub:
await pub.publish("v1.tick.AAPL", {"price": 150.0})
# Consumer (gevent side) — handlers are sync
from gisolate import ProcessSubscriber, PubSubRuntime
def on_tick(topic, payload): # sync, not async def
print(topic, payload)
with ProcessSubscriber(addr, runtime=PubSubRuntime.GEVENT) as sub:
sub.subscribe("v1.tick.", on_tick)
gevent.sleep(10)Notes:
- Runtime must match the host loop —
start()requires a running asyncio loop in ASYNC mode and a greenlet context in GEVENT mode. Subsequentsubscribe/unsubscribe/publish/closecalls must stay on that same loop/hub; ZMQ sockets are not thread-safe. - Handler signature follows the subscriber's runtime, not the publisher's — a gevent subscriber consuming from an asyncio publisher still uses sync handlers.
- Context managers —
withfor GEVENT,async withfor ASYNC; using the wrong form raisesRuntimeError.start()andclose()are idempotent. - Topic prefix matching —
sub.subscribe("v1.snapshot.", h)receives every topic starting with that prefix. Multiple handlers may share a prefix; in ASYNC mode they run viaasyncio.gather, in GEVENT mode each is spawned in its own greenlet. An exception in one handler is logged and does not kill the reader. close()from inside a handler is safe — the reader is not joined in that case (would self-deadlock); sibling handlers in the current dispatch are allowed to finish.- Lossy by design —
publishis non-blocking; messages are dropped when the send queue is full (slow subscriber). Tune viasndhwm=on the publisher. - Late joiners miss history — PUB/SUB has no replay; a subscriber that connects after a message was published will not see it. Treat published state as a stream, not a store.
- IPC cleanup —
close()unlinks the socket file foripc://addresses on the publisher side. Relying on__del__is best-effort only; callclose()(or use a context manager) for deterministic teardown. - Pluggable serializer — defaults to
SmartPickle(pickle, falling back to dill). Pass any object implementing theSerializerprotocol (dumps/loads) to use msgpack, JSON, etc. Publisher and subscriber must agree.
Thread-local proxy using unpatched threading.local for true isolation in gevent.threadpool:
from gisolate import ThreadLocalProxy
proxy = ThreadLocalProxy(create_client)
proxy.query("SELECT 1") # each real OS thread gets its own instancepatch_kwargs |
Child process runtime |
|---|---|
None (default) |
asyncio event loop |
dict |
gevent with patch_all(**patch_kwargs) |
# Child uses asyncio (default)
proxy = ProcessProxy.create(factory)
# Child uses gevent with selective patching
proxy = ProcessProxy.create(factory, patch_kwargs={"thread": False, "os": False})ProcessProxy.create(factory, *, timeout=24, mp_context=None, patch_kwargs=None)— create a proxy without subclassingproxy.<method>(*args, **kwargs)— transparently call any method on the remote objectProcessProxy.attach(address, *, timeout=24)— proxy a worker hosted byserve(); never spawns, andshutdown()leaves the worker runningproxy.restart_process()— kill and restart the child process; on an attached proxy there is no process to kill, so it rebuilds the local transport insteadproxy.shutdown()— gracefully stop child process- Supports context manager (
withstatement) - Thread-safe: usable from greenlets and native threads
Turn this process into the worker, bound to address, for clients that
ProcessProxy.attach(). Blocks until interrupted or terminated.
Run a function in an isolated subprocess. Blocks with gevent-safe polling.
bridge.start()— start the bridge (idempotent, returns self)bridge.address— IPC addressawait bridge.call(func, *args, timeout=60, **kwargs)— async RPC call (client mode)bridge.close()— cleanup resources
pub.start()— bind the PUB socket (idempotent, returns self). In ASYNC mode requires a running asyncio loop.pub.publish(topic, payload)— non-blocking publish; drops on slow consumers. ReturnsNonein GEVENT mode, a coroutine in ASYNC mode (mustawait).pub.close()— cleanup (idempotent). ReturnsNonein GEVENT mode, a coroutine in ASYNC mode.pub.address/pub.runtime— read-only properties.- Context manager:
withfor GEVENT,async withfor ASYNC. Using the wrong form raisesRuntimeError.
sub.subscribe(topic_prefix, handler)— register a handler for a topic prefix. Handler must be sync (def) in GEVENT mode andasync def(or returning an awaitable) in ASYNC mode. Safe to call before or afterstart().sub.unsubscribe(topic_prefix, handler=None)— remove a specific handler or all handlers for a prefix. When the last handler is removed, the ZMQ-level subscription is dropped.sub.start()— connect and spawn the reader (idempotent, returns self). In ASYNC mode requires a running asyncio loop; in GEVENT mode must be called from a greenlet context.sub.close()— tear down the socket and join the reader (idempotent). ReturnsNonein GEVENT mode, a coroutine in ASYNC mode. Safe to call from inside a handler — the reader is not joined in that case to avoid self-deadlock.sub.address/sub.runtime— read-only properties.- Context manager:
withfor GEVENT,async withfor ASYNC.
PubSubRuntime.GEVENT("gevent") — bind to the gevent hub; sync APIs and sync handlers.PubSubRuntime.ASYNC("asyncio") — bind to the running asyncio loop; awaitable APIs and async handlers.
Anything with dumps(obj) -> bytes and loads(bytes) -> obj static methods can be used as a serializer for ProcessPublisher / ProcessSubscriber. Default is SmartPickle (pickle, falling back to dill). Publisher and subscriber must agree on the serializer.
Transparent proxy delegating attribute access to a per-thread instance.
Pre-start the internal gevent hub loop on demand. Idempotent and thread-safe. Called automatically by ProcessProxy, but can be invoked explicitly to control initialization timing.
Schedule a function on the main gevent hub without waiting. Thread-safe, fire-and-forget.
Raised when a child process dies or communication fails.
Wrapper for exceptions from the child process that can't be pickled. Preserves the original exception type name and message.
Explicitly stop the internal gevent hub loop. Registered via atexit automatically.
Configure the default multiprocessing context for all proxies (default: "spawn").
multiprocessing spawn/forkserver children re-import the caller's __main__ module. If your main.py has top-level side effects (e.g. gevent.monkey.patch_all()), these will re-execute in the child — causing double-patching warnings or import errors.
Best practice: guard monkey-patching behind __name__ and defer heavy imports:
# main.py
if __name__ == "__main__":
import gevent.monkey
gevent.monkey.patch_all()
import my_app
my_app.run()Spawn children re-import main.py but skip the __name__ block, avoiding side effects.
These are decisions, not oversights — each one is documented at the site that lives with it, and each was reached by measuring the alternative.
A synchronous client method cannot be stopped (asyncio worker). Sync methods,
and a sync factory() or connect(), run on the default executor. Expiring a
call's deadline cancels only the awaiting task: the thread runs to completion, so
its side effects can land after the caller was told it timed out. Bounding it
would need an executor of our own to join, which every correct client would pay
for. max_concurrency bounds awaiting handlers; a client whose sync methods must
not overlap needs its own lock. The gevent worker has no such gap — kill()
lands inside client code at its next switch.
An asyncio handler's own cleanup can run after the client is closed. The
shutdown cancels outstanding handlers and disposes the client without waiting for
them, so a handler's finally may touch an object that is already closed. Giving
them a step first is worse, and was measured: a handler waiting on the client
build resumes holding it and reaches off_loop, which submits the call to the
executor before the await where the cancellation lands — running the very call
the shutdown exists to prevent, and taking the thread the dispose then waits on.
The gevent worker has no such trade: a kill lands at the handler's current switch
point and starts nothing.
serve() has no hard shutdown bound (asyncio worker). After the six-second
drain, asyncio.run cancels what is left and waits for it with no timeout of its
own, so a coroutine that swallows its cancellation holds the host open. A spawned
ProcessProxy worker has the bound its parent's terminate/kill gives it; an
in-process host does not. The teardown's own join of a pending client build is
bounded at six seconds, after which that client is lost rather than closed.
Two microsecond windows on an ipc:// socket file. A replacement that binds
between our bind and the claim's stat is recorded as ours; one that binds
between release's stat and its unlink is removed by us. libzmq exposes no
descriptor for the listening socket, so there is nothing to fstat instead of
the path, and closing either window needs a lock protocol an address cannot
carry. What the claim does buy is the difference between "wrong whenever anyone
rebinds" and "wrong only inside that window".
A revival is not bounded by the call that triggered it. When execute()
finds the worker dead it rebuilds it, and the spawn takes what it takes — the
call's deadline bounds the marshal, which is a queue we do not control, but not
the shared work of starting a child. restart_cooldown bounds how often an owner
pays for it.
ProcessBridge belongs to one thread. start() and close() carry no
synchronization, and the ZMQ socket, the serving greenlet and the reader task are
all single-thread constructs. Concurrent call() from many coroutines on the
owning thread is supported and is what the send lock is for; use ProcessProxy
when calls must come from several threads.
A custom multiprocessing launcher can strand a child if Process.start()
raises after creating it. start() is not atomic: CPython's POSIX spawn
creates the OS child, records its pid and sentinel, and only then writes the
bootstrap payload. gisolate recovers and reaps that case for the standard POSIX
spawn and fork launchers, which record the pid before anything fallible.
forkserver is not covered: it reads the pid last, after the request that
already created the child, so a failure before that leaves a child gisolate can
neither name nor signal. A custom mp_context whose Process/Popen creates a child and
then raises without exposing its handle may leave that child running, or a
zombie, until the parent exits. gisolate does not call waitpid(-1), because
that would consume the exit status of children owned by the host application.
Build a ProcessProxy on the thread whose hub you want it on — in practice the
main one. The owner is the thread that constructed it, and its socket, reader
and lifecycle calls belong to that thread's hub; but the marshal other threads
use to reach it targets the MAIN hub. Construct one on a native thread and those
two disagree, which for a ZMQ socket is undefined behaviour rather than a slow
path. Calling and restarting FROM other threads is supported and is what the
marshal is for.
A ProcessProxy that is dropped rather than shut down is never collected.
Its reader greenlet holds the proxy, and the hub holds the reader, so nothing
finalizes: the child, the socket and its file all stay until the process exits.
shutdown() — or the context manager — is the lifecycle; __del__ is a
best-effort backstop for the cases that do reach it, such as a start that failed
after publishing. Breaking the cycle would mean a weak reference re-resolved on
every iteration of the reader, paid by every correct user.
A subscriber handler that never returns outlives its close(). The reader
stops with its generation, but a handler already running is not killed — it holds
the callback it was given, and a close/start cycle can leave one abandoned batch
per generation. The same cooperative limit as the executor thread above: nothing
can stop code that will not stop itself.
A subscriber handler that never returns also stops the stream. Each batch of handlers is waited for before the next receive, on both runtimes, because the alternative was measured and worse: draining ahead of the handlers defeats SUB's receive queue and PUB's high-water mark — the two things that answer a handler slower than the stream — and turns a slow handler into unbounded live greenlets, each holding its payload. A handler that never returns therefore wedges its subscriber; a handler that is merely slow gets backpressure, which is the design.
An async factory() or connect() that never finishes poisons its worker.
The one client build is never cancelled and never retried while in flight: a
caller's expiring deadline abandons only its own wait. Retrying instead was the
original bug — two clients built for one worker, and a close racing a connect
that cancellation cannot reach (the executor thread above) — and each retry of a
hanging build stacks another unstoppable thread. Every call times out against
the same build; restarting the worker is the recovery, as it is for a hung sync
build.
Reaping a stranded child is not bounded. In the one launch path with a pid but no sentinel, gisolate kills the child and waits for it, and that wait has no timeout — a child SIGKILLed while in uninterruptible kernel I/O holds up the start that found it. There is no partial answer here: leaving early makes it a zombie nothing owns.
ipc://@name leaks its socket file off Linux. On Linux that is an abstract
endpoint with no filesystem entry; elsewhere @ is an ordinary path character,
and rather than risk deleting a stranger's file we leak ours.
serve() trusts whoever can connect. The wire is unauthenticated pickle and
an empty frame stops the worker, so the socket's directory is the access control.
A spawned ProcessProxy gets this for free — its addresses live in a per-uid
directory created 0700.
MIT