Skip to content

Commit e5bd00f

Browse files
committed
task: Implement garbage collector support for PythonTask
This adds persistent wrapper support (introduced by the previous commit) to PythonTask, which makes it possible for reference cycles involving tasks to be found and destroyed. The major caveat is that it always creates a reference cycle. This can be broken automatically if there is no more Python reference to it by the time the last C++ reference is dropped, but the other way around requires the garbage collector. For tasks, I think this it is generally the case that the last reference is in C++, since tasks are usually created and then handed off to the C++ task manager, and for applications that don't want to rely on the GC, it is easy to work around. If this turns out to be a problem, though, we can add a special garbage collection pass to the task manager.
1 parent 38692dd commit e5bd00f

File tree

3 files changed

+101
-6
lines changed

3 files changed

+101
-6
lines changed

panda/src/event/pythonTask.cxx

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,17 @@ PythonTask::
9797
_exc_traceback = nullptr;
9898
}
9999

100+
PyObject *self = __self__;
101+
if (self != nullptr) {
102+
PyObject_GC_UnTrack(self);
103+
__self__ = nullptr;
104+
Py_DECREF(self);
105+
}
106+
107+
// All of these may have already been cleared by __clear__.
100108
Py_XDECREF(_function);
101-
Py_DECREF(_args);
102-
Py_DECREF(__dict__);
109+
Py_XDECREF(_args);
110+
Py_XDECREF(__dict__);
103111
Py_XDECREF(_exception);
104112
Py_XDECREF(_exc_value);
105113
Py_XDECREF(_exc_traceback);
@@ -364,14 +372,13 @@ __getattribute__(PyObject *self, PyObject *attr) const {
364372
*/
365373
int PythonTask::
366374
__traverse__(visitproc visit, void *arg) {
367-
/*
375+
Py_VISIT(__self__);
368376
Py_VISIT(_function);
369377
Py_VISIT(_args);
370378
Py_VISIT(_upon_death);
371379
Py_VISIT(_owner);
372380
Py_VISIT(__dict__);
373381
Py_VISIT(_generator);
374-
*/
375382
return 0;
376383
}
377384

@@ -380,17 +387,58 @@ __traverse__(visitproc visit, void *arg) {
380387
*/
381388
int PythonTask::
382389
__clear__() {
383-
/*
384390
Py_CLEAR(_function);
385391
Py_CLEAR(_args);
386392
Py_CLEAR(_upon_death);
387393
Py_CLEAR(_owner);
388394
Py_CLEAR(__dict__);
389395
Py_CLEAR(_generator);
390-
*/
396+
397+
Py_CLEAR(__self__);
391398
return 0;
392399
}
393400

401+
/**
402+
*
403+
*/
404+
bool PythonTask::
405+
unref() const {
406+
if (!AsyncTask::unref()) {
407+
// It was cleaned up by the Python garbage collector.
408+
return false;
409+
}
410+
411+
// If the last reference to the object is the one being held by Python,
412+
// check whether the Python wrapper itself is also at a refcount of 1.
413+
bool result = true;
414+
if (get_ref_count() == 1) {
415+
PyGILState_STATE gstate = PyGILState_Ensure();
416+
417+
// Check whether we have a Python wrapper. This is not the case if the
418+
// object has been created by C++ and never been exposed to Python code.
419+
PyObject *self = __self__;
420+
if (self != nullptr) {
421+
int ref_count = Py_REFCNT(self);
422+
assert(ref_count > 0);
423+
if (ref_count == 1) {
424+
// The last reference to the Python wrapper is being held by us.
425+
// Break the reference cycle and allow the object to go away.
426+
if (!AsyncTask::unref()) {
427+
PyObject_GC_UnTrack(self);
428+
((Dtool_PyInstDef *)self)->_memory_rules = false;
429+
__self__ = nullptr;
430+
Py_DECREF(self);
431+
432+
// Let the caller destroy the object.
433+
result = false;
434+
}
435+
}
436+
}
437+
PyGILState_Release(gstate);
438+
}
439+
return result;
440+
}
441+
394442
/**
395443
* Cancels this task. This is equivalent to remove(), except for coroutines,
396444
* for which it will throw an exception into any currently pending await.

panda/src/event/pythonTask.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ class PythonTask final : public AsyncTask {
8989
// custom variables may be stored.
9090
PyObject *__dict__;
9191

92+
public:
93+
// This holds a reference to the Python wrapper corresponding to this
94+
// PythonTask object. It is necessary for this to remain consistent for
95+
// the __traverse__ method to work correctly.
96+
mutable PyObject *__self__ = nullptr;
97+
98+
virtual bool unref() const;
99+
92100
protected:
93101
virtual bool cancel();
94102

tests/event/test_pythontask.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
from panda3d.core import PythonTask
2+
from contextlib import contextmanager
23
import pytest
34
import types
45
import sys
6+
import gc
7+
8+
9+
@contextmanager
10+
def gc_disabled():
11+
gc.disable()
12+
gc.collect()
13+
gc.freeze()
14+
gc.set_debug(gc.DEBUG_SAVEALL)
15+
16+
try:
17+
yield
18+
finally:
19+
gc.set_debug(0)
20+
gc.garbage.clear()
21+
gc.unfreeze()
22+
gc.collect()
23+
gc.enable()
524

625

726
def test_pythontask_property_builtin():
@@ -76,3 +95,23 @@ def test_pythontask_dict_set():
7695
rc2 = sys.getrefcount(d)
7796

7897
assert rc1 == rc2
98+
99+
100+
def test_pythontask_cycle():
101+
with gc_disabled():
102+
task = PythonTask()
103+
assert gc.is_tracked(task)
104+
task.marker = 'test_pythontask_cycle'
105+
task.prop = task
106+
107+
del task
108+
109+
gc.collect()
110+
assert len(gc.garbage) > 0
111+
112+
for g in gc.garbage:
113+
if isinstance(g, PythonTask) and \
114+
getattr(g, 'marker', None) == 'test_pythontask_cycle':
115+
break
116+
else:
117+
pytest.fail('not found in garbage')

0 commit comments

Comments
 (0)