Format: text/x-python
⬇ Download File (9.4 KB)
   1  from __future__ import annotations
   2  
   3  import os
   4  import pickle
   5  import subprocess
   6  import sys
   7  from collections import deque
   8  from collections.abc import Callable
   9  from importlib.util import module_from_spec, spec_from_file_location
  10  from typing import TypeVar, cast
  11  
  12  from ._core._eventloop import current_time, get_async_backend, get_cancelled_exc_class
  13  from ._core._exceptions import BrokenWorkerProcess
  14  from ._core._subprocesses import open_process
  15  from ._core._synchronization import CapacityLimiter
  16  from ._core._tasks import CancelScope, fail_after
  17  from .abc import ByteReceiveStream, ByteSendStream, Process
  18  from .lowlevel import RunVar, checkpoint_if_cancelled
  19  from .streams.buffered import BufferedByteReceiveStream
  20  
  21  if sys.version_info >= (3, 11):
  22      from typing import TypeVarTuple, Unpack
  23  else:
  24      from typing_extensions import TypeVarTuple, Unpack
  25  
  26  WORKER_MAX_IDLE_TIME = 300  # 5 minutes
  27  
  28  T_Retval = TypeVar("T_Retval")
  29  PosArgsT = TypeVarTuple("PosArgsT")
  30  
  31  _process_pool_workers: RunVar[set[Process]] = RunVar("_process_pool_workers")
  32  _process_pool_idle_workers: RunVar[deque[tuple[Process, float]]] = RunVar(
  33      "_process_pool_idle_workers"
  34  )
  35  _default_process_limiter: RunVar[CapacityLimiter] = RunVar("_default_process_limiter")
  36  
  37  
  38  async def run_sync(  # type: ignore[return]
  39      func: Callable[[Unpack[PosArgsT]], T_Retval],
  40      *args: Unpack[PosArgsT],
  41      cancellable: bool = False,
  42      limiter: CapacityLimiter | None = None,
  43  ) -> T_Retval:
  44      """
  45      Call the given function with the given arguments in a worker process.
  46  
  47      If the ``cancellable`` option is enabled and the task waiting for its completion is
  48      cancelled, the worker process running it will be abruptly terminated using SIGKILL
  49      (or ``terminateProcess()`` on Windows).
  50  
  51      :param func: a callable
  52      :param args: positional arguments for the callable
  53      :param cancellable: ``True`` to allow cancellation of the operation while it's
  54          running
  55      :param limiter: capacity limiter to use to limit the total amount of processes
  56          running (if omitted, the default limiter is used)
  57      :return: an awaitable that yields the return value of the function.
  58  
  59      """
  60  
  61      async def send_raw_command(pickled_cmd: bytes) -> object:
  62          try:
  63              await stdin.send(pickled_cmd)
  64              response = await buffered.receive_until(b"\n", 50)
  65              status, length = response.split(b" ")
  66              if status not in (b"RETURN", b"EXCEPTION"):
  67                  raise RuntimeError(
  68                      f"Worker process returned unexpected response: {response!r}"
  69                  )
  70  
  71              pickled_response = await buffered.receive_exactly(int(length))
  72          except BaseException as exc:
  73              workers.discard(process)
  74              try:
  75                  process.kill()
  76                  with CancelScope(shield=True):
  77                      await process.aclose()
  78              except ProcessLookupError:
  79                  pass
  80  
  81              if isinstance(exc, get_cancelled_exc_class()):
  82                  raise
  83              else:
  84                  raise BrokenWorkerProcess from exc
  85  
  86          retval = pickle.loads(pickled_response)
  87          if status == b"EXCEPTION":
  88              assert isinstance(retval, BaseException)
  89              raise retval
  90          else:
  91              return retval
  92  
  93      # First pickle the request before trying to reserve a worker process
  94      await checkpoint_if_cancelled()
  95      request = pickle.dumps(("run", func, args), protocol=pickle.HIGHEST_PROTOCOL)
  96  
  97      # If this is the first run in this event loop thread, set up the necessary variables
  98      try:
  99          workers = _process_pool_workers.get()
 100          idle_workers = _process_pool_idle_workers.get()
 101      except LookupError:
 102          workers = set()
 103          idle_workers = deque()
 104          _process_pool_workers.set(workers)
 105          _process_pool_idle_workers.set(idle_workers)
 106          get_async_backend().setup_process_pool_exit_at_shutdown(workers)
 107  
 108      async with limiter or current_default_process_limiter():
 109          # Pop processes from the pool (starting from the most recently used) until we
 110          # find one that hasn't exited yet
 111          process: Process
 112          while idle_workers:
 113              process, idle_since = idle_workers.pop()
 114              if process.returncode is None:
 115                  stdin = cast(ByteSendStream, process.stdin)
 116                  buffered = BufferedByteReceiveStream(
 117                      cast(ByteReceiveStream, process.stdout)
 118                  )
 119  
 120                  # Prune any other workers that have been idle for WORKER_MAX_IDLE_TIME
 121                  # seconds or longer
 122                  now = current_time()
 123                  killed_processes: list[Process] = []
 124                  while idle_workers:
 125                      if now - idle_workers[0][1] < WORKER_MAX_IDLE_TIME:
 126                          break
 127  
 128                      process_to_kill, idle_since = idle_workers.popleft()
 129                      process_to_kill.kill()
 130                      workers.remove(process_to_kill)
 131                      killed_processes.append(process_to_kill)
 132  
 133                  with CancelScope(shield=True):
 134                      for killed_process in killed_processes:
 135                          await killed_process.aclose()
 136  
 137                  break
 138  
 139              workers.remove(process)
 140          else:
 141              command = [sys.executable, "-u", "-m", __name__]
 142              process = await open_process(
 143                  command, stdin=subprocess.PIPE, stdout=subprocess.PIPE
 144              )
 145              try:
 146                  stdin = cast(ByteSendStream, process.stdin)
 147                  buffered = BufferedByteReceiveStream(
 148                      cast(ByteReceiveStream, process.stdout)
 149                  )
 150                  with fail_after(20):
 151                      message = await buffered.receive(6)
 152  
 153                  if message != b"READY\n":
 154                      raise BrokenWorkerProcess(
 155                          f"Worker process returned unexpected response: {message!r}"
 156                      )
 157  
 158                  main_module_path = getattr(sys.modules["__main__"], "__file__", None)
 159                  pickled = pickle.dumps(
 160                      ("init", sys.path, main_module_path),
 161                      protocol=pickle.HIGHEST_PROTOCOL,
 162                  )
 163                  await send_raw_command(pickled)
 164              except (BrokenWorkerProcess, get_cancelled_exc_class()):
 165                  raise
 166              except BaseException as exc:
 167                  process.kill()
 168                  raise BrokenWorkerProcess(
 169                      "Error during worker process initialization"
 170                  ) from exc
 171  
 172              workers.add(process)
 173  
 174          with CancelScope(shield=not cancellable):
 175              try:
 176                  return cast(T_Retval, await send_raw_command(request))
 177              finally:
 178                  if process in workers:
 179                      idle_workers.append((process, current_time()))
 180  
 181  
 182  def current_default_process_limiter() -> CapacityLimiter:
 183      """
 184      Return the capacity limiter that is used by default to limit the number of worker
 185      processes.
 186  
 187      :return: a capacity limiter object
 188  
 189      """
 190      try:
 191          return _default_process_limiter.get()
 192      except LookupError:
 193          limiter = CapacityLimiter(os.cpu_count() or 2)
 194          _default_process_limiter.set(limiter)
 195          return limiter
 196  
 197  
 198  def process_worker() -> None:
 199      # Redirect standard streams to os.devnull so that user code won't interfere with the
 200      # parent-worker communication
 201      stdin = sys.stdin
 202      stdout = sys.stdout
 203      sys.stdin = open(os.devnull)
 204      sys.stdout = open(os.devnull, "w")
 205  
 206      stdout.buffer.write(b"READY\n")
 207      while True:
 208          retval = exception = None
 209          try:
 210              command, *args = pickle.load(stdin.buffer)
 211          except EOFError:
 212              return
 213          except BaseException as exc:
 214              exception = exc
 215          else:
 216              if command == "run":
 217                  func, args = args
 218                  try:
 219                      retval = func(*args)
 220                  except BaseException as exc:
 221                      exception = exc
 222              elif command == "init":
 223                  main_module_path: str | None
 224                  sys.path, main_module_path = args
 225                  del sys.modules["__main__"]
 226                  if main_module_path and os.path.isfile(main_module_path):
 227                      # Load the parent's main module but as __mp_main__ instead of
 228                      # __main__ (like multiprocessing does) to avoid infinite recursion
 229                      try:
 230                          spec = spec_from_file_location("__mp_main__", main_module_path)
 231                          if spec and spec.loader:
 232                              main = module_from_spec(spec)
 233                              spec.loader.exec_module(main)
 234                              sys.modules["__main__"] = main
 235                      except BaseException as exc:
 236                          exception = exc
 237          try:
 238              if exception is not None:
 239                  status = b"EXCEPTION"
 240                  pickled = pickle.dumps(exception, pickle.HIGHEST_PROTOCOL)
 241              else:
 242                  status = b"RETURN"
 243                  pickled = pickle.dumps(retval, pickle.HIGHEST_PROTOCOL)
 244          except BaseException as exc:
 245              exception = exc
 246              status = b"EXCEPTION"
 247              pickled = pickle.dumps(exc, pickle.HIGHEST_PROTOCOL)
 248  
 249          stdout.buffer.write(b"%s %d\n" % (status, len(pickled)))
 250          stdout.buffer.write(pickled)
 251  
 252          # Respect SIGTERM
 253          if isinstance(exception, SystemExit):
 254              raise exception
 255  
 256  
 257  if __name__ == "__main__":
 258      process_worker()
 259