Executors
Version: 2.0.0 Status: SOURCE OF TRUTH Last Updated: 2026-06-28
Overview
Executors offload blocking and CPU-bound work off the async event loop, keeping it free to serve other requests.
The package (genro_asgi.executors) provides:
BaseExecutor— abstract interface; implements the decorator pattern via__call__, which delegates tosubmit().ThreadExecutor— ownedThreadPoolExecutorfor blocking I/O-bound work (GIL-bound, no pickling), with occupancy/queue pressure gauges and contextvars propagation.LocalExecutor—ProcessPoolExecutorfor CPU-bound work (true parallelism, bypasses the GIL); functions and arguments must be pickle-serializable.ExecutorRegistry— lazy creation, caching, and coordinated shutdown of named executors, with a factory mechanism for custom types.
Cross-cutting features common to both concrete executors:
Decorator usage —
@executorwraps a function so calling itawaits asubmit().Backpressure — a
max_pendingsemaphore bounds in-flight tasks.Metrics — each executor exposes a
metricsproperty for observability.Bypass mode — run synchronously without a pool for testing (
bypass=True, orGENRO_EXECUTOR_BYPASS=1globally).
Public exports:
genro_asgi.executors.__all__exposesBaseExecutor,ExecutorError,ExecutorOverloadError,ExecutorRegistry,LocalExecutor, andThreadExecutor. The top-levelgenro_asgipackage re-exports all of these exceptThreadExecutor; import it fromgenro_asgi.executors.
Architecture
Async event loop
│
├── ExecutorRegistry
│ ├── "worker" → ThreadExecutor (ThreadPoolExecutor, I/O-bound)
│ ├── "pdf" → LocalExecutor (ProcessPoolExecutor, 2 procs)
│ └── "ml" → LocalExecutor (ProcessPoolExecutor, 4 procs)
│
└── async handlers `await` executor results
Each named executor owns its own pool, so a slow workload on one pool does not block another.
BaseExecutor
The abstract base (base.py) defines the contract every executor implements and
the shared decorator behaviour. Subclasses implement only submit(),
shutdown(), and the metrics property; BaseExecutor.__call__ provides the
decorator that wraps a function into an async callable backed by submit().
from genro_asgi.executors import BaseExecutor
class MyExecutor(BaseExecutor):
name = "test"
async def submit(self, func, *args, **kwargs):
return func(*args, **kwargs)
def shutdown(self, wait=True):
...
@property
def metrics(self):
return {"name": self.name}
executor = MyExecutor()
@executor
async def double(x):
return x * 2
result = await double(5) # 10
Exceptions (also in base.py):
ExecutorError— base exception for executor operations.ExecutorOverloadError(ExecutorError)— reserved for backpressure overload.
The metrics dict produced by concrete executors includes at minimum: name,
pending, submitted, completed, failed.
ThreadExecutor
For blocking I/O-bound work that must run off the event loop while staying
observable. Unlike asyncio.to_thread (which uses the loop’s anonymous default
executor), ThreadExecutor owns its ThreadPoolExecutor, so its pressure can be
measured. It also preserves the contextvars context across the thread boundary
(copy_context().run(...)), mirroring asyncio.to_thread semantics.
from genro_asgi.executors import ThreadExecutor
executor = ThreadExecutor(name="worker", max_workers=8)
@executor
def blocking_io(data):
return slow_call(data) # runs on a pool thread
result = await blocking_io(my_data)
Constructor: ThreadExecutor(name="worker", max_workers=None, thread_name_prefix="gnr-worker", max_pending=100, bypass=False). When
max_workers is None, the pool defaults to min(32, cpu + 4).
Backpressure is two-stage: a max_pending semaphore admits tasks, then a
max_workers “gate” semaphore grants a real slot. Tasks that cleared
backpressure but are waiting on the gate count as queue_depth.
Pressure gauges
Beyond the base metrics, ThreadExecutor.metrics adds raw, instantaneous
gauges (no trend, no thresholds — those belong to a scaler, not the executor):
{
"name": "worker",
"mode": "thread", # "bypass" when no pool
"pending": ..., # submitted - completed - failed
"submitted": ...,
"completed": ...,
"failed": ...,
"avg_duration_ms": ...,
"total": ..., # max_workers (pool size)
"busy": ..., # slots currently held
"queue_depth": ..., # max(0, pending - busy)
"occupancy": ..., # busy / total
}
LocalExecutor
For CPU-bound work. Runs functions in separate processes via
ProcessPoolExecutor, achieving true parallelism past the GIL. Functions,
arguments, and return values must be pickle-serializable.
from genro_asgi.executors import LocalExecutor
executor = LocalExecutor(name="compute", max_workers=4)
@executor
def heavy_work(data):
return process(data) # runs in a worker process
result = await heavy_work(my_data)
Constructor: LocalExecutor(name="default", max_workers=None, initializer=None, initargs=(), max_pending=100, bypass=False). When
max_workers is None, the pool uses the CPU count.
If a non-pickle-serializable argument reaches the pool, _execute raises
ExecutorError wrapping the original pickle.PicklingError.
Worker initialization (preloaded data)
LocalExecutor forwards initializer / initargs to the underlying
ProcessPoolExecutor, so each worker process can load heavy data once at
startup and reuse it across tasks:
# Worker-side module globals
_model = None
def init_ml_worker(model_path):
global _model
_model = load_heavy_model(model_path)
def predict(data):
return _model.predict(data) # model already loaded
executor_ml = LocalExecutor(
name="ml",
max_workers=4,
initializer=init_ml_worker,
initargs=("/models/v1.pkl",),
)
@executor_ml
def predict(data):
return _model.predict(data)
Typical preload targets: ML models, large lookup tables, compiled regexes, per-worker connection pools, static configuration.
Metrics
{
"name": "compute",
"mode": "process", # "bypass" when no pool
"pending": ...,
"submitted": ...,
"completed": ...,
"failed": ...,
"avg_duration_ms": ...,
}
ExecutorRegistry
The registry (registry.py) is the public way to obtain a named, cached
executor. It ships two built-in factories, "local" (→ LocalExecutor) and
"thread" (→ ThreadExecutor); "local" is the default executor_type.
from genro_asgi.executors import ExecutorRegistry
registry = ExecutorRegistry()
# Process pool for CPU-bound work (default type "local")
pdf = registry.get_or_create("pdf", max_workers=2)
# Thread pool for blocking I/O
io = registry.get_or_create("io", executor_type="thread", max_workers=8)
# Same name returns the cached instance (kwargs ignored on a cache hit)
same_pdf = registry.get_or_create("pdf")
assert same_pdf is pdf
@pdf
def generate_pdf(data):
return create_pdf(data)
result = await generate_pdf(report_data)
get_or_create(name, executor_type="local", **kwargs):
Returns the cached executor if
namealready exists (extra kwargs ignored).Otherwise looks up
executor_typein the factory table; an unknown type raisesValueErrorlisting the available types.Calls the factory as
factory(name=name, **kwargs)and caches the result.
Other registry API:
register_factory(executor_type, factory)— register a custom type. The factory signature isfactory(name: str, **kwargs) -> BaseExecutor.get(name)— return the executor orNonewithout creating it.shutdown_all(wait=True)— shut down every executor and clear the cache.all_metrics()— list of each executor’smetricsdict.executors(property) — copy of thename -> executormap.len(registry),name in registry— size and membership.
Custom factory
def make_remote(name, **kw):
return RemoteExecutor(name, **kw)
registry.register_factory("remote", make_remote)
heavy = registry.get_or_create("heavy", executor_type="remote", url="...")
GenroAsgiWorker owns a ThreadExecutor
GenroAsgiWorker (server/worker.py) — the minimal single-app ASGI server a
commander spawns to distribute load — owns a ThreadExecutor as its dispatch
for blocking work:
self.executor = ThreadExecutor(name="worker", max_workers=max_workers)
The worker constructor accepts max_workers and passes it straight to the
ThreadExecutor. The mounted app reaches the executor through the dual
parent-child relationship as app.server.executor. The executor’s metrics
are how the worker reports its own pressure (busy / total / queue_depth /
occupancy); the executor makes no scaling decisions itself. On shutdown
(run() finally-block) the worker calls self.executor.shutdown().
This is the only place in genro-asgi that wires an executor into the request path today.
Bypass mode for testing
Both concrete executors accept bypass=True (or honour the global
GENRO_EXECUTOR_BYPASS=1 environment variable). In bypass mode no pool is
created and submit() runs the function synchronously, so decorated functions
still await correctly:
from genro_asgi.executors import LocalExecutor
executor = LocalExecutor(name="test", bypass=True)
@executor
def square(x):
return x * x
async def test_square():
assert await square(5) == 25
assert executor.metrics["mode"] == "bypass"
Backpressure
Both executors bound in-flight work with a max_pending semaphore (default
100). LocalExecutor acquires that one semaphore around each task.
ThreadExecutor adds a second max_workers “gate” semaphore: a task first
passes max_pending admission, then waits for a free worker slot, and the wait
between the two shows up as queue_depth.
ExecutorOverloadError is defined in base.py for backpressure overload
scenarios; the current LocalExecutor / ThreadExecutor implementations block
on the semaphore rather than raising it.
Constraints
LocalExecutor: decorated functions must be top-level (not lambdas or bound methods), and all arguments / return values must be pickle-serializable.LocalExecutorworker-preloaded data is effectively read-only across tasks (separate process memory).ThreadExecutorruns on the GIL (no pickling, no extra processes), so it suits I/O-bound — not CPU-bound — work; it propagates the contextvars context into the thread.Pool workers are persistent: created at pool construction, reused for all tasks.
No server-level executor entry point yet
AsgiServer has no executor() method and wires no ExecutorRegistry in
server/server.py; there is no ExecutorDecorator class. (The former
ServerBinder, which delegated to a nonexistent server.executor(...), was dead
code and has been removed.)
To obtain a pool today, use ExecutorRegistry.get_or_create (or instantiate
ThreadExecutor / LocalExecutor directly), exactly as GenroAsgiWorker does.
A server-level executor() convenience, backed by an ExecutorRegistry owned by
the server, is a reasonable future addition but is not implemented.
Remote executors (roadmap — not implemented)
A remote/distributed executor is a planned extension, not present in the code. The intended shape is a custom factory registered on the registry:
# Roadmap — RemoteExecutor does not exist yet.
registry.register_factory("remote", lambda name, **kw: RemoteExecutor(name, **kw))
heavy = registry.get_or_create("heavy", executor_type="remote", url="...")
@heavy
def heavy_task(data):
return process(data) # would run on a remote worker
Goals: horizontal scaling across machines, load balancing, and fault tolerance,
behind the same BaseExecutor decorator API as the local executors.
Copyright: Softwell S.r.l. (2025) License: Apache License 2.0