perf: preload the app and fork production granian workers

Merged
FA
4d ago
+440-15

Summary

  • On Linux, run_granian_backend_prod now forces the fork start method, imports the app in the supervisor, and runs gc.collect(); gc.freeze() before granian starts workers. Workers share the framework and app pages copy-on-write instead of each re-importing them.
  • REFLEX_BACKEND_START_METHOD (fork / spawn / forkserver) overrides the choice for apps that are not fork-safe. Unset keeps the interpreter default on macOS and Windows (spawn), so nothing changes there.
  • Granian no longer forks its workers while a telemetry thread exists. reflex run --env prod sends its run-prod event synchronously on the calling thread (telemetry._send) before the server starts, so it never touches the thread pool. Right before forking, the supervisor drains whatever is still queued (the compile event, queued before the frontend build) and joins the pool's thread (telemetry._shutdown_executor()). A send that stalls past the 2 s drain, e.g. in a DNS lookup, leaves a live thread, and the supervisor then starts its workers with spawn instead of forking. Nothing in the supervisor sends telemetry after that point, so a SIGHUP respawn forks from a thread-free process too.
  • As the dev fork path does since #7049, the supervisor runs _prepare_serializers_for_fork() before forking.
  • A forked child inherits the telemetry ThreadPoolExecutor but not its worker thread, so any event submitted from a worker would queue forever. An os.register_at_fork hook drops the inherited pool in the child and recreates its creation lock, which a thread creating the pool at fork time would otherwise leave held. This also affected dev mode on Python <= 3.13, where fork was already the Linux default.

Measurement

One-page app (a counter rx.State), GRANIAN_WORKERS=4, reflex run --env prod --backend-only, telemetry off, Python 3.14, granian 2.8.1. PSS summed over the whole process tree from /proc/<pid>/smaps_rollup, after 32 /ping requests, median of 3 runs, measured against current main (which includes #7049).

PSSprivate dirtyfirst /ping OK after
main335 MB302 MB1.01 s
this PR143 MB65 MB0.66 s

Per worker: 66 MB PSS → 27 MB PSS. The supervisor goes from 48 MB to 34 MB PSS, and the tree drops the multiprocessing forkserver (16 MB) and resource tracker (7 MB) that main starts under Python 3.14's default forkserver method.

Functional check: 8 socket.io sessions against the forked workers, each hydrating and sending two increment events, all receiving count 1 then 2. A SIGHUP reload respawned all four workers and /ping answered 32/32 before and after it.

Notes for review

  • The synchronous run-prod send is bounded by urlopen's 5 s timeout, the same as every other CLI telemetry event before the thread pool existed.
  • Granian starts a watcher thread per worker, so workers 2..N are forked while those threads exist. They wait in waitpid and hold no locks.
  • The uvicorn/gunicorn prod path runs its server in a subprocess, so nothing there forks a reflex process.

https://claude.ai/code/session_01GbqrTagh776xkQvsopdytF