Choosing Celery Prefork vs Gevent Pools

The execution pool decides how many tasks a worker runs at once, how it fails, and whether time limits mean anything — which makes it one of the highest-leverage settings in Celery Architecture & Configuration, part of Backend Frameworks & Worker Scaling.

Problem Statement

Your webhook-delivery workers run the default prefork pool with concurrency 8 on 4-core nodes. Each task spends 400ms waiting on an HTTP response and about 5ms computing, so eight processes consume eight cores' worth of memory to keep almost nothing busy. Throughput is capped at roughly 20 requests per second per node, and scaling out means paying for RAM to hold idle processes. Meanwhile your image-resize workers, on the same configuration, genuinely need the isolation prefork provides.

Prerequisites

  • A breakdown of where task time goes: CPU versus network or disk wait.
  • Knowledge of which libraries the tasks use, since C extensions and gevent interact badly.
  • Memory measurements per worker process — the pool choice changes the arithmetic entirely.
  • The ability to run separate worker deployments per queue, which is what makes per-workload pools possible.

Step 1 — Know What Each Pool Actually Is

Pool Unit of concurrency True parallelism Preemptible Best for
prefork OS process Yes, up to core count Yes (hard time limit) CPU work, untrusted libraries, isolation
gevent Greenlet (cooperative) No — one core No High-concurrency network I/O
eventlet Greenlet (cooperative) No — one core No Same as gevent; older ecosystem
threads OS thread Only outside the GIL No C extensions that release the GIL
solo Main process No No Debugging and strict serialisation
Prefork parallelism versus gevent concurrency On the left, four prefork child processes each run one task at a time on their own core, using separate memory and giving true CPU parallelism. On the right, a single gevent process runs hundreds of greenlets on one core, switching between them whenever one blocks on network input or output, which yields high concurrency but no CPU parallelism. Parallelism and concurrency are not the same thing prefork — 4 processes, 4 cores child 1 · 180 MB 1 task, real CPU child 2 · 180 MB 1 task, real CPU child 3 · 180 MB child 4 · 180 MB 720 MB total 4 concurrent tasks a crash kills one child gevent — 1 process, 500 greenlets one process · 220 MB each greenlet yields while waiting on the network 500 concurrent tasks on one core — if they are all waiting one CPU-bound task blocks all 500 a segfault kills every in-flight task

Step 2 — Measure Where the Time Goes

The decision follows from one number: the fraction of task duration spent waiting rather than computing.

import time, resource
from celery.signals import task_prerun, task_postrun

_start = {}

@task_prerun.connect
def _pre(task_id=None, **kw):
    ru = resource.getrusage(resource.RUSAGE_SELF)
    _start[task_id] = (time.monotonic(), ru.ru_utime + ru.ru_stime)

@task_postrun.connect
def _post(task_id=None, sender=None, **kw):
    wall0, cpu0 = _start.pop(task_id, (None, None))
    if wall0 is None:
        return
    ru = resource.getrusage(resource.RUSAGE_SELF)
    wall = time.monotonic() - wall0
    cpu = (ru.ru_utime + ru.ru_stime) - cpu0
    # cpu/wall near 1.0 → CPU-bound (prefork). Near 0 → I/O-bound (gevent).
    CPU_RATIO.labels(task=sender.name).observe(cpu / wall if wall else 0)

A ratio below about 0.2 means the task is waiting four fifths of the time and gevent will multiply throughput several-fold. Above 0.7, gevent buys nothing and prefork's parallelism is what you need.

Step 3 — Configure Prefork for CPU Work

# celeryconfig.py — CPU-bound workers
worker_pool = "prefork"
worker_concurrency = 4                  # ≈ cores; more only adds context switching
worker_max_tasks_per_child = 500        # recycle to bound memory growth
worker_max_memory_per_child = 400_000   # KB — restart a child above 400 MB
worker_prefetch_multiplier = 1
task_soft_time_limit = 55
task_time_limit = 75                    # enforceable: the child is killed

worker_max_memory_per_child is the setting that quietly saves prefork fleets: a task with a slow leak recycles its child instead of growing until the OOM killer removes the whole worker.

Step 4 — Configure Gevent for I/O Work

pip install "celery[gevent]"
celery -A app worker --pool=gevent --concurrency=200 -Q webhooks -n webhooks@%h
# The monkey patch MUST run before anything imports socket, ssl or urllib3.
# Putting it at the top of the module Celery loads first is the only reliable place.
from gevent import monkey
monkey.patch_all()

import app  # noqa: E402  — import order matters here more than lint style

Two constraints come with it. Hard time limits do not work: task_time_limit relies on signalling a child process, and greenlets are not processes, so a runaway task runs forever. And any C extension that blocks without releasing the GIL — some database drivers, some compression and image libraries — blocks the entire pool rather than one task.

# Gevent-appropriate settings
worker_pool = "gevent"
worker_concurrency = 200        # bounded by the downstream, not by cores
task_soft_time_limit = 30       # cooperative only — the task must yield to see it
task_time_limit = None          # unenforceable under gevent; do not pretend otherwise

Step 5 — Size Concurrency from the Bottleneck

def gevent_concurrency(target_rps: float, p95_seconds: float,
                       downstream_limit: int) -> int:
    """Little's Law, capped by whatever the downstream will actually accept."""
    needed = math.ceil(target_rps * p95_seconds)
    return min(needed, downstream_limit)

gevent_concurrency(200, 0.4, downstream_limit=500)   # 80
gevent_concurrency(200, 0.4, downstream_limit=50)    # 50 — the API is the limit

Setting concurrency to 1,000 because greenlets are cheap simply moves the queue from the broker into the downstream's connection backlog, where it is invisible. The binding constraint is almost always a pool or a quota — the same reasoning as tuning prefetch and consumer concurrency.

Step 6 — Run Both, One per Workload

One pool type per workload, not per application Two worker deployments consume different queues from the same broker. The webhooks fleet uses a gevent pool with high concurrency and low memory per node. The media fleet uses a prefork pool with concurrency matched to cores, memory limits per child, and enforceable hard time limits. Pick the pool per queue, not per application broker webhooks · media webhooks · gevent concurrency 200 · 2 pods 400 in flight, 440 MB media · prefork concurrency 4 · 6 pods 24 in flight, hard limits work Same codebase, same broker, two deployments with different --pool flags. This is the main practical reason to route by workload in the first place. Monkey-patching applies per process, so the two must never share one worker.

Step 7 — Understand How Each Pool Fails

Pool choice changes the blast radius of a failure, which matters more than throughput when something goes wrong.

Under prefork, a segfault in a C library kills one child; the parent notices and replaces it, and only that child's task is lost — which task_reject_on_worker_lost turns into a redelivery rather than a loss. Under gevent, the same segfault takes the process down with every in-flight greenlet, so a single bad input can destroy two hundred unrelated tasks at once. That asymmetry is the strongest argument for prefork whenever the task touches native code you do not control.

Memory failures differ too. A prefork leak is bounded by max_memory_per_child, which recycles quietly. A gevent leak accumulates in one long-lived process with no equivalent guard, so it ends in an OOM kill that takes the whole pool. If you run gevent, add an external memory watchdog that restarts the worker gracefully well before the cgroup limit — the graceful path in handling SIGTERM in Celery workers — rather than letting the kernel choose the moment.

Step 8 — Cost the Decision in Nodes, Not in Feelings

Pool choice has a directly computable cost, and putting numbers on it usually ends the debate faster than any argument about elegance.

def nodes_required(target_rps: float, p95_seconds: float, pool: str,
                   cores_per_node: int = 4, ram_gb: int = 8,
                   mem_per_proc_mb: int = 180) -> int:
    """How many nodes to sustain target_rps, given the pool's concurrency model."""
    in_flight = math.ceil(target_rps * p95_seconds)
    if pool == "prefork":
        per_node = min(cores_per_node,
                       (ram_gb * 1024) // mem_per_proc_mb)   # cores or RAM, whichever binds
    else:                                                    # gevent
        per_node = 250                                       # bounded by the downstream
    return math.ceil(in_flight / per_node)

nodes_required(200, 0.4, "prefork")   # 20 nodes — 80 in flight, 4 per node
nodes_required(200, 0.4, "gevent")    # 1 node   — 80 greenlets on one core

For the webhook workload in the problem statement, that is a twentyfold difference in infrastructure for identical throughput. For an image-resize workload the calculation inverts entirely: greenlets give no CPU parallelism, so gevent would need the same twenty nodes and lose the hard time limits and crash isolation that prefork provides.

Run the numbers per queue and record them alongside the pool setting. The output is not just a node count — it is the justification that stops the setting being changed on intuition six months later, and it makes the review trigger obvious: re-run it when p95 duration or the CPU ratio moves by more than a factor of two.

Nodes required for the same throughput For an I/O-bound workload of two hundred requests per second with a four-hundred-millisecond p95, a prefork pool needs twenty nodes because concurrency is bounded by cores and memory. A gevent pool needs one node because greenlets interleave while waiting. For a CPU-bound workload the comparison inverts and prefork is the only workable choice. Same 200 req/s, same p95 — very different bill I/O-bound · prefork 20 nodes — 4 concurrent per node I/O-bound · gevent 1 node — 80 greenlets on one core CPU-bound · either 20 nodes — greenlets add no CPU parallelism For CPU work, gevent costs the same nodes and loses hard time limits and crash isolation.

Verification

  1. The CPU ratio matches the pool. Tasks on gevent queues should show a ratio below 0.2; anything above 0.5 belongs on prefork.
  2. Throughput improved measurably. Compare requests per second per node before and after; an I/O-bound workload should improve several-fold, not marginally.
  3. Monkey patching happened early. python -c "import app; import socket; print(socket.socket)" must show the gevent-patched class.
  4. Time limits behave as documented. Run a deliberately infinite task on each pool: prefork must kill it at the hard limit, gevent must not — confirm you are not relying on a guarantee you do not have.

Gotchas & Edge Cases

Late monkey patching silently does nothing. If socket is imported before patch_all(), the pool runs but every call blocks, and concurrency collapses to one. Nothing warns you.

Blocking C extensions freeze the whole gevent pool. psycopg2 in its default mode, some image libraries, and any time.sleep inside a C call will stall every greenlet. Use psycogreen or move that work to prefork.

Hard time limits are prefork-only. Documentation and configuration accept task_time_limit under gevent; it simply has no effect. Long-running tasks there must cooperate.

Prefork forks after import. Anything created at import time — database connections, file handles, random seeds — is shared across children in an undefined state. Create connections inside the task or in worker_process_init.

Mixing pools in one worker is impossible. Monkey patching is process-wide, so a single worker cannot serve a gevent queue and a prefork queue. Separate deployments are not a stylistic choice here; they are the only way to run both.

Threads help only for GIL-releasing extensions. The threads pool is genuinely useful for NumPy, image codecs, and drivers that release the GIL, and nearly useless for pure Python.

Related