-
Notifications
You must be signed in to change notification settings - Fork 1.8k
perf: fork the dev backend reload worker instead of using a forkserver #7113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Production backend workers on Linux are now forked from a supervisor that has already imported the app, so framework and app code are shared copy-on-write instead of re-imported per worker. A 4-worker blank app drops from about 720 MB to about 220 MB of proportional set size and starts faster. Set `REFLEX_BACKEND_START_METHOD=spawn` for apps that are not fork-safe. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| The `reflex run` backend reload worker is now forked from the supervisor on Linux instead of started through a forkserver, so framework code is shared copy-on-write and the extra forkserver processes are gone. Set `REFLEX_BACKEND_START_METHOD=spawn` or `REFLEX_STRICT_HOT_RELOAD=1` for apps that are not fork-safe. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add the `REFLEX_BACKEND_START_METHOD` environment variable to choose how production backend workers are started (`fork`, `spawn`, or `forkserver`). |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -660,18 +660,22 @@ def run_granian_backend(host: str, port: int, loglevel: LogLevel): | |||||||||||||||||||||||||||||||||||||||||
| port: The app port | ||||||||||||||||||||||||||||||||||||||||||
| loglevel: The log level. | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| logger.debug("Using Granian for backend") | ||||||||||||||||||||||||||||||||||||||||||
| import multiprocessing | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if environment.REFLEX_STRICT_HOT_RELOAD.get(): | ||||||||||||||||||||||||||||||||||||||||||
| import multiprocessing | ||||||||||||||||||||||||||||||||||||||||||
| logger.debug("Using Granian for backend") | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| multiprocessing.set_start_method("spawn", force=True) | ||||||||||||||||||||||||||||||||||||||||||
| set_dev_start_method() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| from granian.constants import Interfaces | ||||||||||||||||||||||||||||||||||||||||||
| from granian.log import LogLevels | ||||||||||||||||||||||||||||||||||||||||||
| from granian.server import Server as Granian | ||||||||||||||||||||||||||||||||||||||||||
| from reflex_base.environment import _load_dotenv_from_env | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # The app itself is not imported here: the reload worker must load it | ||||||||||||||||||||||||||||||||||||||||||
| # fresh on every restart. Only the framework pages are shared. | ||||||||||||||||||||||||||||||||||||||||||
| if multiprocessing.get_start_method() == "fork": | ||||||||||||||||||||||||||||||||||||||||||
| _freeze_for_fork() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| reset_dev_backend_reload_marker() | ||||||||||||||||||||||||||||||||||||||||||
| environment.REFLEX_DEV_BACKEND_RELOAD_ACTIVE.set(True) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -785,6 +789,66 @@ def run_uvicorn_backend_prod( | |||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def _backend_start_method() -> str | None: | ||||||||||||||||||||||||||||||||||||||||||
| """Resolve the multiprocessing start method for backend workers. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||||||
| The start method to force, or None to keep the interpreter default. | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| if (method := environment.REFLEX_BACKEND_START_METHOD.get()) is not None: | ||||||||||||||||||||||||||||||||||||||||||
| return method | ||||||||||||||||||||||||||||||||||||||||||
| import multiprocessing | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Python defaults to fork (<3.14) or forkserver (3.14+) on Linux and to | ||||||||||||||||||||||||||||||||||||||||||
| # spawn elsewhere; only where fork is already the platform norm do we rely | ||||||||||||||||||||||||||||||||||||||||||
| # on it so workers can share the supervisor's pages. | ||||||||||||||||||||||||||||||||||||||||||
| if multiprocessing.get_start_method() in ("fork", "forkserver"): | ||||||||||||||||||||||||||||||||||||||||||
| return "fork" | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+805
to
+806
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Linux with Python 3.14 and default telemetry enabled, this converts the safer Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def set_dev_start_method() -> None: | ||||||||||||||||||||||||||||||||||||||||||
| """Fix the multiprocessing start method for the development backend. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Strict hot reload spawns workers; otherwise the platform rule of | ||||||||||||||||||||||||||||||||||||||||||
| ``_backend_start_method`` applies. Call this before the first child | ||||||||||||||||||||||||||||||||||||||||||
| process starts, or a forkserver started for the compile pool stays alive | ||||||||||||||||||||||||||||||||||||||||||
| for the whole session. | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| import multiprocessing | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if environment.REFLEX_STRICT_HOT_RELOAD.get(): | ||||||||||||||||||||||||||||||||||||||||||
| multiprocessing.set_start_method("spawn", force=True) | ||||||||||||||||||||||||||||||||||||||||||
| elif (start_method := _backend_start_method()) is not None: | ||||||||||||||||||||||||||||||||||||||||||
| multiprocessing.set_start_method(start_method, force=True) | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+820
to
+823
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When both variables are set,
Suggested change
Comment on lines
+820
to
+823
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def _freeze_for_fork() -> None: | ||||||||||||||||||||||||||||||||||||||||||
| """Freeze the heap so forked workers keep the supervisor's pages shared. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Without this, worker GC passes write to the inherited objects' headers, | ||||||||||||||||||||||||||||||||||||||||||
| which copies the shared pages private again. | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| import gc | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| gc.collect() | ||||||||||||||||||||||||||||||||||||||||||
| gc.freeze() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def _preload_for_fork(app_target: str | None) -> None: | ||||||||||||||||||||||||||||||||||||||||||
| """Import the app in the supervisor so forked workers share its pages. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||
| app_target: The ASGI app target; None means the reflex app, which is | ||||||||||||||||||||||||||||||||||||||||||
| imported here. Any other target lives in an already-loaded module. | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| from reflex.utils import prerequisites | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if app_target is None: | ||||||||||||||||||||||||||||||||||||||||||
| prerequisites.get_app() | ||||||||||||||||||||||||||||||||||||||||||
| _freeze_for_fork() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def run_granian_backend_prod( | ||||||||||||||||||||||||||||||||||||||||||
| host: str, port: int, loglevel: LogLevel, app_target: str | None = None | ||||||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -796,12 +860,19 @@ def run_granian_backend_prod( | |||||||||||||||||||||||||||||||||||||||||
| loglevel: The log level. | ||||||||||||||||||||||||||||||||||||||||||
| app_target: The ASGI app target to run. Defaults to the reflex app instance. | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| import multiprocessing | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| from granian.constants import Interfaces | ||||||||||||||||||||||||||||||||||||||||||
| from granian.log import LogLevels | ||||||||||||||||||||||||||||||||||||||||||
| from granian.server import Server as Granian | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| logger.debug("Using Granian for backend") | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (start_method := _backend_start_method()) is not None: | ||||||||||||||||||||||||||||||||||||||||||
| multiprocessing.set_start_method(start_method, force=True) | ||||||||||||||||||||||||||||||||||||||||||
| if start_method == "fork": | ||||||||||||||||||||||||||||||||||||||||||
| _preload_for_fork(app_target) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| granian_app = Granian( | ||||||||||||||||||||||||||||||||||||||||||
| target=app_target or get_app_instance_from_file(), | ||||||||||||||||||||||||||||||||||||||||||
| factory=True, | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -496,6 +496,16 @@ def _get_telemetry_executor() -> ThreadPoolExecutor: | |||||||||||
| return _executor | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def _reset_executor_after_fork() -> None: | ||||||||||||
| """Drop the inherited executor; its worker thread does not exist in the child.""" | ||||||||||||
| global _executor | ||||||||||||
| _executor = None | ||||||||||||
|
Comment on lines
+501
to
+502
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a first telemetry submission races with Granian's fork, the child can inherit Prompt for AI agents
Suggested change
|
||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| if hasattr(os, "register_at_fork"): | ||||||||||||
| os.register_at_fork(after_in_child=_reset_executor_after_fork) | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def _current_registration_context() -> RegistrationContext | None: | ||||||||||||
| """Return the caller's RegistrationContext, or None if none is attached. | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -801,3 +801,14 @@ def test_flush_returns_false_when_worker_does_not_drain_in_time(): | |
| finally: | ||
| release.set() | ||
| blocker.result(timeout=5) | ||
|
|
||
|
|
||
| def test_executor_is_recreated_after_fork(): | ||
| """A forked child drops the inherited pool, whose thread it does not own.""" | ||
| inherited = telemetry._get_telemetry_executor() | ||
|
|
||
| telemetry._reset_executor_after_fork() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This test never forks, so it only proves Prompt for AI agents |
||
|
|
||
| fresh = telemetry._get_telemetry_executor() | ||
| assert fresh is not inherited | ||
| assert fresh.submit(lambda: 1).result(timeout=5) == 1 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: On Python 3.14 Linux with telemetry enabled, forcing
forkmakes Granian fork a supervisor that already has the telemetry worker thread, so inherited locks can deadlock the backend. Retainforkserverwhen it is the interpreter default, or drain telemetry before forking.Prompt for AI agents