Repository navigation
[Bug]: Using MultiProcessShared handle can deadlock when always_proxy=True #40673
Description
What happened?
Beam's MultiProcessShared allows creating an object in one process and interacting with it from other processes through proxy objects. Beam uses Python's multiprocessing.managers to orchestrate this. By default, the object lives in the SDK harness process that created it first, which serves it from a background thread; spawn_process=True uses a dedicated process instead.
When the SDK harness process that hosts the object also accesses it through proxies (always_proxy=True, as RunInference does), a deadlock can happen if:
- a proxy to the object is only reachable from a reference cycle, so only the cyclic GC can free it, and
- the GC runs on one of the server's threads.
The GC then runs the proxy's finalizer, BaseProxy._decref, on that thread. The finalizer sends a decref request to the server over a new connection and waits for the reply. To serve it, the server's accepter thread has to accept the connection and start a request thread. Since GC ran on the accepter thread itself, the finalizer waited for a request thread that only the accepter could start, and the server never accepted another connection.
After the deadlock, the shared handle becomes unresponsive. Since _get_manager connects while holding a process-wide lock, acquiring other MultiProcessShared handles in the affected processes can hang too.
This was observed in an inference pipeline running on Dataflow (2026-10-16_17_11_27-1377094448160546253).
Process bundle threads both in the SDK holding the model and the sibling are stuck in File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 769, in answer_challenge:
========== ACTIVE PROCESSING BUNDLES ==========
--- instruction process_bundle-5846309219931912825-6 ---
ProcessBundleDescriptorId: s12-14
tracked thread: <_Worker(Thread-18, started daemon 137720324150976)>
time since transition: 599.94 seconds
--- Thread #137720324150976 name: Thread-18 ---
File "/usr/local/lib/python3.11/threading.py", line 1002, in _bootstrap
self._bootstrap_inner()
File "/usr/local/lib/python3.11/threading.py", line 1045, in _bootstrap_inner
self.run()
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/utils/thread_pool_executor.py", line 53, in run
self._work_item.run()
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/utils/thread_pool_executor.py", line 37, in run
self._future.set_result(self._fn(*self._fn_args, **self._fn_kwargs))
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/runners/worker/sdk_worker.py", line 392, in task
self._execute(
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/runners/worker/sdk_worker.py", line 311, in _execute
response = task()
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/runners/worker/sdk_worker.py", line 393, in
lambda: self.create_worker().do_instruction(request), request)
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/runners/worker/sdk_worker.py", line 680, in do_instruction
return getattr(self, request_type)(
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/runners/worker/sdk_worker.py", line 718, in process_bundle
bundle_processor.process_bundle(
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/runners/worker/bundle_processor.py", line 1350, in process_bundle
input_op_by_transform_id[element.transform_id].process_encoded(
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/runners/worker/bundle_processor.py", line 263, in process_encoded
self.output(decoded_value)
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/ml/inference/base.py", line 2162, in process
self.update_model(si_model_metadata)
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/ml/inference/base.py", line 2095, in update_model
self._model = self._load_model(side_input_model_path=side_input_model_path)
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/ml/inference/base.py", line 2052, in _load_model
load, tag=copy_tag, always_proxy=True).acquire())
File "/opt/apache/beam-venv/beam-venv-worker-sdk-0-0/lib/python3.11/site-packages/apache_beam/utils/multi_process_shared.py", line 422, in acquire
singleton = self._get_manager().acquire_singleton(self._tag)
File "/usr/local/lib/python3.11/multiprocessing/managers.py", line 732, in temp
conn = self._Client(token.address, authkey=self._authkey)
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 525, in Client
answer_challenge(c, authkey)
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 769, in answer_challenge
message = connection.recv_bytes(256) # reject large message
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 216, in recv_bytes
buf = self._recv_bytes(maxlength)
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 430, in _recv_bytes
buf = self._recv(4)
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 395, in _recv
chunk = read(handle, remaining)
...
and we can see the corresponding accepter thread also stuck in answer_challenge after calling _decref . Garbage collection is not evident since this doesn't include C frames, but the _decref call strongly suggests that GC started while accepter thread was active.
--- Thread #137719921497792 name: Thread-32 (accepter) ---
File "/usr/local/lib/python3.11/threading.py", line 1002, in _bootstrap
self._bootstrap_inner()
File "/usr/local/lib/python3.11/threading.py", line 1045, in _bootstrap_inner
self.run()
File "/usr/local/lib/python3.11/threading.py", line 982, in run
self._target(*self._args, **self._kwargs)
File "/usr/local/lib/python3.11/multiprocessing/managers.py", line 192, in accepter
t = threading.Thread(target=self.handle_request, args=(c,))
File "/usr/local/lib/python3.11/threading.py", line 905, in __init__
self._started = Event()
File "/usr/local/lib/python3.11/threading.py", line 563, in __init__
self._cond = Condition(Lock())
File "/usr/local/lib/python3.11/threading.py", line 254, in __init__
self._release_save = lock._release_save
File "/usr/local/lib/python3.11/multiprocessing/util.py", line 227, in __call__
res = self._callback(*self._args, **self._kwargs)
File "/usr/local/lib/python3.11/multiprocessing/managers.py", line 874, in _decref
conn = _Client(token.address, authkey=authkey)
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 525, in Client
answer_challenge(c, authkey)
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 769, in answer_challenge
message = connection.recv_bytes(256) # reject large message
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 216, in recv_bytes
buf = self._recv_bytes(maxlength)
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 430, in _recv_bytes
buf = self._recv(4)
File "/usr/local/lib/python3.11/multiprocessing/connection.py", line 395, in _recv
chunk = read(handle, remaining)
There is a repro without DF.
import gc
import multiprocessing.connection
import threading
import uuid
from apache_beam.utils import multi_process_shared
class Counter:
def __init__(self):
self._n = 0
def incr(self):
self._n += 1
return self._n
_original_accept = multiprocessing.connection.Listener.accept
def _accept_then_gc(self):
conn = _original_accept(self)
if threading.current_thread().name.endswith('(accepter)'):
gc.collect() # Stand-in for a GC pass triggered by an allocation here.
return conn
def acquire_and_call(tag, result):
shared = multi_process_shared.MultiProcessShared(
Counter, tag=tag, always_proxy=True)
result.append(shared.acquire().incr())
def main():
multiprocessing.connection.Listener.accept = _accept_then_gc
tag = 'mps_gc_deadlock_' + uuid.uuid4().hex
result = []
acquire_and_call(tag, result) # Starts the in-process server.
print('before: acquire()+incr() ->', result)
# A proxy to the server becomes cyclic garbage, so only the cyclic GC can
# reclaim it, on whatever thread the GC happens to run.
gc.disable()
victim = multi_process_shared.MultiProcessShared(
Counter, tag=tag, always_proxy=True).acquire()
cycle = {'proxy': victim}
cycle['self'] = cycle
del victim, cycle
result = []
t = threading.Thread(target=acquire_and_call, args=(tag, result), daemon=True)
t.start()
t.join(timeout=15)
if t.is_alive():
print('after: acquire() still blocked after 15s -> accepter deadlocked')
else:
print('after: acquire()+incr() ->', result)
if __name__ == '__main__':
main()
Issue Priority
Priority: 2 (default / most bugs should be filed as P2)
Issue Components
- Component: Python SDK
- Component: Java SDK
- Component: Go SDK
- Component: Typescript SDK
- Component: IO connector
- Component: Beam YAML
- Component: Beam examples
- Component: Beam playground
- Component: Beam katas
- Component: Website
- Component: Infrastructure
- Component: Spark Runner
- Component: Flink Runner
- Component: Prism Runner
- Component: Twister2 Runner
- Component: Hazelcast Jet Runner
- Component: Google Cloud Dataflow Runner
Activity
tvalentyn commentedon Oct 20, 2026
repro of a similar deadlock without Beam:
tvalentyn commentedon Oct 20, 2026
I think reasons contributing to this deadlock are
Ideas to remediate this (with each of the below, the issue is not reproducible):
A) use
always_proxy=False.B) use
spawn_process=TrueC) monkey-patch multiprocessing internals to make finalizers not blocking
A: We added
always_proxy=Truein #27989 with the motivation to reduce side-effects of an SDK process crash on other harnesses. The intent of that PR is not fully accomplished throughalways_proxysince we still don't launch a separate process withalways_proxy, more recently we addedspawn_process=Truefor that. In the latter case, the separate process is still attached to a parent process via alifeline- IIUC if a process that created a shared MPS handle crashes, the process owning the handle self-destructs:beam/sdks/python/apache_beam/utils/multi_process_shared.py
Line 238 in f014f5a
B: This is a more recent addition. We use it in Model Manager path, and there were several other issues we recently fixed with model manager, so that path is not as tested as the main, but still spawning separate processes might be working well, I haven't tested it beyond confirming that it solves the deadlock problem in the simple repro above. Likely, this is not a one-line change and might impact other things, like metrics. It might be better if multiprocessing makes some assumptions that each shared handle gets a server in a dedicated process. There are some signs that it does make such assumptions.
C: I'd rather not go this route. It looks like we are already messing with internals:
beam/sdks/python/apache_beam/utils/multi_process_shared.py
Line 57 in 7bdc6ff
A) could be the easiest remediation for now if it doesn't make things worse. Though a deadlock might still be possible in a scenario when two SDK processes each have proxies for objects owned by the opposite process, and then both proxies are garbage-collected at the exact same instant.
tvalentyn commentedon Oct 20, 2026
cc: @damccorm @AMOOOMA fyi.
tvalentyn commentedon Oct 20, 2026
This issue was discovered when testing a fix for another issue, and the pipeline used a fake MH where inferences were fast, so the worker spent a lot of time in inter-process communication. In real pipelines the chances that GC starts while one of multiprocessing threads is active might be less probable.
damccorm commentedon Oct 20, 2026
+1 to option (b), I think this is the desired behavior anyways (as you pointed, out that was the intent of my PR when I originally made it).
Thanks for looking into this
alisha-1000 commentedon Oct 22, 2026
Hi @tvalentyn,
I’m interested in contributing to this issue. Based on the investigation and the discussion around
spawn_process=True, I’d like to help evaluate the proposed approach and its implications forMultiProcessShared.I can focus on the following areas:
apache_beam/utils/multi_process_shared.py.spawn_process=True, particularly its impact on model inference, lifecycle management, and metrics.Would you recommend starting with the regression tests or investigating the implementation and its interactions with the model manager first?
Thank you.
tvalentyn commentedon Oct 22, 2026
@damccorm is already looking into this issue. if any help is needed, @damccorm can get back to you.