Skip to content

Commit a4dcd4d

Browse files
"Fix" fork() so it "works" on Python 3.13, and "works" better on older Python versions (#1047)
Behavior change: threads created by eventlet.green.threading.Thread and threading.Thead will be visible across both modules if monkey patching was used. Previously each module would only list threads created in that module. Bug fix: after fork(), greenlet threads are correctly listed in threading.enumerate() if monkey patching was used. You should not use fork()-without-execve(). Co-authored-by: Itamar Turner-Trauring <[email protected]>
1 parent 3c3b07b commit a4dcd4d

File tree

10 files changed

+210
-35
lines changed

10 files changed

+210
-35
lines changed

doc/source/fork.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
fork() without execve()
2+
=======================
3+
4+
``fork()`` is a way to make a clone of a process.
5+
Most subprocesses replace the child process with a new executable, wiping out all memory from the parent process.
6+
A ``fork()ed`` subprocess can choose not to do this, and preserve data from the parent process.
7+
(Technically this is ``fork()`` without ``execve()``.)
8+
9+
This is a terrible idea, as it can cause deadlocks, memory corruption, and crashes.
10+
11+
For backwards compatibility, Eventlet *tries* to work in this case, but this is very much not guaranteed and your program may suffer from deadlocks, memory corruption, and crashes.
12+
This is even more so when using the ``asyncio`` hub, as that requires even more questionable interventions to "work".
13+
14+
This is not a problem with Eventlet.
15+
It's a fundamental problem with ``fork()`` applicable to pretty much every Python program.
16+
17+
Below are some common reasons you might be using ``fork()`` without knowing it, and what you can do about it.
18+
19+
``multiprocessing``
20+
------------------
21+
22+
On Linux, on Python 3.13 earlier, the standard library ``multiprocessing`` library uses ``fork()`` by default.
23+
To fix this, switch to the ``"spawn"`` method (the default in Python 3.14 and later) as `documented here <https://docs.python.org/3.13/library/multiprocessing.html#contexts-and-start-methods>`.
24+
25+
26+
``oslo.service``
27+
----------------
28+
29+
There are alternative ways of running services that do not use ``fork()``.
30+
See the documentation.

doc/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ Concepts & References
9393
zeromq
9494
hubs
9595
environment
96+
fork
9697
modules
9798

9899
Want to contribute?

eventlet/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,13 @@
7676
('call_after_global', 'greenthread.call_after_global', greenthread.call_after_global),
7777
))
7878

79-
os
79+
80+
if hasattr(os, "register_at_fork"):
81+
def _warn_on_fork():
82+
import warnings
83+
warnings.warn(
84+
"Using fork() is a bad idea, and there is no guarantee eventlet will work." +
85+
" See https://eventlet.readthedocs.io/en/latest/fork.html for more details.",
86+
DeprecationWarning
87+
)
88+
os.register_at_fork(before=_warn_on_fork)

eventlet/green/threading.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
from eventlet.green import time
55
from eventlet.support import greenlets as greenlet
66

7-
__patched__ = ['Lock', '_after_fork', '_allocate_lock', '_get_main_thread_ident',
7+
__patched__ = ['Lock', '_allocate_lock', '_get_main_thread_ident',
88
'_make_thread_handle', '_shutdown', '_sleep',
99
'_start_joinable_thread', '_start_new_thread', '_ThreadHandle',
10-
'currentThread', 'current_thread', 'local', 'stack_size']
10+
'currentThread', 'current_thread', 'local', 'stack_size',
11+
"_active", "_limbo"]
1112

1213
__patched__ += ['get_ident', '_set_sentinel']
1314

eventlet/hubs/asyncio.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ def __init__(self):
4444
asyncio.set_event_loop(self.loop)
4545
self.sleep_event = asyncio.Event()
4646

47+
import asyncio.events
48+
if hasattr(asyncio.events, "on_fork"):
49+
# Allow post-fork() child to continue using the same event loop.
50+
# This is a terrible idea.
51+
asyncio.events.on_fork.__code__ = (lambda: None).__code__
52+
else:
53+
# On Python 3.9-3.11, there's a thread local we need to reset.
54+
# Also a terrible idea.
55+
def re_register_loop(loop=self.loop):
56+
asyncio.events._set_running_loop(loop)
57+
58+
os.register_at_fork(after_in_child=re_register_loop)
59+
4760
def add_timer(self, timer):
4861
"""
4962
Register a ``Timer``.

eventlet/patcher.py

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -432,27 +432,21 @@ def monkey_patch(**on):
432432
if hasattr(orig_mod, attr_name):
433433
delattr(orig_mod, attr_name)
434434

435-
# https://github.com/eventlet/eventlet/issues/592
436435
if name == "threading" and register_at_fork:
437-
438-
def fix_threading_active(
439-
_global_dict=_threading.current_thread.__globals__,
440-
# alias orig_mod as patched to reflect its new state
441-
# https://github.com/eventlet/eventlet/pull/661#discussion_r509877481
442-
_patched=orig_mod,
443-
):
444-
_prefork_active = [None]
445-
446-
def before_fork():
447-
_prefork_active[0] = _global_dict["_active"]
448-
_global_dict["_active"] = _patched._active
449-
450-
def after_fork():
451-
_global_dict["_active"] = _prefork_active[0]
452-
453-
register_at_fork(before=before_fork, after_in_parent=after_fork)
454-
455-
fix_threading_active()
436+
# The whole post-fork processing in stdlib threading.py,
437+
# implemented in threading._after_fork(), is based on the
438+
# assumption that threads don't survive fork(). However, green
439+
# threads do survive fork, and that's what threading.py is
440+
# tracking when using eventlet, so there's no need to do any
441+
# post-fork cleanup in this case.
442+
#
443+
# So, we wipe out _after_fork()'s code so it does nothing. We
444+
# can't just override it because it has already been registered
445+
# with os.register_after_fork().
446+
def noop():
447+
pass
448+
orig_mod._after_fork.__code__ = noop.__code__
449+
inject("threading", {})._after_fork.__code__ = noop.__code__
456450
finally:
457451
imp.release_lock()
458452

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import eventlet
2+
3+
eventlet.monkey_patch()
4+
5+
import os
6+
import time
7+
import threading
8+
9+
results = set()
10+
parent = True
11+
12+
13+
def check_current():
14+
if threading.current_thread() not in threading.enumerate():
15+
raise SystemExit(17)
16+
17+
18+
def background():
19+
time.sleep(1)
20+
check_current()
21+
results.add("background")
22+
23+
24+
def forker():
25+
pid = os.fork()
26+
check_current()
27+
if pid != 0:
28+
# We're in the parent. Wait for child to die.
29+
wait_pid, status = os.wait()
30+
exit_code = os.waitstatus_to_exitcode(status)
31+
assert wait_pid == pid
32+
assert exit_code == 0, exit_code
33+
else:
34+
global parent
35+
parent = False
36+
results.add("forker")
37+
38+
39+
t = threading.Thread(target=background)
40+
t.start()
41+
forker()
42+
t.join()
43+
44+
check_current()
45+
assert results == {"background", "forker"}, results
46+
if parent:
47+
print("pass")

tests/isolated/fork_in_thread.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import eventlet
2+
3+
eventlet.monkey_patch()
4+
5+
import os
6+
import time
7+
import threading
8+
9+
results = []
10+
parent = True
11+
12+
13+
def check_current():
14+
if threading.current_thread() not in threading.enumerate():
15+
raise SystemExit(17)
16+
17+
18+
def background():
19+
time.sleep(1)
20+
check_current()
21+
results.append(True)
22+
23+
24+
def forker():
25+
pid = os.fork()
26+
check_current()
27+
if pid != 0:
28+
# We're in the parent. Wait for child to die.
29+
wait_pid, status = os.wait()
30+
exit_code = os.waitstatus_to_exitcode(status)
31+
assert wait_pid == pid
32+
assert exit_code == 0, exit_code
33+
else:
34+
global parent
35+
parent = False
36+
results.append(True)
37+
38+
39+
t = threading.Thread(target=background)
40+
t.start()
41+
t2 = threading.Thread(target=forker)
42+
t2.start()
43+
t2.join()
44+
t.join()
45+
46+
check_current()
47+
assert results == [True, True]
48+
if parent:
49+
print("pass")

tests/isolated/patcher_fork_after_monkey_patch.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ def check(n, mod, tag):
1717
_threading = eventlet.patcher.original('threading')
1818
import eventlet.green.threading
1919

20+
global threads_keep_running
21+
threads_keep_running = True
22+
2023
def target():
21-
eventlet.sleep(0.1)
24+
while threads_keep_running:
25+
eventlet.sleep(0.001)
2226

2327
threads = [
2428
threading.Thread(target=target, name='patched'),
@@ -31,23 +35,31 @@ def target():
3135
for t in threads:
3236
t.start()
3337

34-
check(2, threading, 'pre-fork patched')
38+
check(5, threading, 'pre-fork patched')
3539
check(3, _threading, 'pre-fork original')
36-
check(4, eventlet.green.threading, 'pre-fork green')
40+
check(5, eventlet.green.threading, 'pre-fork green')
3741

38-
if os.fork() == 0:
39-
# Inside the child, we should only have a main thread,
40-
# but old pythons make it difficult to ensure
41-
check(1, threading, 'child post-fork patched')
42+
pid = os.fork()
43+
if pid == 0:
44+
# Inside the child, we should only have a main _OS_ thread,
45+
# but green threads should survive.
46+
check(5, threading, 'child post-fork patched')
4247
check(1, _threading, 'child post-fork original')
43-
check(1, eventlet.green.threading, 'child post-fork green')
48+
check(5, eventlet.green.threading, 'child post-fork green')
49+
threads_keep_running = False
4450
sys.exit()
4551
else:
46-
os.wait()
52+
wait_pid, status = os.wait()
53+
exit_code = os.waitstatus_to_exitcode(status)
54+
assert wait_pid == pid
55+
assert exit_code == 0, exit_code
4756

48-
check(2, threading, 'post-fork patched')
57+
# We're in the parent now; all threads should survive:
58+
check(5, threading, 'post-fork patched')
4959
check(3, _threading, 'post-fork original')
50-
check(4, eventlet.green.threading, 'post-fork green')
60+
check(5, eventlet.green.threading, 'post-fork green')
61+
62+
threads_keep_running = False
5163

5264
for t in threads:
5365
t.join()

tests/patcher_test.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,14 +514,33 @@ def test_threadpoolexecutor():
514514
tests.run_isolated('patcher_threadpoolexecutor.py')
515515

516516

517+
FORK_REASON = "fork() doesn't work well on macOS, and definitely doesn't work on Windows"
518+
519+
517520
@pytest.mark.skipif(
518521
not sys.platform.startswith("linux"),
519-
reason="fork() doesn't work well on macOS, and definitely doesn't work on Windows"
522+
reason=FORK_REASON
520523
)
521524
def test_fork_after_monkey_patch():
522525
tests.run_isolated('patcher_fork_after_monkey_patch.py')
523526

524527

528+
@pytest.mark.skipif(
529+
not sys.platform.startswith("linux"),
530+
reason=FORK_REASON
531+
)
532+
def test_fork_after_monkey_patch_threading():
533+
tests.run_isolated('fork_in_main_thread.py')
534+
535+
536+
@pytest.mark.skipif(
537+
not sys.platform.startswith("linux"),
538+
reason=FORK_REASON
539+
)
540+
def test_fork_in_thread_after_monkey_patch_threading():
541+
tests.run_isolated('fork_in_thread.py')
542+
543+
525544
def test_builtin():
526545
tests.run_isolated('patcher_builtin.py')
527546

0 commit comments

Comments
 (0)