Skip to content

Commit f7ae662

Browse files
committed
Python 3.11: Fix a memory leak switching to greenlets.
This leak was present in the original implementation of Python 3.11 support (#306) Fixes #328 and fixes gevent/gevent#1924 ; I have run gevent's test suite locally on 3.11 with this fix without seeing any regressions.
1 parent 7389976 commit f7ae662

File tree

7 files changed

+239
-8
lines changed

7 files changed

+239
-8
lines changed

CHANGES.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
2.0.1 (unreleased)
66
==================
77

8-
- Nothing changed yet.
8+
- Python 3.11: Fix a memory leak. See `issue 328
9+
<https://github.com/python-greenlet/greenlet/issues/328>`_ and
10+
`gevent issue 1924 <https://github.com/gevent/gevent/issues/1924>`_.
911

1012

1113
2.0.0.post0 (2022-11-03)
@@ -154,13 +156,18 @@ Changes
154156

155157
- Add musllinux (Alpine) binary wheels.
156158

159+
.. important:: This preliminary support for Python 3.11 leaks memory.
160+
Please upgrade to greenlet 2 if you're using Python 3.11.
157161

158162
1.1.3 (2022-08-25)
159163
==================
160164

161165
- Add support for Python 3.11. Please note that Windows binary wheels
162166
are not available at this time.
163167

168+
.. important:: This preliminary support for Python 3.11 leaks memory.
169+
Please upgrade to greenlet 2 if you're using Python 3.11.
170+
164171
1.1.2 (2021-09-29)
165172
==================
166173

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ def get_greenlet_version():
226226
],
227227
'test': [
228228
'objgraph',
229-
'faulthandler; python_version == "2.7" and platform_python_implementation == "CPython"',
229+
'faulthandler; python_version == "2.7" and platform_python_implementation == "CPython"',
230+
'psutil',
230231
],
231232
},
232233
python_requires=">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*",

src/greenlet/greenlet.cpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1438,12 +1438,15 @@ UserGreenlet::inner_bootstrap(OwnedGreenlet& origin_greenlet, OwnedObject& _run)
14381438
result = single_result(result);
14391439
}
14401440
this->release_args();
1441+
this->python_state.did_finish(PyThreadState_GET());
14411442

14421443
result = g_handle_exit(result);
14431444
assert(this->thread_state()->borrow_current() == this->_self);
1445+
14441446
/* jump back to parent */
14451447
this->stack_state.set_inactive(); /* dead */
14461448

1449+
14471450
// TODO: Can we decref some things here? Release our main greenlet
14481451
// and maybe parent?
14491452
for (Greenlet* parent = this->_parent;
@@ -2029,7 +2032,6 @@ UserGreenlet::tp_clear()
20292032
this->_parent.CLEAR();
20302033
this->_main_greenlet.CLEAR();
20312034
this->_run_callable.CLEAR();
2032-
20332035
return 0;
20342036
}
20352037

@@ -2140,6 +2142,10 @@ Greenlet::~Greenlet()
21402142

21412143
UserGreenlet::~UserGreenlet()
21422144
{
2145+
// Python 3.11: If we don't clear out the raw frame datastack
2146+
// when deleting an unfinished greenlet,
2147+
// TestLeaks.test_untracked_memory_doesnt_increase_unfinished_thread_dealloc_in_main fails.
2148+
this->python_state.did_finish(nullptr);
21432149
this->tp_clear();
21442150
}
21452151

src/greenlet/greenlet_greenlet.hpp

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,10 @@ namespace greenlet
146146
int recursion_depth;
147147
int trash_delete_nesting;
148148
#if GREENLET_PY311
149-
_PyInterpreterFrame *current_frame;
150-
_PyStackChunk *datastack_chunk;
151-
PyObject **datastack_top;
152-
PyObject **datastack_limit;
149+
_PyInterpreterFrame* current_frame;
150+
_PyStackChunk* datastack_chunk;
151+
PyObject** datastack_top;
152+
PyObject** datastack_limit;
153153
#endif
154154

155155
public:
@@ -170,6 +170,7 @@ namespace greenlet
170170
void set_new_cframe(_PyCFrame& frame) G_NOEXCEPT;
171171
#endif
172172
void will_switch_from(PyThreadState *const origin_tstate) G_NOEXCEPT;
173+
void did_finish(PyThreadState* tstate) G_NOEXCEPT;
173174
};
174175

175176
class StackState
@@ -213,9 +214,13 @@ namespace greenlet
213214
inline intptr_t stack_saved() const G_NOEXCEPT;
214215
inline char* stack_start() const G_NOEXCEPT;
215216
static inline StackState make_main() G_NOEXCEPT;
217+
#ifdef GREENLET_USE_STDIO
216218
friend std::ostream& operator<<(std::ostream& os, const StackState& s);
219+
#endif
217220
};
221+
#ifdef GREENLET_USE_STDIO
218222
std::ostream& operator<<(std::ostream& os, const StackState& s);
223+
#endif
219224

220225
class SwitchingArgs
221226
{
@@ -933,14 +938,97 @@ void PythonState::set_new_cframe(_PyCFrame& frame) G_NOEXCEPT
933938
}
934939
#endif
935940

936-
937941
const PythonState::OwnedFrame& PythonState::top_frame() const G_NOEXCEPT
938942
{
939943
return this->_top_frame;
940944
}
941945

946+
void PythonState::did_finish(PyThreadState* tstate) G_NOEXCEPT
947+
{
948+
#if GREENLET_PY311
949+
// See https://github.com/gevent/gevent/issues/1924 and
950+
// https://github.com/python-greenlet/greenlet/issues/328. In
951+
// short, Python 3.11 allocates memory for frames as a sort of
952+
// linked list that's kept as part of PyThreadState in the
953+
// ``datastack_chunk`` member and friends. These are saved and
954+
// restored as part of switching greenlets.
955+
//
956+
// When we initially switch to a greenlet, we set those to NULL.
957+
// That causes the frame management code to treat this like a
958+
// brand new thread and start a fresh list of chunks, beginning
959+
// with a new "root" chunk. As we make calls in this greenlet,
960+
// those chunks get added, and as calls return, they get popped.
961+
// But the frame code (pystate.c) is careful to make sure that the
962+
// root chunk never gets popped.
963+
//
964+
// Thus, when a greenlet exits for the last time, there will be at
965+
// least a single root chunk that we must be responsible for
966+
// deallocating.
967+
//
968+
// The complex part is that these chunks are allocated and freed
969+
// using ``_PyObject_VirtualAlloc``/``Free``. Those aren't public
970+
// functions, and they aren't exported for linking. It so happens
971+
// that we know they are just thin wrappers around the Arena
972+
// allocator, so we can use that directly to deallocate in a
973+
// compatible way.
974+
//
975+
// CAUTION: Check this implementation detail on every major version.
976+
//
977+
// It might be nice to be able to do this in our destructor, but
978+
// can we be sure that no one else is using that memory? Plus, as
979+
// described below, our pointers may not even be valid anymore. As
980+
// a special case, there is one time that we know we can do this,
981+
// and that's from the destructor of the associated UserGreenlet
982+
// (NOT main greenlet)
983+
PyObjectArenaAllocator alloc;
984+
_PyStackChunk* chunk = nullptr;
985+
if (tstate) {
986+
// We really did finish, we can never be switched to again.
987+
chunk = tstate->datastack_chunk;
988+
// Unfortunately, we can't do much sanity checking. Our
989+
// this->datastack_chunk pointer is out of date (evaluation may
990+
// have popped down through it already) so we can't verify that
991+
// we deallocate it. I don't think we can even check datastack_top
992+
// for the same reason.
993+
994+
PyObject_GetArenaAllocator(&alloc);
995+
tstate->datastack_chunk = nullptr;
996+
tstate->datastack_limit = nullptr;
997+
tstate->datastack_top = nullptr;
998+
999+
}
1000+
else if (this->datastack_chunk) {
1001+
// The UserGreenlet (NOT the main greenlet!) is being deallocated. If we're
1002+
// still holding a stack chunk, it's garbage because we know
1003+
// we can never switch back to let cPython clean it up.
1004+
// Because the last time we got switched away from, and we
1005+
// haven't run since then, we know our chain is valid and can
1006+
// be dealloced.
1007+
chunk = this->datastack_chunk;
1008+
PyObject_GetArenaAllocator(&alloc);
1009+
}
1010+
1011+
if (alloc.free && chunk) {
1012+
// In case the arena mechanism has been torn down already.
1013+
while (chunk) {
1014+
_PyStackChunk *prev = chunk->previous;
1015+
chunk->previous = nullptr;
1016+
alloc.free(alloc.ctx, chunk, chunk->size);
1017+
chunk = prev;
1018+
}
1019+
}
1020+
1021+
this->datastack_chunk = nullptr;
1022+
this->datastack_limit = nullptr;
1023+
this->datastack_top = nullptr;
1024+
#endif
1025+
}
1026+
1027+
1028+
9421029

9431030
using greenlet::StackState;
1031+
9441032
#ifdef GREENLET_USE_STDIO
9451033
#include <iostream>
9461034
using std::cerr;

src/greenlet/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def __new__(cls, classname, bases, classDict):
4545
classDict[key] = value
4646
return type.__new__(cls, classname, bases, classDict)
4747

48+
4849
class TestCase(TestCaseMetaClass(
4950
"NewBase",
5051
(unittest.TestCase,),

src/greenlet/tests/test_cpp.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
def run_unhandled_exception_in_greenlet_aborts():
1212
# This is used in multiprocessing.Process and must be picklable
1313
# so it needs to be global.
14+
15+
1416
def _():
1517
_test_extension_cpp.test_exception_switch_and_do_in_g2(
1618
_test_extension_cpp.test_exception_throw
@@ -63,3 +65,7 @@ def test_unhandled_exception_aborts(self):
6365
def test_unhandled_exception_in_greenlet_aborts(self):
6466
# verify that unhandled throw called in greenlet aborts too
6567
self._do_test_unhandled_exception(run_unhandled_exception_in_greenlet_aborts)
68+
69+
70+
if __name__ == '__main__':
71+
__import__('unittest').main()

src/greenlet/tests/test_leaks.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# -*- coding: utf-8 -*-
12
"""
23
Testing scenarios that may have leaked.
34
"""
@@ -10,9 +11,12 @@
1011
import weakref
1112
import threading
1213

14+
import psutil
15+
1316
import greenlet
1417
from . import TestCase
1518
from .leakcheck import fails_leakcheck
19+
from .leakcheck import ignores_leakcheck
1620

1721
try:
1822
from sys import intern
@@ -295,3 +299,121 @@ def test_issue251_issue252_explicit_reference_not_collectable(self):
295299
self._check_issue251(
296300
manually_collect_background=False,
297301
explicit_reference_to_switch=True)
302+
303+
def test_untracked_memory_doesnt_increase(self):
304+
# See https://github.com/gevent/gevent/issues/1924
305+
# and https://github.com/python-greenlet/greenlet/issues/328
306+
def f():
307+
return 1
308+
309+
ITER = 10000
310+
def run_it():
311+
for _ in range(ITER):
312+
greenlet.greenlet(f).switch()
313+
314+
# Establish baseline
315+
for _ in range(3):
316+
run_it()
317+
318+
# uss: (Linux, macOS, Windows): aka "Unique Set Size", this is
319+
# the memory which is unique to a process and which would be
320+
# freed if the process was terminated right now.
321+
uss_before = psutil.Process().memory_full_info().uss
322+
323+
for _ in range(10):
324+
run_it()
325+
326+
uss_after = psutil.Process().memory_full_info().uss
327+
328+
self.assertLessEqual(uss_after, uss_before)
329+
330+
def _check_untracked_memory_thread(self, deallocate_in_thread=True):
331+
# Like the above test, but what if there are a bunch of
332+
# unfinished greenlets in a thread that dies?
333+
# Does it matter if we deallocate in the thread or not?
334+
EXIT_COUNT = [0]
335+
336+
def f():
337+
try:
338+
greenlet.getcurrent().parent.switch()
339+
except greenlet.GreenletExit:
340+
EXIT_COUNT[0] += 1
341+
raise
342+
return 1
343+
344+
ITER = 10000
345+
def run_it():
346+
glets = []
347+
for _ in range(ITER):
348+
# Greenlet starts, switches back to us.
349+
# We keep a strong reference to the greenlet though so it doesn't
350+
# get a GreenletExit exception.
351+
g = greenlet.greenlet(f)
352+
glets.append(g)
353+
g.switch()
354+
355+
return glets
356+
357+
test = self
358+
359+
class ThreadFunc:
360+
uss_before = uss_after = 0
361+
glets = ()
362+
ITER = 2
363+
def __call__(self):
364+
self.uss_before = psutil.Process().memory_full_info().uss
365+
366+
for _ in range(self.ITER):
367+
self.glets += tuple(run_it())
368+
369+
for g in self.glets:
370+
test.assertIn('suspended active', str(g))
371+
# Drop them.
372+
if deallocate_in_thread:
373+
self.glets = ()
374+
self.uss_after = psutil.Process().memory_full_info().uss
375+
376+
# Establish baseline
377+
for count in range(15):
378+
EXIT_COUNT[0] = 0
379+
thread_func = ThreadFunc()
380+
t = threading.Thread(target=thread_func)
381+
t.start()
382+
t.join(30)
383+
self.assertFalse(t.is_alive())
384+
385+
uss_before = thread_func.uss_before
386+
if deallocate_in_thread:
387+
self.assertEqual(thread_func.glets, ())
388+
self.assertEqual(EXIT_COUNT[0], ITER * thread_func.ITER)
389+
390+
del thread_func # Deallocate the greenlets; but this won't raise into them
391+
del t
392+
if not deallocate_in_thread:
393+
self.assertEqual(EXIT_COUNT[0], 0)
394+
if deallocate_in_thread:
395+
self.wait_for_pending_cleanups()
396+
397+
uss_after = psutil.Process().memory_full_info().uss
398+
# See if we achieve a non-growth state at some point. Break when we do.
399+
if uss_after <= uss_before and count > 1:
400+
break
401+
402+
self.wait_for_pending_cleanups()
403+
uss_after = psutil.Process().memory_full_info().uss
404+
self.assertLessEqual(uss_after, uss_before, "after attempts %d" % (count,))
405+
406+
@ignores_leakcheck
407+
# Because we're just trying to track raw memory, not objects, and running
408+
# the leakcheck makes an already slow test slower.
409+
def test_untracked_memory_doesnt_increase_unfinished_thread_dealloc_in_thread(self):
410+
self._check_untracked_memory_thread(deallocate_in_thread=True)
411+
412+
@ignores_leakcheck
413+
# Because the main greenlets from the background threads do not exit in a timely fashion,
414+
# we fail the object-based leakchecks.
415+
def test_untracked_memory_doesnt_increase_unfinished_thread_dealloc_in_main(self):
416+
self._check_untracked_memory_thread(deallocate_in_thread=False)
417+
418+
if __name__ == '__main__':
419+
__import__('unittest').main()

0 commit comments

Comments
 (0)