The concurrency model you choose in uWSGI sets how many requests your server handles simultaneously, what that costs in memory, and what your monitoring signals mean once traffic arrives. Get it wrong and you either waste memory on idle processes or starve the kernel listen queue because you misread what “all workers busy” indicates.
uWSGI supports three concurrency models. Pre-fork gives you one request per worker process. Threaded gives you N requests per worker via threads. Async (gevent or asyncio) gives you many requests per worker via an event loop. Each has a different memory profile, a different CPU characteristic, and a different definition of “busy.”
What it is and why it matters
By default, uWSGI starts with a single process and a single thread: one concurrent request. To scale, you add processes, threads, async cores, or some combination.
The decision is not reversible at runtime. Switching from threaded to pre-fork requires a full restart. If the GIL is serializing your CPU-bound workload under load, you are looking at a redeploy, not a configuration tweak.
See how uWSGI actually works in production for the master-worker architecture.
How it works
uWSGI is a pre-fork application server. A master process spawns worker processes, manages their lifecycle, enforces timeouts, and handles reloads. The master never serves requests. Workers pull connections from the kernel listen queue via accept() and process them.
The three concurrency models differ in what happens inside each worker after accept() returns.
flowchart LR
subgraph "Pre-fork"
A1["Worker process"] --> A2["1 request at a time"]
A2 --> A3["True parallel across workers"]
end
subgraph "Threaded"
B1["Worker process"] --> B2["N threads, N requests"]
B2 --> B3["GIL serializes CPU work"]
end
subgraph "Async (gevent)"
C1["Worker process"] --> C2["N greenlets via event loop"]
C2 --> C3["I/O multiplexed, CPU serial"]
endPre-fork (processes only). --processes N spawns N worker processes. Each worker is a full copy of the application loaded into memory. After fork, Linux copy-on-write (COW) means workers share memory pages initially, but pages diverge as the application runs and writes to memory. Concurrency equals the process count. Each worker processes exactly one request at a time. Multiple processes provide true CPU parallelism because each has its own Python interpreter and its own GIL.
Threaded (processes times threads). --threads N runs N threads inside each worker process. Threads share the worker’s address space, so adding threads does not copy the application. Concurrency equals processes times threads. In Python, the GIL serializes bytecode execution within a worker: only one thread executes Python at a time. The GIL is released during I/O waits (socket reads, file operations, sleep), so threads overlap for I/O-bound workloads. For CPU-bound work, threads within a single worker do not give you parallelism.
The --threads option automatically enables the GIL initialization that uWSGI needs for threaded operation. If you set enable-threads = true without --threads, you get GIL support for application-generated background threads (Sentry, metrics, scheduled tasks) but not multi-threaded request handling. The failure mode: setting enable-threads alone and assuming you have threaded request handling when you do not.
Async (gevent or asyncio). --gevent N runs an event loop inside each worker. Each worker multiplexes many concurrent I/O-bound requests via greenlets or coroutines. Concurrency equals processes times async cores. Like threads, async mode is GIL-constrained for CPU-bound work: the event loop runs in a single thread, and CPU-bound greenlets block it. Async mode is suited for workloads that spend most of their time waiting on network I/O.
Memory cost per model
Each worker process is a full copy of the application. The memory floor is the same across all three models: processes times per-worker RSS. The difference is how much concurrency you get for that floor.
| Model | Concurrency | Memory floor | Notes |
|---|---|---|---|
| Pre-fork | processes | processes x per-worker RSS | COW sharing at start, diverges as app runs |
| Threaded | processes x threads | processes x per-worker RSS | Threads share address space; per-thread cost is mainly stack |
| Async | processes x async-cores | processes x per-worker RSS | Greenlets share address space; stack per greenlet is small |
In pre-fork mode, adding a process adds a full app copy. In threaded and async mode, adding threads or greenlets adds minimal memory (stack space). --threads-stacksize can reduce per-thread stack allocation.
--lazy-apps changes COW behavior. With lazy-apps, each worker loads the application independently after fork, so there is no copy-on-write sharing between workers. RSS per worker is higher from the start. This is safer for libraries that are not fork-safe, but it increases the memory floor.
RSS reported by the stats server or ps over-reports per-worker usage because shared pages (shared libraries, COW pages) are counted fully for each process. Use /proc/PID/smaps PSS (Proportional Set Size) or the smem tool for accurate per-worker accounting.
Python and Ruby memory allocators rarely return freed memory to the OS. RSS may stabilize at a high-water mark even after a leak is fixed, because of allocator fragmentation. This compounds the cost of each additional process since each carries its own fragmented heap.
Where it shows up in production
What “all workers busy” means changes
This is the most consequential monitoring difference between the three models.
In pre-fork mode, a worker with status: "busy" is processing exactly one request. When all workers are busy, the pool is exhausted and new connections queue in the kernel backlog. The busy ratio directly maps to capacity utilization.
In threaded mode, a worker with status: "busy" has at least one active thread. It may still have idle threads with capacity for more requests. The busy ratio overstates utilization because it does not tell you how many threads within each worker are occupied. For thread-level visibility, use per-core in_request from the stats server’s cores[] array.
In async mode, a worker is almost never “idle” because the event loop is always running. The busy ratio is nearly useless. Utilization tracking must be based on per-core request counts and active greenlet counts, not the worker-level busy/idle flag.
Database connection multiplication
With processes times threads concurrency, database connections multiply. Each thread may hold its own connection from the application’s connection pool. If you run 4 processes with 8 threads each, your application may open up to 32 concurrent database connections per uWSGI instance. Across multiple app servers, this can exhaust the database’s connection limit.
A connection pooler (PgBouncer for PostgreSQL, ProxySQL for MySQL) between uWSGI and the database is the standard mitigation. The alternative is reducing the thread count and relying on async mode for I/O concurrency, which multiplexes many requests over fewer threads and thus fewer connections.
thunder-lock and worker distribution
When multiple worker processes call accept() on the same listening socket, the kernel’s thundering herd behavior wakes all idle workers to compete for a single incoming connection. --thunder-lock serializes the accept() call across workers, distributing connections more evenly and reducing context-switch overhead. This matters most with high process counts. It has no effect on threads within a worker because threads do not call accept() independently.
The cheaper subsystem
The --cheaper subsystem dynamically scales the worker process count based on demand. It works by spawning and despawning processes, not threads. If you rely on cheaper for adaptive scaling, your concurrency model must include multiple processes, since the process is the unit cheaper manages. A single-process deployment cannot benefit from cheaper scaling.
Tradeoffs and when to use it
CPU-bound workloads. Use more processes. Each process has its own Python interpreter and GIL, so multiple workers execute Python bytecode in true parallel. Adding threads within a worker does not help because the GIL serializes CPU work. The tradeoff is memory: each process is a full app copy.
I/O-bound workloads. Use threads or async mode. The GIL is released during I/O waits, so threads or greenlets overlap network calls, database queries, and cache lookups. The memory cost is lower because threads or greenlets share the worker’s address space. A smaller process count with higher thread or async-core counts gives you more concurrency per megabyte of RSS.
Memory-constrained environments. Threaded or async mode reduces the memory floor. Instead of 8 processes each holding a full app copy, run 2 processes with 4 threads each for the same concurrency with a quarter of the process-level memory overhead. The tradeoff is GIL contention if any requests do significant CPU work.
Python 3.14 free-threading. Python 3.14 supports free-threading (PEP 703), which allows true parallel Python execution without the GIL. On free-threaded builds, threads within a worker could execute Python bytecode in parallel. Until free-threading is proven in production with uWSGI, the safe assumption is that the GIL still constrains threaded workers.
No magic formula. The commonly cited processes = 2 * cpucores + 1 is a starting point, not a rule. uWSGI documentation states that simple math based on CPU count is not sufficient. The right number depends on your application’s memory footprint, request latency profile, and downstream dependency behavior.
Signals to watch in production
| Signal | Why it matters | Warning sign |
|---|---|---|
| Worker busy ratio | Capacity utilization (pre-fork); overstates utilization in threaded/async | Sustained at or near 100% means pool exhaustion |
Per-core in_request | Thread-level visibility in threaded and async mode | All cores in_request across all workers means true saturation |
| Worker RSS (per worker) | Memory floor; growth indicates leak or fragmentation | RSS approaching reload-on-rss threshold or system memory limit |
| Total worker RSS (COW-adjusted) | Actual memory consumed by the pool | Total PSS exceeding 70% of system RAM |
| Harakiri rate | Requests exceeding timeout; may indicate wrong model for workload | Non-zero rate in a normally-zero deployment |
| Accepting worker count | Workers able to serve requests right now | Dropping below cheaper minimum, or to zero |
| Request throughput (delta) | Whether the pool is keeping up with demand | Sudden drop with no traffic decrease |
Avg response time (avg_rt) | Latency trend | Approaching harakiri timeout or 2x baseline |
How Netdata helps
- Per-second worker RSS collection shows the COW divergence curve after fork, distinguishing shared pages from per-worker growth. This is critical when comparing process-heavy versus thread-heavy deployments.
- Worker busy ratio and accepting worker count are tracked continuously. Cross-reference with per-core request counts to determine whether threaded-mode “busy” workers have idle thread capacity.
- Harakiri rate deltas correlate with response time trends, helping you identify whether a timeout spike stems from GIL contention on CPU-bound threaded workers or a downstream dependency.
- Memory metrics (RSS, PSS via
/proc/PID/smaps, system-level swap) correlate with worker respawn rates, making the memory floor of each model visible across configuration changes. - The cheaper subsystem’s worker count fluctuations are tracked alongside utilization ratios, so you can verify that adaptive process scaling is working within your chosen model.
Related guides
- uWSGI all workers busy: reading the busy ratio before the queue fills
- uWSGI chain reload: cycling workers one at a time for zero-downtime deploys
- uWSGI connection refused: clients turned away when the backlog overflows
- uWSGI reload thundering herd: capacity drops to zero during a slow restart
- uWSGI harakiri death spiral: workers killed and respawned while throughput collapses
- uWSGI harakiri not configured: stuck workers with no timeout and no recovery
- uWSGI harakiri timeout: setting it against request duration and nginx timeouts
- uWSGI harakiri-verbose: finding the blocked syscall behind a timeout
- uWSGI HARAKIRI ON WORKER: requests killed for exceeding the timeout
- How uWSGI actually works in production: a mental model for operators
- uWSGI listen backlog and net.core.somaxconn: sizing the connection queue
- uWSGI listen queue full: the backlog overflow that drops connections silently






