Skip to content

Commit aa6f251

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 aa6f251

File tree

7 files changed

+268
-8
lines changed

7 files changed

+268
-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()

0 commit comments

Comments
 (0)