Skip to content
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

Create ProcessManager #1205

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tests/supervisors/test_multiprocess.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import signal

from uvicorn import Config
from uvicorn.supervisors import Multiprocess
from uvicorn.supervisors.multiprocess import Multiprocess


def run(sockets):
Expand Down
8 changes: 4 additions & 4 deletions uvicorn/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def __init__(self, config: Config) -> None:
self.server_state = ServerState()

self.started = False
self.terminate_called = 0
self.should_exit = False
self.force_exit = False
self.last_notified = 0.0
Expand Down Expand Up @@ -309,8 +310,7 @@ def install_signal_handlers(self) -> None:
signal.signal(sig, self.handle_exit)

def handle_exit(self, sig: signal.Signals, frame: FrameType) -> None:

if self.should_exit:
self.terminate_called += 1
self.should_exit = True
if self.terminate_called > 2:
self.force_exit = True
else:
self.should_exit = True
7 changes: 6 additions & 1 deletion uvicorn/supervisors/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys
import typing

from uvicorn.supervisors.basereload import BaseReload
from uvicorn.supervisors.multiprocess import Multiprocess

if typing.TYPE_CHECKING:
ChangeReload: typing.Type[BaseReload] # pragma: no cover
Expand All @@ -11,4 +11,9 @@
except ImportError: # pragma: no cover
from uvicorn.supervisors.statreload import StatReload as ChangeReload

if sys.platform == "win32":
from uvicorn.supervisors.multiprocess import Multiprocess
Copy link
Member

Choose a reason for hiding this comment

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

If there are plans to support Windows, I would be happy to help. (I read the code carefully, it is not difficult to support Windows)

Copy link
Member Author

Choose a reason for hiding this comment

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

My problem is with the signals not supported by Windows, what do you expect to do on that part? I just added the minimal signal handlers, but further work should not work.

Copy link
Member

@abersheeran abersheeran Oct 20, 2021

Choose a reason for hiding this comment

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

We can use process.terminate() replace SIGINT. Other signals are transmitted as they are.

Copy link
Member

Choose a reason for hiding this comment

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

In fact, a large number of other signals cannot be triggered on Windows. We can give Windows users a near-identical development experience as long as we can handle the signals raised by Ctrl-C in a unified manner.

Copy link
Member Author

@Kludex Kludex Oct 20, 2021

Choose a reason for hiding this comment

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

I was not referring to SIGINT, SIGINT and SIGTERM are not an issue, and I don't see any issue of calling process.terminate() on SIGINT. I mean, I don't think there's an issue on that part for windows users.

The issue right now is the init_signals() method, that adds a handler for signals that Windows doesn't have. If we just add a conditional to create SIGNALS attribute according to the OS, then it will work. The issue is with the SIGCHLD, that Windows doesn't support.

Copy link
Member

@abersheeran abersheeran Oct 20, 2021

Choose a reason for hiding this comment

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

The issue right now is the init_signals() method, that adds a handler for signals that Windows doesn't have. If we just add a conditional to create SIGNALS attribute according to the OS, then it will work. The issue is with the SIGCHLD, that Windows doesn't support.

https://github.com/Kludex/uvicorn/blob/a4377a14e3360fa487c506b49e8c5537fb0057a1/uvicorn/supervisors/manager.py#L71-L74

Maybe don't need SIGCHLD in Windows? The delay of 0.25 is not high.

Copy link
Member

Choose a reason for hiding this comment

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

When you say "development experience" I imagine the user wanting to use the reload feature, which should not be recommended here? 🤔

I mean, if the user wants to use the class implemented here in production, I imagine that it also wants to use the reload feature in development.

Yes, I agree. It is a good idea to incorporate the reload here.

Copy link
Member

Choose a reason for hiding this comment

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

I was not referring to SIGINT, SIGINT and SIGTERM are not an issue, and I don't see any issue of calling process.terminate() on SIGINT. I mean, I don't think there's an issue on that part for windows users.

#684 Sometime, os.kill() maybe got an error.

Copy link
Member Author

Choose a reason for hiding this comment

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

https://github.com/Kludex/uvicorn/blob/a4377a14e3360fa487c506b49e8c5537fb0057a1/uvicorn/supervisors/manager.py#L71-L74

Maybe don't need SIGCHLD in Windows? The delay of 0.25 is not high.

I see! You mean that it's redundant to have the handler as we already perform the same logic in case we don't handle the SIGCHLD.

I need to recheck if there's further logic to be implemented on the SIGCHLD handler that I didn't add because it's not needed on this initial step. If I can't find anything, then yes, we can remove it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I agree. It is a good idea to incorporate the reload here.

I was not defending the idea of adding the reload here, as I think it's a posterior discussion. But I do agree with that, if we do that, we're going to have only this ProcessManager class, and both the Reload classes and the Multiprocess class should be removed.

else:
from uvicorn.supervisors.manager import ProcessManager as Multiprocess

__all__ = ["Multiprocess", "ChangeReload"]
157 changes: 157 additions & 0 deletions uvicorn/supervisors/manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import enum
import logging
import multiprocessing as mp
import os
import queue
import signal
import sys
import time
from multiprocessing.context import SpawnProcess
from socket import socket
from types import FrameType
from typing import Callable, List, Optional

from uvicorn.config import Config
from uvicorn.subprocess import get_subprocess

if sys.version_info >= (3, 8):
from typing import Protocol
else:
from typing_extensions import Protocol

logger = logging.getLogger("uvicorn.error")


class Target(Protocol):
def __call__(self, sockets: Optional[List[socket]] = None) -> None:
...


class ExitCode(enum.IntEnum):
OK = 0
DEFAULT_FAILURE = 1
STARTUP_FAILURE = 3


class ProcessManager:
# Complete list of signals can be found on:
# http://manpages.ubuntu.com/manpages/bionic/man7/signal.7.html
SIGNALS = {
getattr(signal, f"SIG{sig.upper()}"): sig
for sig in (
"abrt", # Abort signal from abort(3)
"hup", # Hangup signal generated by terminal close.
"quit", # Quit signal generated by terminal close.
"int", # Interrupt signal generated by Ctrl+C
"term", # Termination signal
"winch", # Window size change signal
"chld", # Child process terminated, stopped, or continued
)
}

# TODO(Marcelo): This should be converted into a CLI option.
GRACEFUL_TIMEOUT = 30

def __init__(self, config: Config, target: Target, sockets: List[socket]) -> None:
self.config = config
self.target = target
self.sockets = sockets
self.processes: List[SpawnProcess] = []
self.sig_queue = mp.Queue()

def run(self) -> None:
self.start()

try:
self.spawn_processes()

while True:
try:
sig = self.sig_queue.get(timeout=0.25)
except queue.Empty:
self.reap_processes()
self.spawn_processes()
continue

if sig not in self.SIGNALS.keys():
logger.info("Ignoring unknown signal: %d", sig)
continue

handler = self.signal_handler(sig)
if handler is None:
logger.info("Unhandled signal: %s", self.SIGNALS.get(sig))
continue

handler()
except StopIteration:
self.halt()
except Exception:
logger.info("Unhandled exception in main loop", exc_info=True)
self.stop(signal.SIGTERM)
self.halt(ExitCode.DEFAULT_FAILURE)

def start(self) -> None:
self.pid = os.getpid()
logger.info("Started manager process [%d]", self.pid)
self.init_signals()

def spawn_processes(self) -> None:
for _ in range(self.config.workers - len(self.processes)):
self.spawn_process()

def spawn_process(self) -> None:
process = get_subprocess(self.config, target=self.target, sockets=self.sockets)
process.start()
self.processes.append(process)

def init_signals(self) -> None:
for s in self.SIGNALS.keys():
signal.signal(s, self._signal)
signal.signal(signal.SIGCHLD, self.handle_chld)

def _signal(self, sig: signal.Signals, frame: FrameType) -> None:
self.sig_queue.put(sig)

def handle_int(self) -> None:
self.stop(signal.SIGINT)
raise StopIteration

def handle_term(self) -> None:
self.stop(signal.SIGTERM)
raise StopIteration

def handle_chld(self, sig: signal.Signals, frame: FrameType) -> None:
self.reap_processes()

def signal_handler(self, sig: signal.Signals) -> Optional[Callable[..., None]]:
sig_name = self.SIGNALS.get(sig)
return getattr(self, f"handle_{sig_name}", None)

def reap_processes(self) -> None:
for process in self.processes:
if not process.is_alive():
Copy link
Member Author

Choose a reason for hiding this comment

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

I need to further check if the wait() here is enough to reap! It worked on the cases I've tested, but I'm not sure if there are edge cases!

self.processes.remove(process)

def halt(self, exit_code: int = ExitCode.OK) -> None:
logger.info("Stopping parent process [%d]", self.pid)
sys.exit(exit_code)

def kill_processes(self, sig: signal.Signals) -> None:
for process in self.processes:
self.kill_process(process, sig)

def kill_process(self, process: SpawnProcess, sig: signal.Signals) -> None:
os.kill(process.pid, sig)

def wait_timeout(self) -> None:
limit = time.time() + self.GRACEFUL_TIMEOUT
while self.processes and time.time() < limit:
time.sleep(0.1)

def stop(self, sig: signal.Signals) -> None:
self.kill_processes(sig)
self.wait_timeout()
self.kill_processes(signal.SIGKILL)

for sock in self.sockets:
sock.close()
8 changes: 8 additions & 0 deletions uvicorn/workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import signal
import sys
import warnings
from typing import Any

from gunicorn.arbiter import Arbiter
Expand All @@ -10,6 +11,13 @@
from uvicorn.config import Config
from uvicorn.main import Server

warnings.warn(
"'workers' module is deprecated since uvicorn 0.16.0, "
"and it will be removed in a future release. "
"See https://github.com/encode/uvicorn/pull/1205.",
DeprecationWarning,
)


class UvicornWorker(Worker):
"""
Expand Down