You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

230 lines
8.8 KiB

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