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"]
    end

Pre-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.

ModelConcurrencyMemory floorNotes
Pre-forkprocessesprocesses x per-worker RSSCOW sharing at start, diverges as app runs
Threadedprocesses x threadsprocesses x per-worker RSSThreads share address space; per-thread cost is mainly stack
Asyncprocesses x async-coresprocesses x per-worker RSSGreenlets 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

SignalWhy it mattersWarning sign
Worker busy ratioCapacity utilization (pre-fork); overstates utilization in threaded/asyncSustained at or near 100% means pool exhaustion
Per-core in_requestThread-level visibility in threaded and async modeAll cores in_request across all workers means true saturation
Worker RSS (per worker)Memory floor; growth indicates leak or fragmentationRSS approaching reload-on-rss threshold or system memory limit
Total worker RSS (COW-adjusted)Actual memory consumed by the poolTotal PSS exceeding 70% of system RAM
Harakiri rateRequests exceeding timeout; may indicate wrong model for workloadNon-zero rate in a normally-zero deployment
Accepting worker countWorkers able to serve requests right nowDropping below cheaper minimum, or to zero
Request throughput (delta)Whether the pool is keeping up with demandSudden drop with no traffic decrease
Avg response time (avg_rt)Latency trendApproaching 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.