プログラムを中心とした個人的なメモ用のブログです。 タイトルは迷走中。
内容の保証はできませんのであしからずご了承ください。

2022/02/20

[PySide] QThread を使って時間のかかる処理をスレッド化する

event_note2022/02/19 19:32

例えばボタンをクリックしたときに重い処理を実行する場合など、そのまま処理すると GUI が固まってしまうため、スレッド化してやります。

概要

python の標準モジュールに threading がありますが、PySide を使っている場合は QThread を使います。
本質的にはどちらも同じだそうですが、Signal/Slot など、Qt との対話を行うなら QThread を使ったほうが良いようです。

ちなみに、Qt には QtConcurrent という QThread よりも高レベルな API がありますが、こちらは PySide では提供されていないようです。

QThread のドキュメントは以下です。

また、PySide で GUI を止めることなく重い処理をするサンプルとして、以下がありました。

上記のサンプルでは QThread を継承し、run をオーバーライドしていますが、このやり方は良くないそうで、moveToThread を使って処理をスレッドに渡すやり方のほうが一般的なようです。
詳しくは以下で解説されています。

とはいえ、moveToThread を使った公式のサンプルが見当たらなかったので、知らない人は知らないんじゃないかなぁと思ったり・・。

というわけで、QThreadmoveToThread 使ったスレッド化のサンプルです。

環境

  • Python 3.8.10
  • PySide 6.2.2.1

サンプルコード

解説は後にして、まずはコード全体です。

import sys, threading
from PySide6.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QVBoxLayout
from PySide6.QtCore import Qt, QObject, QThread, Signal, Slot
import shiboken6
import time

class Worker(QObject):
    """バックグラウンドで処理を行うクラス
    """

    countup = Signal(int)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.__is_canceled = False

    def run(self):
        print(f'worker started, thread_id={threading.get_ident()}')
        count = 0
        while not self.__is_canceled:
            count += 1
            self.countup.emit(count)
            time.sleep(0.001)
        print(f'worker finished, thread_id={threading.get_ident()}')

    def stop(self):
        self.__is_canceled = True

class MainWindow(QWidget):
    """メインウィンドウ
    """

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

        self.__thread = None

        layout = QVBoxLayout()
        button = QPushButton('Start')
        button.clicked.connect(self.__start)
        layout.addWidget(button)
        button = QPushButton('Stop')
        button.clicked.connect(self.__stop)
        layout.addWidget(button)
        self.__label = QLabel()
        layout.addWidget(self.__label)
        self.setLayout(layout)

    def __start(self):
        """開始
        """
        print(f'start, thread_id={threading.get_ident()}')
        self.__stop()

        self.__thread = QThread()
        self.__worker = Worker()
        self.__worker.moveToThread(self.__thread) # 別スレッドで処理を実行する
        # シグナルスロットの接続(self.__countup をスレッド側で実行させるために Qt.DirectConnection を指定)
        self.__worker.countup.connect(self.__countup, type=Qt.DirectConnection)
        # スレッドが開始されたら worker の処理を開始する
        self.__thread.started.connect(self.__worker.run)
        # ラムダ式を使う場合は Qt.DirectConnection を指定
        #self.__thread.started.connect(lambda: self.__worker.run(), type=Qt.DirectConnection) 
        # スレッドが終了したら破棄する
        self.__thread.finished.connect(self.__worker.deleteLater)
        self.__thread.finished.connect(self.__thread.deleteLater)

        # 処理開始
        self.__thread.start()

    def __stop(self):
        """停止
        """
        print(f'stop, thread_id={threading.get_ident()}')
        if self.__thread and shiboken6.isValid(self.__thread):
            # スレッドが作成されていて、削除されていない
            if self.__thread.isRunning() or not self.__thread.isFinished():
                print('thread is stopping')
                self.__worker.stop()
                self.__thread.quit()
                self.__thread.wait()
                print('thread is stopped')

    def __countup(self, count):
        """countup シグナルに対する処理
        """
        self.__label.setText(f'count={count}, thread_id={threading.get_ident()}')

    def closeEvent(self, event):
        """closeEvent のオーバーライド(ウィンドウを閉じたときにスレッドを終了させる)
        """
        self.__stop()

if __name__ == "__main__":
    app = QApplication(sys.argv)
    main_window = MainWindow()
    main_window.show()
    sys.exit(app.exec())

カウントアップした変数の内容を表示するだけのシンプルなコードです。
スロットがどのスレッドで実行されているかわかるように、スレッドIDも表示しています。

簡単な解説

Worker が別スレッドで行いたい処理ですが、こいつ自身はスレッド云々を意識せず(どこで実行されるかを意識せず)、処理内容のみを責務としています。
どこで実行するかを意識するのは、処理を開始する MainWindow です。

シグナルスロット接続時の注意

以下の部分です。

self.__worker.countup.connect(self.__countup, type=Qt.DirectConnection)

self.__countupMainWindow 内の関数なので、普通に接続するとメインスレッド側で処理が行われてしまいます。
これをスレッド側で処理が行われるようにするには Qt.DirectConnection を指定する必要があります。
countup シグナルはスレッド側で動いている Worker で emit されるので、Qt.DirectConnection を指定することでシグナルを emit するスレッド側での実行を指定することになります。
ここらへん、以下で詳しく解説されています。

ちなみに、以下のスレッド開始時の処理ですが、

self.__thread.started.connect(self.__worker.run)

self.__workermoveToThread でスレッド側で処理を行うように指定しているので、 明示的に Qt.DirectConnection を指定しなくてもよいのですが、これを仮に以下のようにラムダ式などを使った場合は、上記と同じ理由で Qt.DirectConnection を指定する必要があります。

self.__thread.started.connect(lambda: self.__worker.run(), type=Qt.DirectConnection)  

例えば、self.__worker.run に引数を与えて開始したい場合などはこういうケースもあるのではないかと思います。
でもその場合 moveToThread を使う意味がなくなりそうですが・・・。(実際、moveToThread を使わなくても上手く動きます。)

終了処理

スレッドが終了したら deleteLater でオブジェクトの破棄を行っています。

self.__thread.finished.connect(self.__worker.deleteLater)
self.__thread.finished.connect(self.__thread.deleteLater)

また、スレッドを開始したままウィンドウを閉じたときのために、closeEvent をオーバーライドしてスレッドを停止するようにしています。

オブジェクトの判定

Start ボタンを押したときにスレッドを停止してから再度開始していますが、その際に既にスレッドが破棄されていないかどうか判定しています。
オブジェクトが破棄されているかどうかは shiboken を使います。

スレッドの終了判定

QThreadisRunningisFinished という関数がありますが、どっちを使えばいいのかいまいち分からなかったので両方見るようにしました。
ここらへん微妙です・・・。