Запуск длинного вычисления Python в потоке с записью в окно Qt через некоторое время приводит к сбою

У меня есть большой модуль из отдельного проекта, который я хотел интегрировать в графический интерфейс. Модуль выполняет некоторые вычисления, которые занимают пару минут, и я хочу, чтобы графический интерфейс оставался отзывчивым в течение этого времени, и желательно иметь возможность отменить процесс в любое время.

Лучшим решением, вероятно, было бы переписать модуль с использованием сигналов и многопоточности, но я хотел попробовать сделать это без этого для начала. Итак, моя идея состояла в том, чтобы запустить myLongFunction в отдельном потоке.

В графическом интерфейсе я сделал текстовое поле (QPlainTextEdit), в котором я хочу отображать сообщения с помощью средств ведения журнала Python. У меня тоже есть кнопка "Старт".

Программа, кажется, некоторое время работает по назначению, но обычно происходит сбой в течение 10 секунд. Иногда вылетает сразу, иногда чуть дольше. И я не получаю никаких исключений или других ошибок, я просто возвращаюсь в командную строку терминала. Минимальный пример ниже.

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_()

Самое странное, что если я уберу текстовое поле (закомментируйте строки 51-56 и строку 72), программа запустится просто отлично (я остановил ее вручную через 5 минут).

Любая идея, что может вызвать это?


person Filip S.    schedule 24.09.2018    source источник
comment
Я не очень знаком с некоторыми аспектами вашего кода, но на первый взгляд я бы сказал, что вы пытаетесь обновить QTextEditLogger, который находится в основном потоке графического интерфейса, непосредственно из вашего вторичного потока (то есть Worker). Это не поддерживается. Ваша первоначальная идея использования сигналов/слотов с соединениями в очереди - это правильный путь.   -  person G.M.    schedule 24.09.2018
comment
Я не думал об этом. Я бы все же предпочел не трогать другой модуль, который я использую. Возможно, есть способ поймать сигналы регистратора в рабочем потоке и передать их в основной поток?   -  person Filip S.    schedule 24.09.2018
comment
Я разместил ответ ниже, пытаясь избежать проблем, на которые вы указали. Не уверен, что это правильный путь, но, по крайней мере, теперь он не падает.   -  person Filip S.    schedule 25.09.2018


Ответы (1)


Следуя подсказке G.M., я сделал версию, которая, как мне кажется, соответствует правилам Qt. Я создал класс ThreadLogger(logging.Handler), который я настроил для обработки всех журналов в потоке Worker и отправки их в основной поток через слоты и сигналы.

Я продолжал получать ошибку TypeError: emit() takes 2 positional arguments but 3 were given, когда унаследовал QtCore.QObjectlogging.Handler) в ThreadLogger, что, как я подозреваю, было связано с тем, что я переопределял QtCore.Signal.emit(). Поэтому я поместил Signal в отдельный класс MyLog(QObject) и просто использовал экземпляр этого в 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__()

А вот и ThreadLogger(logging.Handler) класс. Это просто отправляет все журналы через signal в MyLog выше.

# 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)

Полный код

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_()

Я пока не совсем уверен, почему, но я получаю все журналы от longFunction в терминале, а также в окне графического интерфейса (но в разных форматах). Я также не на 100% уверен, что это на самом деле потокобезопасно и подчиняется всем правилам многопоточности Qt, но, по крайней мере, оно не падает так же, как раньше.

Я оставлю этот ответ на пару дней, а затем приму его, если не получу лучших ответов или выяснится, что мое решение неверно!

person Filip S.    schedule 25.09.2018