Skip to content

prefork is not safe anymore - #10339

Merged
auvipy merged 4 commits into
celery:mainfrom
ConsioAi:worker-spawn-instead-of-fork
Sep 9, 2026
Merged

prefork is not safe anymore#10339
auvipy merged 4 commits into
celery:mainfrom
ConsioAi:worker-spawn-instead-of-fork

Conversation

@superboum

@superboum superboum commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

prefork is not safe anymore

Table of content:

  • A proof/reproducer showing that prefork is unsafe
  • Why prefork is no longer safe
  • Why this trade-off was fine before, and why it is not anymore
  • prefork can be safe again
  • How this PR partially fixes the issue
  • Testing the PR on the reproducer

A proof/reproducer showing that prefork is unsafe

Setup a test env:

mkdir /tmp/celery-gcpubsub && cd /tmp/celery-gcpubsub
uv init
uv add celery[gcpubsub] google-cloud-iam google-cloud-monitoring google-cloud-pubsub

Some info about the environment:

celery 5.6.3 (recovery)
Python 3.10.19
uv 0.10.2
Linux lheureduthe 6.12.73 #1-NixOS SMP PREEMPT_DYNAMIC Mon Feb 16 16:09:13 UTC 2026 x86_64 GNU/Linux

Write tasks.py:

from celery import Celery
import random
import time
import faulthandler, signal

faulthandler.register(signal.SIGUSR2, all_threads=True)

app = Celery('tasks', broker='gcpubsub://projects/consio-development/')
app.conf.task_default_queue = 'a_qdu_reproducer'
app.conf.task_default_exchange = 'a_qdu_reproducer'
app.conf.task_default_routing_key = 'a_qdu_reproducer'

@app.task
def dummy():
    print("dummy task")

@app.task(time_limit=2, acks_late=True)
def complex():
    print("progress is made...")
    dummy.delay()
    time.sleep(3600)

Write main.py

from tasks import dummy, complex, app
import logging
import time

def main():
    print("Hello from gcpubsub!")
    for i in range(100):
        print(complex.delay())
    time.sleep(50)

if __name__ == "__main__":
    main()

Start worker following official grpc recommendations:

export GRPC_POLL_STRATEGY=poll
export GRPC_ENABLE_FORK_SUPPORT=true
uv run celery -A tasks worker \
  --without-gossip \
  --without-mingle \
  --without-heartbeat  \
  --loglevel=DEBUG \
  --concurrency 1

Send tasks to worker:

uv run main.py

Note that the worker becomes stuck with this log (no more progress is made, worker can't process tasks anymore):

I0603 16:40:02.202568  154246 fork_posix.cc:71] Other threads are currently calling into gRPC, skipping fork() handlers
I0603 16:40:02.208759  154425 ev_poll_posix.cc:593] FD from fork parent still in poll list: fd(20, generation: 1)
I0603 16:40:02.208894  154425 ev_poll_posix.cc:593] FD from fork parent still in poll list: fd(18, generation: 1)
I0603 16:40:02.208916  154425 ev_poll_posix.cc:593] FD from fork parent still in poll list: fd(16, generation: 1)
I0603 16:40:02.208929  154425 ev_poll_posix.cc:593] FD from fork parent still in poll list: fd(14, generation: 1)
Full trace
[2026-06-03 16:39:59,182: INFO/MainProcess] Task tasks.complex[730876e3-52c8-49ab-843b-50d1dec25994] received
[2026-06-03 16:39:59,182: DEBUG/MainProcess] TaskPool: Apply <function fast_trace_task at 0x7fffe8b09bd0> (args:('tasks.complex', '730876e3-52c8-49ab-843b-50d1dec25994', {'lang': 'py', 'task': 'tasks.complex', 'id': '730876e3-52c8-49ab-843b-50d1dec25994', 'shadow': None, 'eta': None, 'expires': None, 'group': None, 'group_index': None, 'retries': 0, 'timelimit': [2, None], 'root_id': '730876e3-52c8-49ab-843b-50d1dec25994', 'parent_id': None, 'argsrepr': '()', 'kwargsrepr': '{}', 'origin': 'gen151142@lheureduthe', 'ignore_result': False, 'replaced_task_nesting': 0, 'stamped_headers': None, 'stamps': {}, 'properties': {'correlation_id': '730876e3-52c8-49ab-843b-50d1dec25994', 'reply_to': '5cfba92a-eb5b-384e-ae2a-ac9216a34e38', 'delivery_mode': 2, 'delivery_info': {'exchange': '', 'routing_key': 'a_qdu_reproducer', 'gcpubsub_message': {'queue': 'kombu-a_qdu_reproducer', 'ack_id': 'UAYWLF1GSFE3GQhoUQ5PXiM_NSAoRRcECBQFfH13Ulh1WFkaB1ENGXJ8aCBiDkYAChZTLVVaEw5iXE5EB0mC0MKcV1dLWxoIAEZUeVdfGAVsXVtzAVglp82fhZSBqQEbOX3ct--6LS2K7dpZZiI9XxJLLD5-PD9FQV5AEkw3CkRJUytDCypYEU4EISE-MD5FU0Q', 'message_id': '19923778438417669',... kwargs:{})
[2026-06-03 16:39:59,182: WARNING/ForkPoolWorker-1] progress is made...
[2026-06-03 16:39:59,185: INFO/ForkPoolWorker-1] new GCP pub/sub channel: gcpubsub://projects/consio-development/
[2026-06-03 16:39:59,185: INFO/ForkPoolWorker-1] unacked deadline extension thread: [154354] started
[2026-06-03 16:39:59,186: DEBUG/ForkPoolWorker-1] binding queue: kombu-a_qdu_reproducer to direct exchange: a_qdu_reproducer with routing_key: a_qdu_reproducer
[2026-06-03 16:40:01,606: ERROR/MainProcess] Task handler raised error: TimeLimitExceeded(2)
Traceback (most recent call last):
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/pool.py", line 684, in on_hard_timeout
    raise TimeLimitExceeded(job._timeout)
billiard.einfo.ExceptionWithTraceback:
"""
Traceback (most recent call last):
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/pool.py", line 684, in on_hard_timeout
    raise TimeLimitExceeded(job._timeout)
billiard.exceptions.TimeLimitExceeded: TimeLimitExceeded(2,)
"""
[2026-06-03 16:40:01,607: ERROR/MainProcess] Hard time limit (2s) exceeded for tasks.complex[ce54d38c-ece0-4e4e-a22d-8c75c4fce3be]
[2026-06-03 16:40:01,610: DEBUG/ForkPoolWorker-1] closing channel
[2026-06-03 16:40:01,610: INFO/ForkPoolWorker-1] unacked deadline extension thread [154354] stopped
[2026-06-03 16:40:02,200: ERROR/MainProcess] Process 'ForkPoolWorker-1' pid:154245 exited with 'signal 15 (SIGTERM)'
I0603 16:40:02.202568  154246 fork_posix.cc:71] Other threads are currently calling into gRPC, skipping fork() handlers
I0603 16:40:02.208759  154425 ev_poll_posix.cc:593] FD from fork parent still in poll list: fd(20, generation: 1)
I0603 16:40:02.208894  154425 ev_poll_posix.cc:593] FD from fork parent still in poll list: fd(18, generation: 1)
I0603 16:40:02.208916  154425 ev_poll_posix.cc:593] FD from fork parent still in poll list: fd(16, generation: 1)
I0603 16:40:02.208929  154425 ev_poll_posix.cc:593] FD from fork parent still in poll list: fd(14, generation: 1)
[2026-06-03 16:40:02,211: DEBUG/MainProcess] consuming message from queue: a_qdu_reproducer
[2026-06-03 16:40:02,212: INFO/MainProcess] Task tasks.complex[b60c91a2-0116-4729-bec8-9d89a31b5346] received
Other C lib errors
E0000 00:00:1780499591.762198  164829 ssl_transport_security_utils.cc:114] Corruption detected.
E0000 00:00:1780499591.762252  164829 ssl_transport_security_utils.cc:71] error:100003fc:SSL routines:OPENSSL_internal:SSLV3_ALERT_BAD_RECORD_MAC
E0000 00:00:1780499591.762260  164829 secure_endpoint.cc:243] Decryption error: TSI_DATA_CORRUPTED

When sending a SIGUSR2, we get this stacktrace (worker is stuck in grpc code):

Current thread 0x00007fffe5cff6c0 (most recent call first):
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/grpc/_channel.py", line 2200 in _close_on_fork
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/popen_fork.py", line 70 in _launch
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/popen_fork.py", line 22 in __init__
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/context.py", line 331 in _Popen
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/process.py", line 120 in start
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/pool.py", line 1158 in _create_worker_process
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/pool.py", line 1328 in _repopulate_pool
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/pool.py", line 1343 in _maintain_pool
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/pool.py", line 563 in body
  File "/tmp/gcpubsub/.venv/lib/python3.10/site-packages/billiard/pool.py", line 504 in run
  File "/home/quentin/.local/share/uv/python/cpython-3.10-linux-x86_64-gnu/lib/python3.10/threading.py", line 1016 in _bootstrap_inner
  File "/home/quentin/.local/share/uv/python/cpython-3.10-linux-x86_64-gnu/lib/python3.10/threading.py", line 973 in _bootstrap

It might be tempting to not set the recommended variables, but from our experience processing 60 celery tasks/second, it will take less than a day to hit a similar issue where the worker will hang again. It's just harder to write a reproducer.

Why prefork is no longer safe

fork() copies memory, but not the resources that memory points to.

When the prefork pool spawns a child, the child receives a copy-on-write snapshot of the parent's address space — every Python object, every module global, every data structure. What it does not receive is the operating-system state those objects refer to.

POSIX is explicit that after fork() in a multithreaded process, only the calling thread survives in the child, and the child may safely call almost nothing until it execs.

Consider a trivial example: the parent opens a file/socket and stores its descriptor in a global list. The child inherits the list, and the integer 7 is still sitting there — but in the child, descriptor 7 either does not refer to the same thing, or refers to a connection now shared with (and concurrently used by) the parent. Read from it and you get an error, a corrupted stream, or a hang. The Python object survived the fork; the resource behind it did not.

Stale resource leakage is not theoretical, it is the default for modern dependency stacks, starting with gRPC.

gRPC runs background C threads and explicitly does not support fork(). The crucial point for Celery users: you do not have to use gRPC in your tasks to be affected — gRPC is pulled in by infrastructure you almost certainly run. The gcpubsub broker (via google-cloud-pubsub) is built on gRPC, and the OpenTelemetry OTLP exporter uses gRPC.

The list of widely-used libraries that are unsafe across fork() — because they either hold native state or run background threads — is long and growing: psycopg/psycopg2 (libpq), SQLAlchemy connection pools, pymongo (which now even warns and calls os.register_at_fork), redis/hiredis, OpenSSL (via cryptography/ssl), confluent-kafka (librdkafka), the numeric stack (numpy/scipy/scikit-learn on OpenBLAS/MKL/OpenMP), CUDA-backed torch/tensorflow, and pure-Python-but-threaded SDKs like the OpenTelemetry SDK, Sentry, and Datadog tracers.

The probability that a real-world Celery worker imports none of these is essentially zero.

Asyncio or gevent are not the way out.

The reason the prefork pool exists is not just parallelism; it is the only model in Python that can safely interrupt running code. You cannot safely inject an exception into a thread (the interpreter only delivers it at a bytecode boundary, never inside a blocking C call), and asyncio/gevent cancellation is strictly cooperative: it works only if the task yields, which blocking or CPU-bound or C-level code never does.

The only mechanism that reliably reclaims a task stuck in a recv(), a C extension, or an infinite loop is killing the OS process running it — which is exactly what prefork's hard time_limit does. Interrupting code is a hard requirement for any real timeout guarantee, and process-kill is the only thing that delivers it.

So "just use a different pool" is not an answer for anyone who needs timeouts.

The existing mitigation hooks cannot close the gap.

The usual advice is "(re)create your unsafe resources in worker_process_init." But this only works for resources you can fully reset, and for some libraries (gRPC chief among them) there is no supported way to reset the inherited native state from inside the forked child.

The second piece of advice points the other way: "defer initializing the control process's own dangerous libraries until after it has forked its executors, so the children fork from a still-clean parent". This can protect the initial batch of executors, since the pool is forked early in startup. But it cannot protect replacements, and replacements are the whole point: the hard time_limit kills a stuck executor and forks a fresh one on demand, during steady-state operation. By then the control process has necessarily initialized exactly the libraries you tried to defer — the broker connection (gcpubsub → gRPC) and the telemetry exporter (OpenTelemetry → gRPC) must be live for the worker to receive and observe work at all. So the replacement executor is forked from a parent that is now irreversibly "dirty," and it inherits the corrupted, fork-unsafe state.

We (at consio.ai) have observed exactly this in our production workload: healthy operation right up to the first hard-timeout kill, then a poisoned child is started and hangs forever due to a deadlock in grpc.

The feature you adopt prefork for is the feature that breaks it.

Why this trade-off was fine before, and why it is not anymore

The fork-without-exec trade-off was a reasonable bet in its era.

Forking is fast and memory-cheap: the child inherits the already-imported application and configuration via copy-on-write, so workers start instantly and share read-only pages. For a single-threaded process linked against a conservative libc/malloc and a handful of simple Python libraries, forking "just worked," and the performance and simplicity wins were real.

That bet no longer pays off, because the platform and the ecosystem moved underneath it.

Modern application stacks are full of background threads (telemetry, tracing, async clients) and native extensions that hold locks and own kernel resources, precisely the conditions under which fork-without-exec is unsafe.

Python maintainers have now reached the same verdict. After a four-year discussion in bpo-40379, CPython changed the default multiprocessing start method on POSIX away from fork in Python 3.14. The issue is titled "multiprocessing's default start method of fork()-without-exec() is broken."

The reporter, Itamar Turner-Trauring, opened it by noting that:

[fork-without-exec] can lead to inconsistent state in subprocesses... quite often [as] silent lockups [and that] in real world usage, this results in users getting mysterious hangs they do not have the knowledge to debug [...] I would not say that 'fork' works on Linux either. More like '99% of the time it works, 1% it randomly breaks in mysterious way.

PyPy's Michał Górny concurred from hard experience:

This is a very bad default and what's even worse is that it often causes deadlocks that are hard to reproduce or debug.

The thread contains the exact failure we are describing: ravwojdyla reported that:

gRPC client (which was buried deep into one of our dependencies) can hang in some cases when forked... very tricky to debug

In other words, the rest of the ecosystem has already concluded that fork-without-exec is the wrong default. Celery's prefork pool, by hardcoding fork, is now swimming against the language itself: keeping a default that delivers diminishing speed gains in exchange for rising, hard-to-diagnose corruption.

prefork can be safe again

The fix is to give each child a fresh address space instead of a copy of the parent's.

If the child starts from a clean interpreter, there is no inherited gRPC channel, no frozen lock, no stale
descriptor to corrupt; resources are created fresh and correctly in the child. The way to achieve a clean
address space while still using the fork-based worker model is the spawn start method: fork(), then execv() a brand-new Python interpreter in the child. This preserves everything that makes prefork valuable (a separate, killable OS process per task) while shedding the inherited-state hazard.

This capability already exists in billiard, it is simply not exposed. billiard ships full spawn/forkserver contexts (SpawnContext → popen_spawn_posix), and set_start_method('spawn', force=True) switches the pool over to them. The prefork pool already threads a forking_enable boolean from BasePool through to on_start, and the worker bootstep hardcodes forking_enable=True, so the value was the only thing standing in the way.

AsynPool is not compatible with billiard spawn method

Celery's hub-integrated AsynPool (used with asynchronous transports such as amqp and redis) communicates with workers over file descriptors that are set up in the parent and inherited across fork(); a spawned child cannot reproduce that wiring without a substantial rework.

However, the synchronous transports (including gcpubsub) already run on billiard's plain blocking Pool, which is much closer to the standard-library pool and works naturally with a spawn start method.

So the initial, safe, low-risk scope is: expose the knob, and recommend it for the blocking-pool/synchronous-transport path (the gcpubsub broker docs should explicitly recommend it, since gcpubsub is gRPC-based and therefore one of the most fork-hostile setups in the ecosystem).

Longer term, the truly robust design might be a dedicated "IPC" pool and dropping billiard legacy

Rather than fork-then-exec, the control process and the executor processes would be fully independent from birth (no shared address space at all) communicating over an explicit IPC channel such as a Unix socket. This is the architecture chosen by software with a hard reputation for reliability: Postfix and PostgreSQL both run as cooperating, isolated processes that talk over well-defined channels rather than sharing inherited memory, which is precisely what lets them survive the death of any single worker without corruption. Such a pool would give Celery clean isolation and the hard-kill-for-timeout guarantee by design.

How this PR partially fixes the issue

  • Expose the prefork start method as a Celery setting. billiard allows to configure the pool as either being fork or spawn, but it was hardcoded as fork in Celery. We expose that choice through the new worker_pool_start_method setting (fork/spawn), naming the mechanism rather than a boolean so it leaves room for future methods (e.g. forkserver). It defaults to fork to preserve current behavior (non-breaking, no migration), and is wired through both config and a CLI flag (--pool-start-method).
  • Drive spawn through the API that actually works on Python 3. The pool's on_start now calls
    set_start_method('spawn', force=True) for the spawn case instead of the legacy forking_enable(False)
    execv path. As described above, forking_enable(False) is a no-op on CPython 3 (it depends on a C
    extension billiard never builds there and merely warns), whereas set_start_method('spawn') is pure
    Python and takes effect. The fork case keeps calling forking_enable(True) for backward compatibility reasons.
  • Reject start methods the active pool can't support (e.g. AsynPool + spawn). Spawn is fundamentally
    incompatible with the asynchronous prefork pool (AsynPool). Rather than letting that surface later as
    an obscure hang/crash, the worker validates as early as the pool type is known and refuses to start,
    turning an undiagnosable runtime failure into an upfront, actionable error.
  • Document the setting, when to tune it, and the gcpubsub case it most affects. Document the setting
    itself (default, trade-offs, the AsynPool caveat); add optimization guidance on when to switch
    (threads / fork-unsafe C-extensions like gRPC, psycopg, CUDA) and its costs; and call it out on the
    gcpubsub page, since its gRPC driver isn't fork-safe and is the concrete case that motivates spawn.

Testing the PR on the reproducer

First patch the pyproject.toml as follow:

diff --git a/pyproject.toml b/pyproject.toml
index ba9ca95..b314917 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,3 +10,5 @@ dependencies = [
     "google-cloud-monitoring>=2.30.0",
     "google-cloud-pubsub>=2.38.0",
 ]
+[tool.uv.sources]
+celery = { git = "https://github.com/ConsioAi/celery", branch = "worker-spawn-instead-of-fork" }

Then run uv sync. You may restart the worker and confirm the error is still there.

Now, we'll fix the error by configuring the worker_pool_start_method parameter:

diff --git a/tasks.py b/tasks.py
index b30cfe3..8d3813d 100644
--- a/tasks.py
+++ b/tasks.py
@@ -9,6 +9,7 @@ app = Celery('tasks', broker='gcpubsub://projects/consio-development/')
 app.conf.task_default_queue = 'a_qdu_reproducer'
 app.conf.task_default_exchange = 'a_qdu_reproducer'
 app.conf.task_default_routing_key = 'a_qdu_reproducer'
+app.conf.worker_pool_start_method = 'spawn'

 @app.task
 def dummy():

Restart the worker, schedule some workloads. Now the worker does not hang anymore.


Note: this work was done as part of my job at Consio.ai.

Comment thread docs/userguide/configuration.rst Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a configurable prefork child-process start method to mitigate fork-unsafety in modern dependency stacks (notably gRPC-based transports like GCP Pub/Sub), by allowing prefork workers to start via a fresh-interpreter “spawn” model instead of plain fork().

Changes:

  • Add worker_pool_start_method (fork/spawn, default fork) and expose it via celery worker --pool-start-method.
  • Wire the setting through worker initialization into the prefork pool so the spawn path uses billiard.set_start_method('spawn', force=True) and marks children as “fresh interpreters”.
  • Add validation/docs/tests, including rejecting unsupported combinations (spawn + async-prefork/AsynPool).

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
celery/app/defaults.py Adds the worker_pool_start_method default (fork) to worker settings.
celery/bin/worker.py Adds CLI flag --pool-start-method (fork/spawn).
celery/concurrency/prefork.py Implements spawn behavior in prefork pool startup using set_start_method('spawn', force=True) and sets FORKED_BY_MULTIPROCESSING.
celery/worker/components.py Validates/threads the start-method choice into pool instantiation; rejects spawn with async-prefork.
celery/worker/worker.py Plumbs pool_start_method into worker defaults with validation.
docs/getting-started/backends-and-brokers/gcpubsub.rst Recommends spawn for gRPC-based Pub/Sub transport.
docs/userguide/configuration.rst Documents the new worker_pool_start_method setting and constraints.
docs/userguide/optimizing.rst Adds guidance on when to use spawn and its trade-offs.
t/unit/app/test_defaults.py Tests the new default config value and namespace mapping.
t/unit/concurrency/test_prefork.py Tests prefork startup behavior for both fork and spawn cases.
t/unit/worker/test_components.py Tests pool instantiation and rejection behavior for start-method combinations.
t/unit/worker/test_worker.py Tests worker default, explicit spawn, and invalid start method handling.

Comment thread celery/worker/components.py
@codecov

codecov Bot commented Jun 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.31%. Comparing base (aa4c3b1) to head (6ba05e3).
⚠️ Report is 44 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #10339   +/-   ##
=======================================
  Coverage   88.31%   88.31%           
=======================================
  Files         153      153           
  Lines       19740    19749    +9     
  Branches     2286     2289    +3     
=======================================
+ Hits        17433    17442    +9     
  Misses       2008     2008           
  Partials      299      299           
Flag Coverage Δ
unittests 88.29% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@superboum
superboum requested a review from auvipy June 3, 2026 17:08
@auvipy

auvipy commented Jun 3, 2026

Copy link
Copy Markdown
Member

is there any related issue for this PR?

@auvipy auvipy added this to the 5.7.0 milestone Jun 3, 2026
@superboum

superboum commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

is there any related issue for this PR?

No, we were working on that topic internally until now.
But this issue might be linked: #6036
And this draft PR might be linked: #9810
This PR is also linked: #7612

@superboum

superboum commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Compared to the other proposal, this one is probably the least ambitious one (which I think is something positive in this context).

The next steps (ie. future PR) could be:

  • enable AsynPool with spawn
  • switch default from fork to spawn
  • (optional) hardcode spawn and drop support for worker_pool_start_method

I am not sure creating another pool is the solution. I think prefork is the de-facto pool in Celery, and making it follow Python evolution makes sense to me.

@auvipy

auvipy commented Jun 4, 2026

Copy link
Copy Markdown
Member

yeah this seems better

@auvipy
auvipy requested a review from Nusnus June 14, 2026 09:06
@auvipy
auvipy requested a review from Copilot July 5, 2026 15:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Comment thread celery/worker/components.py
@superboum

Copy link
Copy Markdown
Contributor Author

@auvipy -> I will address your feedback in the coming 7 days :)

@stuaxo

stuaxo commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

(optional) hardcode spawn and drop support for worker_pool_start_method

This seems like a sensible simplification - it seems to be adding complexity otherwise, since it comes with its own commandline option.

@superboum

superboum commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

@stuaxo I agree with your proposition. Based on @auvipy comment, celery/billiard#443 is also linked. I was not aware of celery/billiard#443 but seing it, I would say that we should take the macOS case as a reason to migrate all the OS from fork to spawn.

So, if billiard maintains agree, I would be in favor of closing this PR and replace, in billiard, this:

    if sys.platform == 'darwin':
        # bpo-33725: running arbitrary code after fork() is no longer
        # reliable on macOS since macOS 10.14 (Mojave). Use spawn by
        # default instead.
        # See https://github.com/celery/celery/issues/9894
        _default_context = DefaultContext(_concrete_contexts['spawn'])
    else:
        _default_context = DefaultContext(_concrete_contexts['fork'])

With (configure spawn for all platforms):

    # bpo-33725: running arbitrary code after fork() is no longer
    # reliable on macOS since macOS 10.14 (Mojave). Use spawn by
    # default instead.
    # See https://github.com/celery/celery/issues/9894
    #
    # bpo-40379: CPython changed the default multiprocessing start method on POSIX away from fork in Python 3.14. 
    # The issue is titled "multiprocessing's default start method of fork()-without-exec() is broken."
    # See https://bugs.python.org/issue40379
    _default_context = DefaultContext(_concrete_contexts['spawn'])

What do you think?

@auvipy auvipy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we consider smoke tests for this new feature?

@auvipy

auvipy commented Jul 25, 2026

Copy link
Copy Markdown
Member

with the billiard change, don't we need the changes in celery? or both should be changed

@auvipy auvipy modified the milestones: 5.7.0, 5.8.0 Aug 26, 2026
@auvipy

auvipy commented Sep 8, 2026

Copy link
Copy Markdown
Member

@GangEunzzang can you please thoroughly review this PR and its related billiard issues? it would be of great help

@GangEunzzang

GangEunzzang commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@auvipy I spent some time digging into this and debugging the start-method behavior to see how it works in practice. I tested it on macOS 15 and Linux (python:3.12), using Redis for the AsynPool path and filesystem:// for the blocking pool, against billiard 4.2.4, 4.3.0rc1, and current billiard main.

A few things came out of that:

1. The overall approach works

Exposing the start method and sending spawned children through the existing execv initialization path (FORKED_BY_MULTIPROCESSINGsetup_worker_optimizations) looks sound.

Blocking pool + spawn worked on both Linux and macOS. I tested hard time limits and worker replacement, --max-tasks-per-child 1, -B, and a 100-task run at -c 3. The replacement after a hard time limit was spawned correctly and picked up the next task, and --max-tasks-per-child 1 went through 30 tasks with 30 different child pids.

Unit tests also pass when merged with current main, and flake8 is clean.

2. AsynPool + spawn also works

I don't think the ImproperlyConfigured check for AsynPool is necessary.

I tested this both by disabling the check on this branch and by injecting set_start_method('spawn', force=True) on current main. On Linux (both ways) and on macOS (where billiard 4.3 already defaults to spawn), AsynPool kept working with spawned children.

I tried hard/soft time limits, worker replacement, autoscaling, acks_late + task_reject_on_worker_lost, revoke(terminate=True), pool restart/grow/shrink, chains/groups/chords, ETA/retry, larger payloads, and warm/cold shutdowns. I also ran -c 8 with 1000 tasks, and 200 tasks with --max-tasks-per-child 1 to check for fd or thread growth in the parent after repeated replacements — there was none.

I dug into the fd handling because the concern in the PR description does make sense. The child reports inqW_fd back to the parent and the parent uses that value as the busy_workers key, so this relies on the fd number surviving process creation.

With spawn, spawnv_passfds preserves those fd numbers, so it works. With forkserver they get renumbered, and I can reproduce the breakage there (the second task never runs).

So restricting the option to fork/spawn makes sense, but I don't think spawn itself needs to be rejected for AsynPool. I'd remove that check and add a small spawn smoke test for both pool implementations.

3. -P solo / -P threads

I could reproduce the -P solo / -P threads case that came up in the Copilot review. With an async transport:

celery -A app worker -P solo --pool-start-method spawn

currently exits with:

worker_pool_start_method='spawn' is not supported when the asynchronous prefork pool is used.

The check is based on w.use_eventloop, which is also true for solo/threads with an async transport, rather than on the actual pool class.

If the AsynPool restriction is removed, this goes away with it. Otherwise, I think the check should at least be scoped to issubclass(w.pool_cls, celery.concurrency.prefork.TaskPool).

4. macOS + billiard 4.3 exposed a separate issue

While debugging this I found another case that initially looked related to this PR, but it reproduces without it.

billiard 4.3.0rc1 (celery/billiard#443) defaults to spawn on Darwin. forking_enable(True) is effectively a no-op in billiard (context.py only changes the method for a false value), so with this PR's default worker_pool_start_method='fork', the actual children are still spawned.

The problem is that Celery doesn't know that happened. Since the explicit spawn branch in on_start isn't taken, FORKED_BY_MULTIPROCESSING isn't set, and the spawned child doesn't go through the fresh-interpreter initialization path.

The result is that every task fails in fast_trace_task (RuntimeError: fast_trace_task: worker task registry is empty on main; ValueError: not enough values to unpack (expected 3, got 0) on 5.6.2).

I reproduced the same thing on unmodified current main and released Celery with billiard 4.3.0rc1, so this looks like a pre-existing Celery/billiard integration gap rather than something introduced here.

It also explains the billiard-only approach mentioned earlier: if billiard simply defaults to spawn, Celery still needs some way to detect that the child is running in a fresh interpreter.

I think that detection should use the effective start method (billiard.get_start_method()), rather than the Celery config value. I'll open this separately with a small fix around process_initializer; this PR could then just choose the requested start method, with None meaning billiard's platform default.

5. -B starts Beat before the pool changes the start method

I also noticed that the bootstep order is Beat → Timer → Hub → Pool → …, so TaskPool.on_start() is too late to affect the embedded Beat process.

With --pool-start-method spawn -B, the pool children are spawned, but the Beat child is still a forked copy of the worker.

It works functionally, but it doesn't quite match the "fresh interpreter" behavior described for spawn. It may make more sense to apply the start method earlier in worker setup rather than when the pool starts.

6. Unrelated billiard main regression

While testing against billiard main I also ran into a blocking-pool hang after a hard time limit. This one is unrelated to the PR and reproduces on Linux/fork as well.

I traced it back to bd2f803 / celery/billiard#452 changing the worker's except Exception to except BaseException.

A hard time limit first sends SIGTERM, whose handler raises SystemExit. That is now caught and treated like a task failure, so the worker reports it and goes back to waiting for work. Blocking-pool workers wait inside with self._rlock: recv_bytes(), and when the SIGKILL follows shortly afterward, the process can die while holding that lock. The replacement then blocks forever trying to enter _rlock.

I confirmed the replacement was stuck there with py-spy. It reproduces with fork on Linux; 4.3.0rc1 is fine, current billiard main isn't. AsynPool isn't affected because it doesn't use the shared read lock.

I'll file this separately in billiard as well, with a fix that preserves the #427 behavior but lets the worker exit after reporting SystemExit/KeyboardInterrupt.

7. A few spawn-specific things I ran into

These probably don't need to block the PR, but I think they're worth documenting:

  • Replacement workers only have worker_proc_alive_timeout (4s) to send WORKER_UP, and under spawn that includes importing the application. With a 5s import, I can get replacement workers into a kill/respawn loop. Initial workers don't hit this, so it only becomes visible after the first replacement.
  • The app has to be pickleable. For example, config_from_object(<module object>) or a lambda in settings fails when the pool starts.
  • Tasks defined in __main__ (for example, scripts calling app.worker_main()) aren't registered in the spawned child.
  • Signal handlers connected from worker_init aren't inherited by spawned children. The Django fixup's DjangoWorkerFixup.install() is one example because it connects task pre/post-run DB cleanup there.
  • Spawned Beat doesn't appear to have logging configured (beat: Starting... etc. disappear), although it still sends tasks.
  • Worker replacement is noticeably more expensive: roughly ~0.5s with spawn vs ~50ms with fork in my testing, so small --max-tasks-per-child values have a visible cost.

Overall, the core spawn path looks good to me. The main thing I'd change in this PR is the AsynPool rejection; the macOS/billiard detection issue and the hard-time-limit regression look separate enough that I'd keep them out of this PR.

Happy to help with those separately, and I can also put together the AsynPool spawn smoke test if that would be useful.

A few questions before I open the follow-ups

  1. Where should the spawn detection live? I can do it in Celery (process_initializer checks billiard.get_start_method() and takes the execv branch), or in billiard (spawned children set FORKED_BY_MULTIPROCESSING themselves in spawn.prepare(), which restores the contract the old execv path had and would also cover Windows). Both work in my testing. Do you have a preference?
  2. For the receiving KeyError when requesting inspect().active() #452 regression: report the SystemExit to the caller and then exit the worker (keeps the UnicodeEncodeErrors with celery built-in console logging #427 behaviour), or go back to not catching SystemExit/KeyboardInterrupt at all? I have the first one passing billiard's tests; happy to switch if you'd rather keep it minimal.
  3. For this PR: would you prefer the AsynPool-check removal and the smoke test as a follow-up PR, or should they go into this branch first?

@auvipy

auvipy commented Sep 9, 2026

Copy link
Copy Markdown
Member

thanks a lot @GangEunzzang I think we can move forward with this PR, and welcome your upcoming PR in both billiard and celery. @superboum you can resume the effort... only one suggestion needed to be committed which I can not push

@auvipy

auvipy commented Sep 9, 2026

Copy link
Copy Markdown
Member

@GangEunzzang or you can fork this branch and start a new pr for smoke tests and additional changes, if that is easier for you. keeping the existing commits.... so we can consider it for v5.7

@GangEunzzang

Copy link
Copy Markdown
Contributor

@auvipy Sure, happy to help. I'll start with the #452 regression since that's independent, then work on the spawn-detection issue on the Celery/billiard side.

@superboum Would you be okay with me picking this up and continuing the work on this PR? I can add the AsynPool changes and smoke tests on top of the existing work.

@auvipy

auvipy commented Sep 9, 2026

Copy link
Copy Markdown
Member

It is better I merge this PR and you continue in a separate PR with the needed remaining works

@auvipy auvipy modified the milestones: 5.8.0, 5.7.0 Sep 9, 2026
@auvipy
auvipy merged commit 683a911 into celery:main Sep 9, 2026
338 checks passed
@superboum

Copy link
Copy Markdown
Contributor Author

You were faster than me. Thanks a lot for carrying the remaining work.

@auvipy

auvipy commented Sep 9, 2026

Copy link
Copy Markdown
Member

You were faster than me. Thanks a lot for carrying the remaining work.

thanks for the initial minimal change PR! would you mind referencing the existing open issues related to this?

auvipy added a commit that referenced this pull request Sep 9, 2026
…pool (#10584)

* Allow worker_pool_start_method='spawn' with the asynchronous prefork pool

The check added in #10339 rejected 'spawn' whenever the worker uses an
event loop, assuming AsynPool could not work with spawned children. It
does: the fd numbers the children report back to the parent survive
spawnv_passfds, and the pool behaves the same as with fork, including
replacement after a hard time limit. The check was also keyed off
use_eventloop rather than the pool class, so it rejected -P solo and
-P threads with an async transport as well.

Remove it, update the CLI help and docs, and add smoke tests that run
the worker with spawn against the smoke brokers.

* Use a per-call time limit in the spawn smoke test

* Add versionadded to the pool start method section of the optimizing guide

---------

Co-authored-by: Asif Saif Uddin {"Auvi":"অভি"} <auvipy@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants