Skip to content

Commit 48ddda1

Browse files
gpsheadfedonman
andauthored
gh-155717: Determine multiprocessing's default start method lazily (GH-159254)
gh-155827 made `import multiprocessing` check whether the temporary directory is writeable in order to choose between "forkserver" and "spawn" as the default start method. That check ran at import time and pulled in `multiprocessing.util`, `subprocess`, `tempfile`, `shutil` and `random`, noticeably slowing down the import for every program, including ones that never start a process and every spawned child, the fork server and the resource tracker. The default start method is now determined the first time it is needed instead: on the first `get_context()`, `get_start_method()`, `get_all_start_methods()` or default `Process` start. `multiprocessing.util` goes back to importing `tempfile` only where it is used. Also include the fix from gh-158410 by Vyron Vasileiadis, along with a test adapted from that PR: when `tempfile` finds no usable temporary directory at all, the check now reports that instead of raising `FileNotFoundError`, so that "spawn" is chosen. The regression was reported, and the lazy approach suggested, by Leandro Damascena in #155717 (comment) Co-authored-by: Vyron Vasileiadis <hi@fedonman.com>
1 parent 0768eed commit 48ddda1

4 files changed

Lines changed: 110 additions & 22 deletions

File tree

‎Doc/library/multiprocessing.rst‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,21 @@ to start a process. These *start methods* are
160160
No unnecessary resources are inherited.
161161

162162
Available on POSIX platforms which support passing file descriptors over
163-
Unix pipes such as Linux. The default on those.
163+
Unix pipes such as Linux. The default on those, unless there is no
164+
writable temporary directory (see :func:`tempfile.gettempdir`), which
165+
the fork server requires; *spawn* is the default in that case.
164166

165167
.. versionchanged:: 3.14
166168
This became the default start method on POSIX platforms.
167169

170+
.. versionchanged:: next
171+
*spawn* is now the default start method when there is no writable
172+
temporary directory, for example on a read-only file system.
173+
Previously *forkserver* remained the default there and starting a
174+
process failed with an :exc:`OSError`. The default is determined
175+
when it is first needed rather than when :mod:`!multiprocessing`
176+
is imported. See :gh:`155717`.
177+
168178
.. versionchanged:: 3.4
169179
*spawn* added on all POSIX platforms, and *forkserver* added for
170180
some POSIX platforms.

‎Lib/multiprocessing/context.py‎

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import functools
12
import os
23
import sys
34
import threading
45

56
from . import process
67
from . import reduction
7-
from . import util
88

99
__all__ = ()
1010

@@ -240,10 +240,19 @@ def _after_fork():
240240
class DefaultContext(BaseContext):
241241
Process = Process
242242

243-
def __init__(self, context):
244-
self._default_context = context
243+
def __init__(self, context=None):
244+
if context is not None:
245+
self._default_context = context
245246
self._actual_context = None
246247

248+
# The platform default is only determined when first needed, as doing
249+
# so can be costly; importing multiprocessing must stay cheap.
250+
# A cached_property rather than a property so that the attribute can
251+
# still be assigned, deleted and patched like a plain one.
252+
@functools.cached_property
253+
def _default_context(self):
254+
return _get_platform_default_context()
255+
247256
def get_context(self, method=None):
248257
if method is None:
249258
if self._actual_context is None:
@@ -331,18 +340,21 @@ def _check_available(self):
331340
if reduction.HAVE_SEND_HANDLE:
332341
_concrete_contexts['forkserver'] = ForkServerContext()
333342

334-
# bpo-33725: running arbitrary code after fork() is no longer reliable
335-
# on macOS since macOS 10.14 (Mojave). Use spawn by default instead.
336-
# gh-84559: We changed everyones default to a thread safeish one in 3.14.
337-
if (
338-
reduction.HAVE_SEND_HANDLE
339-
and sys.platform != 'darwin'
340-
# gh-155717: forkserver requires to write temporary files
341-
and util._has_writeable_tempdir()
342-
):
343-
_default_context = DefaultContext(_concrete_contexts['forkserver'])
344-
else:
345-
_default_context = DefaultContext(_concrete_contexts['spawn'])
343+
def _get_platform_default_context():
344+
# bpo-33725: running arbitrary code after fork() is no longer reliable
345+
# on macOS since macOS 10.14 (Mojave). Use spawn by default instead.
346+
# gh-84559: We changed everyones default to a thread safeish one in 3.14.
347+
if reduction.HAVE_SEND_HANDLE and sys.platform != 'darwin':
348+
# gh-155717: forkserver requires to write temporary files.
349+
#
350+
# Imported here rather than at the top level as util is costly
351+
# to import and is not otherwise needed to import this module.
352+
from . import util
353+
if util._has_writeable_tempdir():
354+
return _concrete_contexts['forkserver']
355+
return _concrete_contexts['spawn']
356+
357+
_default_context = DefaultContext()
346358

347359
else: # Windows
348360

@@ -365,7 +377,10 @@ class SpawnContext(BaseContext):
365377
_concrete_contexts = {
366378
'spawn': SpawnContext(),
367379
}
368-
_default_context = DefaultContext(_concrete_contexts['spawn'])
380+
def _get_platform_default_context():
381+
return _concrete_contexts['spawn']
382+
383+
_default_context = DefaultContext()
369384

370385
#
371386
# Force the start method

‎Lib/multiprocessing/util.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import os
1111
import itertools
1212
import sys
13-
import tempfile
1413
import weakref
1514
import atexit
1615
import threading # we want threading to install it's
@@ -155,7 +154,7 @@ def _remove_temp_dir(rmtree, tempdir):
155154
current_process._config['tempdir'] = None
156155

157156

158-
def _get_base_temp_dir():
157+
def _get_base_temp_dir(tempfile):
159158
"""Get a temporary directory where socket files will be created.
160159
161160
To prevent additional imports, pass a pre-imported 'tempfile' module.
@@ -216,8 +215,9 @@ def get_temp_dir():
216215
# get name of a temp directory which will be automatically cleaned up
217216
tempdir = process.current_process()._config.get('tempdir')
218217
if tempdir is None:
219-
import shutil
220-
base_tempdir = _get_base_temp_dir()
218+
# Deferred until first use to keep importing this module cheap.
219+
import shutil, tempfile
220+
base_tempdir = _get_base_temp_dir(tempfile)
221221
tempdir = tempfile.mkdtemp(prefix='pymp-', dir=base_tempdir)
222222
info('created temp directory %s', tempdir)
223223
# keep a strong reference to shutil.rmtree(), since the finalizer
@@ -234,7 +234,14 @@ def _has_writeable_tempdir():
234234
#
235235
# See: https://github.com/python/cpython/issues/155717.
236236

237-
path = _get_base_temp_dir()
237+
# Deferred until first use to keep importing this module cheap.
238+
import tempfile
239+
240+
try:
241+
path = _get_base_temp_dir(tempfile)
242+
except OSError:
243+
# No usable temporary directory at all.
244+
return False
238245
if path is None:
239246
return False
240247

‎Lib/test/_test_multiprocessing.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6516,6 +6516,62 @@ def test_forkserver_requires_writeable_tempdir(self, mode):
65166516
_, out, err = script_helper.assert_python_ok('-c', cmd, TMPDIR=TMPDIR)
65176517
self.assertEqual(out.decode().strip(), "spawn")
65186518

6519+
@unittest.skipIf(os.name == "nt", "requires POSIX")
6520+
@support.requires_non_root_user
6521+
def test_default_start_method_without_usable_tempdir(self):
6522+
# Neither importing multiprocessing nor determining the default
6523+
# start method may fail when tempfile finds no usable temporary
6524+
# directory at all, and the default start method must be 'spawn'.
6525+
#
6526+
# See https://github.com/python/cpython/issues/155717.
6527+
6528+
cmd = '''if 1:
6529+
import os, tempfile
6530+
# Make TMPDIR the only candidate, so that tempfile.gettempdir()
6531+
# raises FileNotFoundError instead of falling back to /tmp.
6532+
tempfile._candidate_tempdir_list = lambda: [os.environ["TMPDIR"]]
6533+
try:
6534+
tempfile.gettempdir()
6535+
except FileNotFoundError:
6536+
pass
6537+
else:
6538+
raise AssertionError("TMPDIR is usable")
6539+
6540+
import multiprocessing
6541+
if __name__ == "__main__":
6542+
print(multiprocessing.get_start_method(),
6543+
multiprocessing.get_all_start_methods()[0])
6544+
'''
6545+
6546+
with support.os_helper.temp_dir() as root:
6547+
TMPDIR = pathlib.Path(root, "TMPDIR")
6548+
TMPDIR.mkdir(mode=os.R_OK | os.X_OK)
6549+
_, out, err = script_helper.assert_python_ok('-c', cmd, TMPDIR=TMPDIR)
6550+
self.assertEqual(out.decode().split(), ["spawn", "spawn"])
6551+
6552+
def test_import_does_not_determine_default_start_method(self):
6553+
# Determining the default start method requires checking that the
6554+
# temporary directory is writeable. That must not happen, nor may
6555+
# the modules it needs be loaded, when importing multiprocessing.
6556+
import_helper.ensure_lazy_imports(
6557+
"multiprocessing", {"tempfile", "shutil", "subprocess"})
6558+
6559+
cmd = '''if 1:
6560+
import sys
6561+
import multiprocessing
6562+
print(multiprocessing.get_start_method(allow_none=True),
6563+
"tempfile" in sys.modules,
6564+
multiprocessing.get_start_method(),
6565+
multiprocessing.get_all_start_methods()[0])
6566+
'''
6567+
# -S: site customizations could import tempfile themselves.
6568+
_, out, err = script_helper.assert_python_ok('-S', '-c', cmd)
6569+
unset, probed, method, first = out.decode().split()
6570+
self.assertEqual(unset, "None")
6571+
self.assertEqual(probed, "False")
6572+
self.assertEqual(method, first)
6573+
self.assertIn(method, multiprocessing.get_all_start_methods())
6574+
65196575

65206576
@unittest.skipIf(sys.platform == "win32",
65216577
"test semantics don't make sense on Windows")

0 commit comments

Comments
 (0)