QRunnable with setAutoDelete(False) leads to memory leak in PySide6

68 views Asked by At

We have a Task class, inherited from QRunnable to run it's code in a separate thread. When task.setAutoDelete(True) (which is the default value), then everything works well: task is deleted after run() is finished. But we need to keep the task in memory some time to display it's status: so we need to keep a reference to it. So we set task.setAutoDelete(False): then QThreadPool will not delete it. And we expect, that task will be deleted (by garbage collector), when all our references to it are gone. But the task is not deleted even when gc.collect() is called, and even when program is closed.

  1. Is it a memory leak, when task.setAutoDelete(False)?
  2. When task.setAutoDelete(True): should the QThreadPool delete C++ object of QRunnable or python wrapper of QRunnable?

The code to reproduce the problem (it does not keep the reference on task to be more concise):

import gc
import sys
import time

from PySide6.QtCore import QRunnable, QThreadPool
from PySide6.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout


class Task(QRunnable):
    def __init__(self):
        super().__init__()

    def __del__(self):
        print('Task.__del__')

    def run(self):
        print('Task.run')
        time.sleep(3)  # other long working function was tested too with no difference
        print(f'Task.run finished')


def run_task():
    task = Task()
    task.setAutoDelete(False)  # without this line the task object is deleted
    QThreadPool().globalInstance().start(task)


def collect_by_gc():
    collected_count = gc.collect()
    print(f'collected by GC: {collected_count}')


def main():
    app = QApplication(sys.argv)

    run_task_push_button = QPushButton('Run Task')
    run_task_push_button.clicked.connect(run_task)

    collect_by_gc_push_button = QPushButton('Collect by GC')
    collect_by_gc_push_button.clicked.connect(collect_by_gc)

    layout = QVBoxLayout()
    layout.addWidget(run_task_push_button)
    layout.addWidget(collect_by_gc_push_button)

    window = QWidget()
    window.setWindowTitle('Test App')
    window.setLayout(layout)
    window.show()

    app.exec()


if __name__ == '__main__':
    main()

If Run Task button is pressed, the output is:

Task.run
Task.run finished

Process finished with exit code 0

(the destructor of task was not called. And even is not called, when Collect by GC button is pressed. We expected it to be called.)

But when task.setAutoDelete(True) then everything works well and destructor is called. The output is:

Task.run
Task.run finished
Task.__del__

Process finished with exit code 0

Improved code example to keep the reference on task:

import gc
import sys
import time

from PySide6.QtCore import QRunnable, QThreadPool, QObject, Signal
from PySide6.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout

# from PyQt6.QtCore import QRunnable, QThreadPool, pyqtSignal, QObject, Qt
# from PyQt6.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout


class TaskSignals(QObject):
    # finished = pyqtSignal()
    finished = Signal()


class Task(QRunnable):
    def __init__(self):
        super().__init__()

        self.signals = TaskSignals()

    def __del__(self):
        print('Task.__del__')

    def run(self):
        print('Task.run')
        time.sleep(2)  # other long working function was tested too with no difference
        print(f'Task.run finished')
        self.signals.finished.emit()


class MainWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)

        self._run_task_button = QPushButton('Run Task')
        self._run_task_button.clicked.connect(self._run_task)

        collect_by_gc_button = QPushButton('Collect by GC')
        collect_by_gc_button.clicked.connect(self._collect_by_gc)

        layout = QVBoxLayout()
        layout.addWidget(self._run_task_button)
        layout.addWidget(collect_by_gc_button)

        self.setWindowTitle('Test App')
        self.setLayout(layout)

        self._task = None

    def _run_task(self):
        self._run_task_button.setDisabled(True)

        self._task = Task()
        self._task.setAutoDelete(False)  # without this line the task object is deleted
        self._task.signals.finished.connect(self._on_task_finished)
        QThreadPool().globalInstance().start(self._task)

    @staticmethod
    def _collect_by_gc():
        collected_count = gc.collect()
        print(f'collected by GC: {collected_count}')

    def _on_task_finished(self):
        print(f'MainWidget._on_task_finished')
        self._task.signals.finished.disconnect(self._on_task_finished)
        self._task = None
        self._run_task_button.setEnabled(True)


def main():
    app = QApplication(sys.argv)

    window = MainWidget()
    window.show()

    app.exec()


if __name__ == '__main__':
    main()

That improved example dumps expected output with PyQt 6.6.1 (when autoDelete is any (False or True)):

Task.run
Task.run finished
MainWidget._on_task_finished
Task.__del__

Process finished with exit code 0

But with PySide 6.6.2 it dumps the same expected output only if autoDelete is True. When autoDelete is False the __del__ is not dumped.

Tested on Windows 10 (x64), Python 3.10.12, PySide 6.6.2 and PySide 6.5.2. Thanks!

0

There are 0 answers