Running a long Python calculation in a thread, with logging to a Qt window, crashes after a short while
Asked Answered
H

1

0

I have a large module from a separate project, which I wanted to integrate into a GUI. The module does some calculations that take a couple of minutes, and I want to keep the GUI responsive during this time, and preferrably be able to cancel the process at any time.

The best solution would probably be to rewrite the module using signals and threading, but I wanted to try to do it without that to start. So my idea was to run myLongFunction in a separate thread.

In the GUI I have made a text box (a QPlainTextEdit) where I want to display messages via the logging facilities of Python. I also have a "Start" button.

The program seems to work as intended for a little while, but it usually crashes within 10 seconds. Sometimes it crashes right away, sometimes it takes a bit longer. And I get no exceptions or other errors, I'm just returned to the terminal prompt. A minimal example is below.

import sys
import time
import logging
from PySide2 import QtWidgets, QtCore
import numpy as np


def longFunction():
    logging.info("Start long running function")
    i = 0
    while True:
        for j in range(10000):
            t = np.arange(256)
            sp = np.fft.fft(np.sin(t))
            freq = np.fft.fftfreq(t.shape[-1])
            sp = sp + freq
        logging.info("%d" % i)
        i += 1

        # I added a sleep here, but it doesn't seem to help
        time.sleep(0.001)


# since I don't really need an event thread, I subclass QThread, as per
# https://woboq.com/blog/qthread-you-were-not-doing-so-wrong.html
class Worker(QtCore.QThread):
    def __init__(self, parent=None):
        super().__init__(parent)

    def run(self):
        longFunction()


# custom logging handler
class QTextEditLogger(logging.Handler):
    def __init__(self, parent=None):
        super().__init__()
        self.widget = QtWidgets.QPlainTextEdit(parent)
        self.widget.setReadOnly(True)

    def emit(self, record):
        msg = self.format(record)
        self.widget.appendPlainText(msg)
        self.widget.centerCursor()  # scroll to the bottom


class MyWidget(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

        logTextBox = QTextEditLogger(self)

        # format what is printed to text box
        logTextBox.setFormatter(
            logging.Formatter('%(asctime)s - %(levelname)s - %(threadName)s - %(message)s'))
        logging.getLogger().addHandler(logTextBox)

        # set the logging level
        logging.getLogger().setLevel(logging.DEBUG)

        self.resize(400, 500)

        # start button
        self.startButton = QtWidgets.QPushButton(self)
        self.startButton.setText('Start')

        # connect start button
        self.startButton.clicked.connect(self.start)

        # set up layout
        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(logTextBox.widget)
        layout.addWidget(self.startButton)
        self.setLayout(layout)

    def start(self):
        logging.info('Start button pressed')

        self.thread = Worker()

        # regardless of whether the thread finishes or the user terminates it
        # we want to show the notification to the user that it is done
        # and regardless of whether it was terminated or finished by itself
        # the finished signal will go off. So we don't need to catch the
        # terminated one specifically, but we could if we wanted.
        self.thread.finished.connect(self.threadFinished)  # new-style signal

        self.thread.start()

        # we don't want to enable user to start another thread while this one
        # is running so we disable the start button.
        self.startButton.setEnabled(False)

    def threadFinished(self):
        logging.info('Thread finished!')
        self.startButton.setEnabled(True)


app = QtWidgets.QApplication(sys.argv)
w = MyWidget()
w.show()
app.exec_()

The strangest thing is that if I remove the text box (comment out line 51-56 and line 72), the program runs just fine (I stopped it manually after 5 minutes).

Any idea what could cause this?

Helsinki answered 24/9, 2018 at 12:28 Comment(3)
I'm not that familiar with certain aspects of your code but, at first glance, I'd say you're trying to update the QTextEditLogger which lives on the main GUI thread directly from your secondary thread (i.e. the Worker). That's not supported. Your initial idea of using signals/slots with queued connections is the way to go.Multiply
I haven't thought of that. I'd still rather not touch the other module I'm using though. Perhaps there is a way to catch the logger signals in the Worker thread, and pass them on to the main thread?Helsinki
I posted an answer below trying to avoid the issues you pointed out. Not sure if it's the correct way, but at least it doesn't crash now.Helsinki
H
3

Following the hint by G.M., I have made a version that I think obeys the rules of Qt. I made a ThreadLogger(logging.Handler) class, which I set to handle all logs in the Worker thread, and send them to the main thread via slots and signals.

I kept getting the error TypeError: emit() takes 2 positional arguments but 3 were given when I inherited QtCore.QObject (and logging.Handler) in ThreadLogger, which I suspect was because I was overriding QtCore.Signal.emit() So I put the Signal in a separate MyLog(QObject) class, and just use an instance of that in ThreadLogger

class MyLog(QtCore.QObject):
    # create a new Signal
    # - have to be a static element
    # - class  has to inherit from QObject to be able to emit signals
    signal = QtCore.Signal(str)

    # not sure if it's necessary to implement this
    def __init__(self):
        super().__init__()

And here is the ThreadLogger(logging.Handler) class. This just emits all logs via signal in MyLog above

# custom logging handler that can run in separate thread, and emit all logs
# via signals/slots so they can be used to update the GUI in the main thread
class ThreadLogger(logging.Handler):
    def __init__(self):
        super().__init__()
        self.log = MyLog()

    # logging.Handler.emit() is intended to be implemented by subclasses
    def emit(self, record):
        msg = self.format(record)
        self.log.signal.emit(msg)

The complete code is

import sys
import logging
import numpy as np
from PySide2 import QtWidgets, QtCore


def longFunction(logger):
    logger.info("Start long running function")
    i = 0
    while True:
        for j in range(10000):
            t = np.arange(256)
            sp = np.fft.fft(np.sin(t))
            freq = np.fft.fftfreq(t.shape[-1])
            sp = sp + freq
        logger.info("%d" % i)
        i += 1


# since I don't really need an event thread, I subclass QThread, as per
# https://woboq.com/blog/qthread-you-were-not-doing-so-wrong.html
class Worker(QtCore.QThread):
    def __init__(self, parent=None):
        super().__init__(parent)

        ## set up logging
        # __init__ is run in the thread that creates this thread, not in the
        # new thread. But logging is thread-safe, so I don't think it matters

        # create logger for this class
        self.logger = logging.getLogger("Worker")

        # set up log handler
        self.logHandler = ThreadLogger()
        self.logHandler.setFormatter(
            logging.Formatter('%(asctime)s - %(levelname)s - %(threadName)s - %(message)s'))
        self.logger.addHandler(self.logHandler)

        # set the logging level
        self.logger.setLevel(logging.DEBUG)

    def run(self):
        longFunction(self.logger)


class MyLog(QtCore.QObject):
    # create a new Signal
    # - have to be a static element
    # - class  has to inherit from QObject to be able to emit signals
    signal = QtCore.Signal(str)

    # not sure if it's necessary to implement this
    def __init__(self):
        super().__init__()


# custom logging handler that can run in separate thread, and emit all logs
# via signals/slots so they can be used to update the GUI in the main thread
class ThreadLogger(logging.Handler):
    def __init__(self):
        super().__init__()
        self.log = MyLog()

    # logging.Handler.emit() is intended to be implemented by subclasses
    def emit(self, record):
        msg = self.format(record)
        self.log.signal.emit(msg)


class MyWidget(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

        # read-only text box
        self.logTextBox = QtWidgets.QPlainTextEdit(self)
        self.logTextBox.setReadOnly(True)

        self.resize(400, 500)

        # start button
        self.startButton = QtWidgets.QPushButton(self)
        self.startButton.setText('Start')

        # connect start button
        self.startButton.clicked.connect(self.start)

        # set up layout
        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(self.logTextBox)
        layout.addWidget(self.startButton)
        self.setLayout(layout)

    def start(self):
        self.thread = Worker()
        self.thread.finished.connect(self.threadFinished)
        self.thread.start()

        # we don't want to enable user to start another thread while this one
        # is running so we disable the start button.
        self.startButton.setEnabled(False)

        # connect logger
        self.thread.logHandler.log.signal.connect(self.write_log)

    def threadFinished(self):
        self.startButton.setEnabled(True)

    # define a new Slot, that receives a string
    @QtCore.Slot(str)
    def write_log(self, log_text):
        self.logTextBox.appendPlainText(log_text)
        self.logTextBox.centerCursor()  # scroll to the bottom


app = QtWidgets.QApplication(sys.argv)
w = MyWidget()
w.show()
app.exec_()

I am not quite sure why yet, but I get all logs from longFunction in the terminal as well as in the GUI window (but with different formats). I am also not 100% if this is actually thread-safe, and obeys all Qt threading rules, but at least it doesn't crash in the same way as before.

I will leave this answer up for a couple of days, and accept it then if I don't get any better answers, or it turns out my solution is wrong!

Helsinki answered 25/9, 2018 at 7:21 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.