Skip to content

Commit 55000c6

Browse files
gpsheadfedonman
andauthored
[3.15] gh-155717: Determine multiprocessing's default start method lazily (GH-159254) (GH-159271)
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) (cherry picked from commit 48ddda1) Co-authored-by: Vyron Vasileiadis <hi@fedonman.com> * gh-155717: Reword the NEWS entry for the default start method change Say that the change applies when there is no writable temporary directory and that starting a process previously failed there. --------- Co-authored-by: Vyron Vasileiadis <hi@fedonman.com>
1 parent 18e0bcb commit 55000c6

5 files changed

Lines changed: 116 additions & 25 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: 32 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:
@@ -329,18 +338,22 @@ def _check_available(self):
329338
'spawn': SpawnContext(),
330339
'forkserver': ForkServerContext(),
331340
}
332-
# bpo-33725: running arbitrary code after fork() is no longer reliable
333-
# on macOS since macOS 10.14 (Mojave). Use spawn by default instead.
334-
# gh-84559: We changed everyones default to a thread safeish one in 3.14.
335-
if (
336-
reduction.HAVE_SEND_HANDLE
337-
and sys.platform != 'darwin'
338-
# gh-155717: forkserver requires to write temporary files
339-
and util._has_writeable_tempdir()
340-
):
341-
_default_context = DefaultContext(_concrete_contexts['forkserver'])
342-
else:
343-
_default_context = DefaultContext(_concrete_contexts['spawn'])
341+
342+
def _get_platform_default_context():
343+
# bpo-33725: running arbitrary code after fork() is no longer reliable
344+
# on macOS since macOS 10.14 (Mojave). Use spawn by default instead.
345+
# gh-84559: We changed everyones default to a thread safeish one in 3.14.
346+
if reduction.HAVE_SEND_HANDLE and sys.platform != 'darwin':
347+
# gh-155717: forkserver requires to write temporary files.
348+
#
349+
# Imported here rather than at the top level as util is costly
350+
# to import and is not otherwise needed to import this module.
351+
from . import util
352+
if util._has_writeable_tempdir():
353+
return _concrete_contexts['forkserver']
354+
return _concrete_contexts['spawn']
355+
356+
_default_context = DefaultContext()
344357

345358
else: # Windows
346359

@@ -363,7 +376,10 @@ class SpawnContext(BaseContext):
363376
_concrete_contexts = {
364377
'spawn': SpawnContext(),
365378
}
366-
_default_context = DefaultContext(_concrete_contexts['spawn'])
379+
def _get_platform_default_context():
380+
return _concrete_contexts['spawn']
381+
382+
_default_context = DefaultContext()
367383

368384
#
369385
# 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
@@ -6226,6 +6226,62 @@ def test_forkserver_requires_writeable_tempdir(self, mode):
62266226
_, out, err = script_helper.assert_python_ok('-c', cmd, TMPDIR=TMPDIR)
62276227
self.assertEqual(out.decode().strip(), "spawn")
62286228

6229+
@unittest.skipIf(os.name == "nt", "requires POSIX")
6230+
@support.requires_non_root_user
6231+
def test_default_start_method_without_usable_tempdir(self):
6232+
# Neither importing multiprocessing nor determining the default
6233+
# start method may fail when tempfile finds no usable temporary
6234+
# directory at all, and the default start method must be 'spawn'.
6235+
#
6236+
# See https://github.com/python/cpython/issues/155717.
6237+
6238+
cmd = '''if 1:
6239+
import os, tempfile
6240+
# Make TMPDIR the only candidate, so that tempfile.gettempdir()
6241+
# raises FileNotFoundError instead of falling back to /tmp.
6242+
tempfile._candidate_tempdir_list = lambda: [os.environ["TMPDIR"]]
6243+
try:
6244+
tempfile.gettempdir()
6245+
except FileNotFoundError:
6246+
pass
6247+
else:
6248+
raise AssertionError("TMPDIR is usable")
6249+
6250+
import multiprocessing
6251+
if __name__ == "__main__":
6252+
print(multiprocessing.get_start_method(),
6253+
multiprocessing.get_all_start_methods()[0])
6254+
'''
6255+
6256+
with support.os_helper.temp_dir() as root:
6257+
TMPDIR = pathlib.Path(root, "TMPDIR")
6258+
TMPDIR.mkdir(mode=os.R_OK | os.X_OK)
6259+
_, out, err = script_helper.assert_python_ok('-c', cmd, TMPDIR=TMPDIR)
6260+
self.assertEqual(out.decode().split(), ["spawn", "spawn"])
6261+
6262+
def test_import_does_not_determine_default_start_method(self):
6263+
# Determining the default start method requires checking that the
6264+
# temporary directory is writeable. That must not happen, nor may
6265+
# the modules it needs be loaded, when importing multiprocessing.
6266+
import_helper.ensure_lazy_imports(
6267+
"multiprocessing", {"tempfile", "shutil", "subprocess"})
6268+
6269+
cmd = '''if 1:
6270+
import sys
6271+
import multiprocessing
6272+
print(multiprocessing.get_start_method(allow_none=True),
6273+
"tempfile" in sys.modules,
6274+
multiprocessing.get_start_method(),
6275+
multiprocessing.get_all_start_methods()[0])
6276+
'''
6277+
# -S: site customizations could import tempfile themselves.
6278+
_, out, err = script_helper.assert_python_ok('-S', '-c', cmd)
6279+
unset, probed, method, first = out.decode().split()
6280+
self.assertEqual(unset, "None")
6281+
self.assertEqual(probed, "False")
6282+
self.assertEqual(method, first)
6283+
self.assertIn(method, multiprocessing.get_all_start_methods())
6284+
62296285

62306286
@unittest.skipIf(sys.platform == "win32",
62316287
"test semantics don't make sense on Windows")
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
:mod:`multiprocessing`'s default start method on systems with non-writeable
2-
tempfile filesystem is now :ref:`"spawn" <multiprocessing-start-methods>`
3-
instead of ``"forkserver"``. Patch by Bénédikt Tran.
1+
:mod:`multiprocessing`'s default start method is now
2+
:ref:`"spawn" <multiprocessing-start-methods>` instead of ``"forkserver"``
3+
when there is no writable temporary directory, such as on a read-only file
4+
system. Previously starting a process failed with an :exc:`OSError` there.
5+
Patch by Bénédikt Tran.

0 commit comments

Comments
 (0)