Skip to content

Commit 4a962b1

Browse files
authored
Merge pull request #3957 from jsiirola/capture-output-deadlock
Resolve `capture_output` deadlock in multiprocessing environments
2 parents 92951cb + b8a1608 commit 4a962b1

10 files changed

Lines changed: 311 additions & 91 deletions

File tree

pyomo/common/dependencies.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import importlib.util
1313
import logging
1414
import sys
15+
import threading
1516
import warnings
1617

1718
from collections.abc import Mapping
@@ -945,6 +946,11 @@ def __exit__(self, exc_type, exc_value, traceback):
945946
# Common optional dependencies used throughout Pyomo
946947
#
947948

949+
#: lock for deconflicting access to capturing the process file
950+
#: descriptors. This starts as a threading.Lock, unless the environment
951+
#: imports multiprocessing, in which case, it is upgraded to a
952+
#: multiprocessing lock.
953+
capture_output_lock = threading.Lock()
948954
yaml_load_args = {}
949955

950956

@@ -961,6 +967,18 @@ def _finalize_ctypes(module, available):
961967
import ctypes.util
962968

963969

970+
def _finalize_multiprocessing(module, available):
971+
# Note: multiprocessing is very slow to import, but we need to make
972+
# sure that the capture_output_lock Lock is created *before* the
973+
# user spawns any subprocesses. tee.capture_output will look here
974+
# for the lock, which will start out as a "dummy" lock, and then
975+
# will be updated to a multiprocessing.Lock when the first module
976+
# triggers the multiprocessing import.
977+
978+
global capture_output_lock
979+
capture_output_lock = module.Lock()
980+
981+
964982
def _finalize_scipy(module, available):
965983
if available:
966984
# Import key subpackages that we will want to assume are present
@@ -1081,12 +1099,25 @@ def _pyutilib_importer():
10811099

10821100

10831101
with declare_modules_as_importable(globals()):
1102+
# Standard libraries that we will unconditionally import. We are
1103+
# importing it here so that import timing is better reported from
1104+
# pyomo.environ.tests.test_environ (hence the imports are not
1105+
# necessarily alphebetical)
1106+
#
1107+
# Pickle is used by Pyomo and by multiprocessing
1108+
try:
1109+
import cPickle as pickle
1110+
except ImportError:
1111+
import pickle
1112+
10841113
# Standard libraries that are slower to import and not strictly required
10851114
# on all platforms / situations.
10861115
ctypes, _ = attempt_import(
10871116
'ctypes', deferred_submodules=['util'], callback=_finalize_ctypes
10881117
)
1089-
multiprocessing, _ = attempt_import('multiprocessing')
1118+
multiprocessing, _ = attempt_import(
1119+
'multiprocessing', callback=_finalize_multiprocessing
1120+
)
10901121
random, _ = attempt_import('random')
10911122

10921123
# Necessary for minimum version checking for other optional dependencies
@@ -1127,8 +1158,3 @@ def _pyutilib_importer():
11271158
deferred_submodules=['pyplot', 'pylab', 'backends'],
11281159
catch_exceptions=(ImportError, RuntimeError),
11291160
)
1130-
1131-
try:
1132-
import cPickle as pickle
1133-
except ImportError:
1134-
import pickle

pyomo/common/enums.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,30 @@ class SolverAPIVersion(NamedIntEnum):
249249

250250
minimize = ObjectiveSense.minimize
251251
maximize = ObjectiveSense.maximize
252+
253+
254+
class CaptureOutputMode(IntEnum):
255+
"""Enum to override the default behavior of the :class:`capture_output`
256+
context manager.
257+
258+
This enum provides options for overriding the behavior of the
259+
:class:`capture_output` context manager through the
260+
:attr:`~pyomo.common.tee.OVERRIDE_CAPTURE_OUTPUT` flag.
261+
262+
"""
263+
264+
#: Setting this mode will cause :class:`capture_output` to be a noop
265+
DISABLE = 0
266+
#: :class:`capture_output` will capture the standard ``sys.stdout`` /
267+
#: ``sys.stderr`` streams
268+
ENABLE_STREAM_CAPTURE = 1
269+
#: :class:`capture_output` will capture the file descriptors that
270+
#: underlie the standard ``sys.stdout`` / ``sys.stderr`` streams
271+
ENABLE_FD_CAPTURE = 2
272+
#: This is :class:`capture_output`'s normal operation (all capturing is enabled)
273+
NORMAL = 3
274+
#: :class:`capture_output` will capture the standard ``sys.stdout`` /
275+
#: ``sys.stderr`` streams, but will not attempt to capture their raw
276+
#: file descriptors (regardless of the value of `capture_fd`) [alias
277+
#: of ENABLE_STREAM_CAPTURE]
278+
DISABLE_FD_CAPTURE = 1

pyomo/common/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import os
1111

12-
from .dependencies import ctypes, multiprocessing
12+
from pyomo.common.dependencies import ctypes, multiprocessing
1313

1414

1515
def _as_bytes(val):

pyomo/common/tee.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
2222
import threading
2323
import time
2424

25+
import pyomo.common.dependencies as dependencies
26+
from pyomo.common.enums import CaptureOutputMode
2527
from pyomo.common.errors import DeveloperError
2628
from pyomo.common.log import LoggingIntercept, LogStream
29+
from pyomo.common.shutdown import python_is_shutting_down
2730

2831
_poll_interval = 0.0001
2932
_poll_rampup_limit = 0.099
@@ -35,6 +38,7 @@
3538
# ~(13.1 * #threads) seconds
3639
_poll_timeout = 1 # 14 rounds: 0.0001 * 2**14 == 1.6384
3740
_poll_timeout_deadlock = 100 # seconds
41+
_threading_deadlock = 200 # seconds; should be longer than _poll_timeout_deadlock
3842
_pipe_buffersize = 1 << 16 # 65536
3943
_noop = lambda: None
4044
_mswindows = sys.platform.startswith('win')
@@ -54,6 +58,8 @@
5458

5559
logger = logging.getLogger(__name__)
5660

61+
OVERRIDE_CAPTURE_OUTPUT = CaptureOutputMode.NORMAL
62+
5763

5864
class _SignalFlush:
5965
def __init__(self, ostream, handle):
@@ -297,14 +303,14 @@ class capture_output:
297303
298304
"""
299305

300-
startup_shutdown = threading.Lock()
301-
302306
def __init__(self, output=None, capture_fd=False):
303307
self.output = output
304308
self.output_stream = None
305309
self.old = None
306310
self.tee = None
307-
self.capture_fd = capture_fd
311+
self.capture_fd = capture_fd and (
312+
OVERRIDE_CAPTURE_OUTPUT & CaptureOutputMode.ENABLE_FD_CAPTURE
313+
)
308314
self.context_stack = []
309315

310316
def _enter_context(self, cm, prior_to=None):
@@ -339,13 +345,12 @@ def _exit_context_stack(self, et, ev, tb):
339345
cm.__exit__(et, ev, tb)
340346
except:
341347
_stack = self.context_stack
342-
FAIL.append(
343-
f"{sys.exc_info()[0].__name__}: {sys.exc_info()[1]} ({len(_stack)+1}: {cm}@{id(cm):x})"
344-
)
348+
_et, _e, _tb = sys.exc_info()
349+
FAIL.append(f"{_et.__name__}: {_e} ({len(_stack)+1}: {cm}@{id(cm):x})")
345350
return FAIL
346351

347352
def __enter__(self):
348-
if not capture_output.startup_shutdown.acquire(timeout=_poll_timeout_deadlock):
353+
if not dependencies.capture_output_lock.acquire(timeout=_threading_deadlock):
349354
# This situation *shouldn't* happen. If it does, it is
350355
# unlikely that the user can fix it (or even debug it).
351356
# Instead they should report it back to us.
@@ -358,20 +363,22 @@ def __enter__(self):
358363
# was trying to start up / run (so the other solver held
359364
# the lock, but the GC interrupted that thread and
360365
# wouldn't let go).
361-
raise DeveloperError("Deadlock starting capture_output")
366+
if not python_is_shutting_down():
367+
raise DeveloperError("Deadlock starting capture_output")
362368
try:
363369
return self._enter_impl()
364370
finally:
365-
capture_output.startup_shutdown.release()
371+
dependencies.capture_output_lock.release()
366372

367373
def __exit__(self, et, ev, tb):
368-
if not capture_output.startup_shutdown.acquire(timeout=_poll_timeout_deadlock):
374+
if not dependencies.capture_output_lock.acquire(timeout=_threading_deadlock):
369375
# See comments & breadcrumbs in __enter__() above.
370-
raise DeveloperError("Deadlock closing capture_output")
376+
if not python_is_shutting_down():
377+
raise DeveloperError("Deadlock closing capture_output")
371378
try:
372379
return self._exit_impl(et, ev, tb)
373380
finally:
374-
capture_output.startup_shutdown.release()
381+
dependencies.capture_output_lock.release()
375382

376383
def _enter_impl(self):
377384
self.old = (sys.stdout, sys.stderr)
@@ -486,8 +493,11 @@ def _enter_impl(self):
486493
# exception.
487494
self._exit_context_stack(*sys.exc_info())
488495
raise
489-
sys.stdout = self.tee.STDOUT
490-
sys.stderr = self.tee.STDERR
496+
if OVERRIDE_CAPTURE_OUTPUT & CaptureOutputMode.ENABLE_STREAM_CAPTURE:
497+
sys.stdout = self.tee.STDOUT
498+
sys.stderr = self.tee.STDERR
499+
else:
500+
self.old = None
491501
buf = self.tee.ostreams
492502
if len(buf) == 1:
493503
buf = buf[0]

pyomo/common/tests/test_multithread.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@
88
# ____________________________________________________________________________________
99

1010
import threading
11-
import pyomo.common.unittest as unittest
12-
from pyomo.common.multithread import *
1311
from threading import Thread
12+
from multiprocessing.dummy import Pool as ThreadPool
13+
14+
import pyomo.common.unittest as unittest
15+
16+
from pyomo.common.multithread import MultiThreadWrapper, MultiThreadWrapperWithMain
1417
from pyomo.opt.base.solvers import check_available_solvers
1518

19+
import pyomo.environ as pyo
20+
1621

1722
class Dummy:
1823
"""asdfg"""
@@ -103,10 +108,6 @@ def thread_func():
103108
)
104109
def test_solve(self):
105110
# Based on the minimal example in https://github.com/Pyomo/pyomo/issues/2475
106-
import pyomo.environ as pyo
107-
from pyomo.opt import SolverFactory
108-
from multiprocessing.dummy import Pool as ThreadPool
109-
110111
model = pyo.ConcreteModel()
111112
model.nVars = pyo.Param(initialize=4)
112113
model.N = pyo.RangeSet(model.nVars)
@@ -115,7 +116,7 @@ def test_solve(self):
115116
model.cuts = pyo.ConstraintList()
116117

117118
def test(model):
118-
opt = SolverFactory('glpk')
119+
opt = pyo.SolverFactory('glpk')
119120
opt.solve(model)
120121

121122
# Iterate, adding a cut to exclude the previously found solution

pyomo/common/tests/test_tee.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from pyomo.common.errors import DeveloperError
2222
from pyomo.common.log import LoggingIntercept, LogStream
2323
from pyomo.common.tempfiles import TempfileManager
24+
import pyomo.common.dependencies as deps
2425
import pyomo.common.tee as tee
2526
import pyomo.common.unittest as unittest
2627

@@ -581,28 +582,40 @@ def test_capture_output_stack_error(self):
581582
logging.getLogger('pyomo.common.tee').handlers.clear()
582583

583584
def test_atomic_deadlock(self):
584-
save_poll = tee._poll_timeout_deadlock
585-
tee._poll_timeout_deadlock = 0.01
585+
save_poll = tee._threading_deadlock
586+
tee._threading_deadlock = 0.01
587+
588+
# Ensure multiprocessing is loaded:
589+
deps.multiprocessing.Lock
586590

587591
co = tee.capture_output()
588592
try:
589-
tee.capture_output.startup_shutdown.acquire()
593+
deps.capture_output_lock.acquire()
590594
with self.assertRaisesRegex(
591595
DeveloperError, "Deadlock starting capture_output"
592596
):
593597
with tee.capture_output():
594598
pass
595-
tee.capture_output.startup_shutdown.release()
599+
deps.capture_output_lock.release()
596600

597601
with self.assertRaisesRegex(
598602
DeveloperError, "Deadlock closing capture_output"
599603
):
600604
with co:
601-
tee.capture_output.startup_shutdown.acquire()
605+
deps.capture_output_lock.acquire()
602606
finally:
603-
tee._poll_timeout_deadlock = save_poll
604-
if tee.capture_output.startup_shutdown.locked():
605-
tee.capture_output.startup_shutdown.release()
607+
tee._threading_deadlock = save_poll
608+
# We would like to just test if out Lock was acquired and
609+
# then release it if necessary. Unfortunately,
610+
# multiprocessing.Lock doesn't support locked(), so we will
611+
# just catch and eat the error for releasing an unlocked
612+
# lock.
613+
#
614+
## if deps.capture_output_lock.locked():
615+
try:
616+
deps.capture_output_lock.release()
617+
except ValueError:
618+
pass
606619
co.reset()
607620

608621
def test_capture_output_invalid_ostream(self):
@@ -710,6 +723,22 @@ def flush(self):
710723
finally:
711724
tee._poll_timeout, tee._poll_timeout_deadlock = _save
712725

726+
def test_capture_output_override(self):
727+
capture1 = tee.capture_output(capture_fd=True)
728+
self.assertTrue(capture1.capture_fd)
729+
with capture1 as OUT1:
730+
try:
731+
orig = tee.OVERRIDE_CAPTURE_OUTPUT
732+
tee.OVERRIDE_CAPTURE_OUTPUT = tee.CaptureOutputMode.DISABLE
733+
capture2 = tee.capture_output(capture_fd=True)
734+
with capture2 as OUT2:
735+
self.assertFalse(capture2.capture_fd)
736+
print("Hello, World")
737+
self.assertEqual(OUT2.getvalue(), "")
738+
finally:
739+
tee.OVERRIDE_CAPTURE_OUTPUT = orig
740+
self.assertEqual(OUT1.getvalue(), "Hello, World\n")
741+
713742

714743
class BufferTester:
715744
def setUp(self):

pyomo/common/tests/test_unittest.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
# ____________________________________________________________________________________
99

1010
import datetime
11-
import multiprocessing
1211
import os
1312
import pickle
1413
import time
1514

1615
import pyomo.common.unittest as unittest
16+
import pyomo.common.dependencies as deps
17+
from pyomo.common.dependencies import multiprocessing
1718
from pyomo.common.log import LoggingIntercept
1819
from pyomo.common.tee import capture_output
1920
from pyomo.common.tempfiles import TempfileManager
@@ -171,8 +172,28 @@ def test_assertStructuredAlmostEqual_numericvalue(self):
171172

172173
def test_timeout_fcn_call(self):
173174
self.assertEqual(short_sleep(), 42)
174-
with self.assertRaisesRegex(TimeoutError, 'test timed out after 0.01 seconds'):
175-
long_sleep()
175+
with LoggingIntercept() as LOG:
176+
with self.assertRaisesRegex(
177+
TimeoutError, 'test timed out after 0.01 seconds'
178+
):
179+
long_sleep()
180+
self.assertEqual(LOG.getvalue(), "")
181+
deps.capture_output_lock.acquire()
182+
save = unittest._timeout_terminate_timeout
183+
unittest._timeout_terminate_timeout = 0.01
184+
try:
185+
with self.assertRaisesRegex(
186+
TimeoutError, 'test timed out after 0.01 seconds'
187+
):
188+
long_sleep()
189+
finally:
190+
unittest._timeout_terminate_timeout = save
191+
deps.capture_output_lock.release()
192+
self.assertEqual(
193+
LOG.getvalue(),
194+
"Failed to acquire capture_output_lock Lock before "
195+
"terminating subprocess on timeout: process deadlock is likely.\n",
196+
)
176197
with self.assertRaisesRegex(
177198
NameError, r"name 'foo' is not defined\s+Original traceback:"
178199
):

0 commit comments

Comments
 (0)