to_process.py 9.6 KB

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