Execution Models
Thread pool, prefork, native async — pick the right one for your workload.
Thread pool, prefork, native async — pick the right one for your workload.
Choose how tasks execute: OS threads (default), child processes (prefork), or native async.
| Mode | Concurrency | GIL | Memory per worker | Startup cost | Best for |
|---|---|---|---|---|---|
| Thread Pool | workers OS threads | Shared | ~1 MB | None | I/O-bound sync tasks |
| Prefork | workers child processes | Independent | ~30 MB | One app import per child | CPU-bound tasks, mixed workloads |
| Native Async | async_concurrency coroutines | Shared (event loop) | Negligible per coroutine | None | I/O-bound async tasks |
The default. Runs sync task functions on Rust std::thread threads. Each
worker acquires the Python GIL (Global Interpreter Lock — only one thread runs Python bytecode at a time) only during task execution — the scheduler and
dispatch logic never touch it.
# Default — thread pool with auto-detected worker count
queue.run_worker()
# Explicit worker count
queue.run_worker(workers=8)flexiq worker --app myapp:queue --workers 8Because threads share a single GIL, CPU-bound tasks block each other. For Python code that spends most of its time in C extensions (numpy, pandas) that release the GIL, threads still work well.
Spawns separate child processes. Each process has its own Python interpreter and GIL, so CPU-bound tasks run in true parallel.
queue.run_worker(pool="prefork", app="myapp:queue")flexiq worker --app myapp:queue --pool preforkThe app parameter tells each child process where to import your Queue
instance. It must be a module-level name ("module:attribute" format) —
tasks defined inside functions or closures cannot be imported by child
processes.
For more details, see the Prefork Pool guide.
async def task functions run on a dedicated Python event loop thread. No
asyncio.run() wrapping, no thread-per-task overhead.
@queue.task()
async def fetch_prices(symbol: str) -> dict:
async with httpx.AsyncClient() as client:
r = await client.get(f"https://api.example.com/prices/{symbol}")
return r.json()Control how many coroutines run at once:
queue = Queue(
db_path="myapp.db",
async_concurrency=200, # default: 100
)For more details, see the Native Async Tasks guide.
A single queue handles both sync and async tasks. No configuration needed — the worker inspects each task at registration time and routes it to the correct pool.
@queue.task()
def resize_image(path: str) -> str:
# Sync — runs on thread pool
...
@queue.task()
async def send_notification(user_id: str) -> None:
# Async — runs on event loop
...Both are enqueued, retried, rate-limited, and monitored identically.
These two parameters are independent:
queue = Queue(
workers=4, # OS threads (or child processes) for sync tasks
async_concurrency=200, # concurrent coroutines for async tasks
)workers=4 means 4 sync tasks can execute at the same time.
async_concurrency=200 means 200 async tasks can be in-flight concurrently
on the event loop. A queue with both set runs up to 4 + 200 tasks
simultaneously.
For mostly-async workloads, keep workers small (2–4) and raise
async_concurrency. For mostly-sync I/O workloads, raise workers. For
CPU-bound workloads, switch to prefork.