PyQtで重い処理をする時に使うべきマルチスレッドとプログレスバーの実装

この記事ではPyQtにおいて**マルチスレッド**を使う方法について説明します。これはPyQtで重くて時間かかる処理を行いたい時にかなり重要です。ついでに、関連があるので**プログレスバー**の作り方も説明します。

ここでPyQt6を使いますが、PyQt5も基本的にあまり違いがありません。PyQt6の基本とPyQt5との微妙な違いは以前書いた記事を参考に。

https://qiita.com/phyblas/items/d56003904c83938823f2


# 本来のやり方の問題点

本来の方法で何がいけないか説明するために、まずはマルチスレッドなしの単純な方法の実装をしてみます。

例えば、PyQtでボタン押したらある重い処理を行うとします。ここでは例として「素数検出」にします。本題とは関係ないのでアルゴリズムについては割愛します。

ではボタンを押したら素数が検出されて数が表示される簡単なGUIを作ってみます。
```python
import sys
from PyQt6.QtWidgets import QApplication,QWidget,QVBoxLayout,QPushButton,QLabel

class Qmado(QWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet('font-size: 23px; font-family: Kaiti SC;')
        
        self.vbl = QVBoxLayout()
        self.setLayout(self.vbl)
        
        self.botan = QPushButton('開始') # 実行開始のボタン
        self.vbl.addWidget(self.botan)
        self.botan.clicked.connect(self.omoi_shori)
        
        self.label = QLabel() # 結果は後でここに表示する
        self.vbl.addWidget(self.label)
    
    def omoi_shori(self):
        # 一旦ボタンを押せないようにする
        self.botan.setEnabled(False)
        self.botan.setText('処理中')
        # 2から1000000までの素数を全部検出する
        self.lis_sosuu = []
        for i in range(2,1000000+1):
            for j in range(2,int(i**0.5)+1):
                if(i%j==0):
                    break
            else:
                self.lis_sosuu.append(i)
        # 検出した素数の数を表示する
        self.label.setText(f'1000000までの素数の数は{len(self.lis_sosuu)}')
        # ボタンを復活
        self.botan.setEnabled(True)
        self.botan.setText('開始')
        
qAp = QApplication(sys.argv)
qmado = Qmado()
qmado.show()
qAp.exec()
```

これを実行したらボタンを持つウィンドウが現れます。

![截屏2025-01-13-16.46.18.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/1d3c127d-cb4d-07ba-678d-c375246e8346.jpeg)

そしてボタンを押したら……、なんかウィンドウが止まってコントロールできなくなって、2秒くらい（パソコンによって違いますが）経ったら漸く結果が現れます。

![截屏2025-01-13-16.47.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/12ee7e72-469b-c076-ae34-b051263b31b8.png)

このように、処理が行われている2秒くらいの間はGUIが固まって何もすることはできません。それだけでなく、`setEnabled`と`setText`の効果も見えません。本来なら処理中の間はボタンの表示を変えたはずなのに、今の結果はただ固まっただけ。つまり処理が始まる前の変化も処理が終わるまで反映されません。

このままではやはり思い通りの仕様にならなくて困るでしょう。それを解決するためにPyQtではマルチスレッドを使うという方法が一般的です。


# マルチスレッドの導入

では上述のコードをそのままマルチスレッドを使って書き換えます。書き方はかなり複雑で面倒に見えますが、後で説明します。
```python
import sys
from PyQt6.QtWidgets import QApplication,QWidget,QVBoxLayout,QPushButton,QLabel
from PyQt6.QtCore import QThread

# スレッドを継承するクラスを定義する
class QShori(QThread):
    def __init__(self,qmado):
        super().__init__()
        self.qmado = qmado
    
    # 実行したい重い処理をここで定義する
    def run(self):
        self.qmado.botan.setEnabled(False)
        self.qmado.botan.setText('処理中')
        self.qmado.lis_sosuu = []
        for i in range(2,1000000+1):
            for j in range(2,int(i**0.5)+1):
                if(i%j==0):
                    break
            else:
                self.qmado.lis_sosuu.append(i)
        self.qmado.label.setText(f'1000000までの素数の数は{len(self.qmado.lis_sosuu)}')
        self.qmado.botan.setEnabled(True)
        self.qmado.botan.setText('開始')

class Qmado(QWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet('font-size: 23px; font-family: Kaiti SC;')
        
        self.vbl = QVBoxLayout()
        self.setLayout(self.vbl)
        
        self.botan = QPushButton('開始') # 実行開始のボタン
        self.vbl.addWidget(self.botan)
        self.botan.clicked.connect(self.omoi_shori)
        
        self.label = QLabel()
        self.vbl.addWidget(self.label)
    
    def omoi_shori(self):
        # スレッドのオブジェクトを作成して実行する
        self.qshori = QShori(self)
        self.qshori.start()
        
qAp = QApplication(sys.argv)
qmado = Qmado()
qmado.show()
qAp.exec()
```

これで実行したら同じようにウィンドウとボタンが出てきますが、今回ボタンを押したらちゃんと「処理中」に変わります。そして処理終了後の結果は同じ。

![截屏2025-01-13-17.33.09.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/91cd4cf2-2c13-c6ee-33e8-8e83fe889369.jpeg)

ここでマルチスレッドを使うために`QThread`というクラスを継承したクラスのオブジェクトを使います。まずはそのクラスを定義して、その中で実行したい処理を`run`というメソッドとして定義するのです。そして`.start`メソッドを実行することで`run`で定義された内容は別のスレッドで実行されます。

別のスレッドで実行することでウィンドウが固まることはなくなります。


# スレッドでやってはいけないこと

こうやって重い処理は別のスレッドでやることができて順調に操作ができそうですが、実は気をつけるべきところがいくつかあります。

例えばスレッドでウェジェットを作ることはできません。

この例を見てみましょう。
```python
import sys
from PyQt6.QtWidgets import QApplication,QWidget,QVBoxLayout,QPushButton,QLabel
from PyQt6.QtCore import QThread

class QShori(QThread):
    def __init__(self,qmado):
        super().__init__()
        self.qmado = qmado
    
    def run(self):
        self.qmado.botan.setEnabled(False)
        self.qmado.botan.setText('処理中')
        self.qmado.lis_sosuu = []
        for i in range(2,1000000+1):
            for j in range(2,int(i**0.5)+1):
                if(i%j==0):
                    break
            else:
                self.qmado.lis_sosuu.append(i)
        # QLabelを作成する
        self.qmado.label = QLabel(f'1000000までの素数の数は{len(self.qmado.lis_sosuu)}')
        self.qmado.vbl.addWidget(self.qmado.label)
        self.qmado.botan.setEnabled(True)
        self.qmado.botan.setText('開始')

class Qmado(QWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet('font-size: 23px; font-family: Kaiti SC;')
        
        self.vbl = QVBoxLayout()
        self.setLayout(self.vbl)
        
        self.botan = QPushButton('開始')
        self.vbl.addWidget(self.botan)
        self.botan.clicked.connect(self.omoi_shori)
        
    def omoi_shori(self):
        self.qshori = QShori(self)
        self.qshori.start()

qAp = QApplication(sys.argv)
qmado = Qmado()
qmado.show()
qAp.exec()
```

先程の例とは殆ど同じですが、違いは今回予め`QLabel`を作成したのではく、その場で作成しようとすることです。

そして結果はやはり何も起きません。どうやら別のスレッドで作成されたウェジェットは表示されないようです。

だからできれば予めウェジェットを準備しておいた方が一番ですが、それができない場合も多いでしょう。

どうしても新しくウェジェットを作成する必要がある場合は方法がないわけがないのですが、更に複雑になります。


# シグナルによるやり取りを利用する

ウェジェットの作成ができないなど、スレッドで実行できることは限られているので、やはり普通の関数で実行するしかないでしょう。でもスレッドの中の処理の結果を待つ必要がある処理なら「もう実行していい」というシグナルが必要です。

シグナルというのは、例えばボタンをクリックするときに`clicked`というシグナルが放出されるのです。それと同じものです。そのようなシグナルを自分で作成することができます。

書き方はこのようになります。
```python
import sys
from PyQt6.QtWidgets import QApplication,QWidget,QVBoxLayout,QPushButton,QLabel
from PyQt6.QtCore import QThread,pyqtSignal

class QShori(QThread):
    shori_owari = pyqtSignal() # シグナルの定義
    
    def __init__(self,qmado):
        super().__init__()
        self.qmado = qmado
    
    def run(self):
        self.qmado.botan.setEnabled(False)
        self.qmado.botan.setText('処理中')
        self.qmado.lis_sosuu = []
        for i in range(2,1000000+1):
            for j in range(2,int(i**0.5)+1):
                if(i%j==0):
                    break
            else:
                self.qmado.lis_sosuu.append(i)
                
        self.shori_owari.emit() # シグナルを送る

class Qmado(QWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet('font-size: 23px; font-family: Kaiti SC;')
        
        self.vbl = QVBoxLayout()
        self.setLayout(self.vbl)
        
        self.botan = QPushButton('開始')
        self.vbl.addWidget(self.botan)
        self.botan.clicked.connect(self.omoi_shori)
        
    def omoi_shori(self):
        self.qshori = QShori(self)
        # シグナルを受けたら実行する関数を設定
        self.qshori.shori_owari.connect(self.label_settei)
        self.qshori.start()
    
    # ラベルを追加するなどの処理
    def label_settei(self):
        self.label = QLabel(f'1000000までの素数の数は{len(self.lis_sosuu)}')
        self.vbl.addWidget(self.label)
        self.botan.setEnabled(True)
        self.botan.setText('開始')
        
qAp = QApplication(sys.argv)
qmado = Qmado()
qmado.show()
qAp.exec()
```

このように書き換えることでラベルを追加することができます。因みに今回実行するたびにラベルが追加されるので、もう一度ボタンを押したら新しいラベルがまた現れます。

![截屏2025-01-13-18.09.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/548eda16-37c9-149e-648a-71654aacc678.png)

ここで処理が終わった後`shori_owari`というシグナルを放出するように書いてあります。そのシグナルに応じて実行する関数を定義したらそれが普通にメインのスレッドで実行されて、ウェジェットを追加するなど普通の操作ができます。


# 処理完了のシグナル

さっきの例ではわざわざ処理が終わった後のシグナルを作ったのですが、実際はそもそも`QThread`は実行が終わった後`finished`というシグナルを放出します。つまりこれを自分で作る必要がなく、これを使えばいいです。

このように書き換えても結果は同じです。
```python
import sys
from PyQt6.QtWidgets import QApplication,QWidget,QVBoxLayout,QPushButton,QLabel
from PyQt6.QtCore import QThread

class QShori(QThread):
    def __init__(self,qmado):
        super().__init__()
        self.qmado = qmado
    
    def run(self):
        self.qmado.botan.setEnabled(False)
        self.qmado.botan.setText('処理中')
        self.qmado.lis_sosuu = []
        for i in range(2,1000000+1):
            for j in range(2,int(i**0.5)+1):
                if(i%j==0):
                    break
            else:
                self.qmado.lis_sosuu.append(i)

class Qmado(QWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet('font-size: 23px; font-family: Kaiti SC;')
        
        self.vbl = QVBoxLayout()
        self.setLayout(self.vbl)
        
        self.botan = QPushButton('開始')
        self.vbl.addWidget(self.botan)
        self.botan.clicked.connect(self.omoi_shori)
        
    def omoi_shori(self):
        self.qshori = QShori(self)
        # 実行が終わってfinishedシグナルを受けたら処理する
        self.qshori.finished.connect(self.label_settei)
        self.qshori.start()
    
    def label_settei(self):
        self.label = QLabel(f'1000000までの素数の数は{len(self.lis_sosuu)}')
        self.vbl.addWidget(self.label)
        self.botan.setEnabled(True)
        self.botan.setText('開始')
        
qAp = QApplication(sys.argv)
qmado = Qmado()
qmado.show()
qAp.exec()
```

こうやってスレッドで実行する処理と、普通に実行する処理を使い分けます。


# プログレスバー

処理がどれくらい進んだかを示すためにプログレスバーはよく使われますね。PyQtには`QProgressBar`ウェジェットがあります。これもマルチスレッドを使うことで実装できるものです。

まずは`QProgressBar`ウェジェットを作成しておいて、処理が進む段階で更新していきます。ただし`QProgressBar`の更新は直接スレッドの`run`から実行できないため、シグナル送信を通じて行う必要があります。

実装例として、今までの素数探しのGUIにプログレスバーを追加してみましょう。
```python
import sys
from PyQt6.QtWidgets import QApplication,QWidget,QVBoxLayout,QPushButton,QLabel,QProgressBar
from PyQt6.QtCore import pyqtSignal,QThread

class QSosuuSagashi(QThread):
    hajimaru = pyqtSignal()
    susumu = pyqtSignal(int)
    
    def __init__(self,qmado):
        super().__init__()
        self.qmado = qmado
    
    def run(self):
        self.qmado.botan.setEnabled(False)
        self.hajimaru.emit()
        
        self.lis_sosuu = []
        for i in range(2,1000000+1):
            for j in range(2,int(i**0.5)+1):
                if(i%j==0):
                    break
            else:
                self.lis_sosuu.append(i)
            if(i%10000==0):
                self.susumu.emit(i)
        self.qmado.botan.setEnabled(True)
        self.qmado.label.setText(f'1000000までの素数の数は{len(self.lis_sosuu)}')
    
class Qmado(QWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet('font-size: 19px;')
        
        self.vbl = QVBoxLayout()
        self.setLayout(self.vbl)
        
        self.botan = QPushButton('素数探し')
        self.vbl.addWidget(self.botan)
        self.botan.setFixedWidth(300)
        self.botan.clicked.connect(self.sosuu_sagashi)
        
        self.pbar = QProgressBar(visible=False)
        self.pbar.setStyleSheet('''
            QProgressBar {border: 1px solid #822; border-radius: 5px; text-align: center;}
            QProgressBar::chunk {background-color: orange;}
        ''')
        self.pbar.setRange(0,1000000)
        self.pbar.setFormat('🐢 %p%')
        self.vbl.addWidget(self.pbar)
        
        self.label = QLabel()
        self.vbl.addWidget(self.label)
        
    def sosuu_sagashi(self):
        self.qshori = QSosuuSagashi(self)
        self.qshori.hajimaru.connect(lambda: self.pbar.setVisible(True))
        self.qshori.susumu.connect(self.pbar_koushin)
        self.qshori.start()
    
    def pbar_koushin(self,i):
        self.pbar.setValue(i)
        
qAp = QApplication(sys.argv)
qmado = Qmado()
qmado.show()
qAp.exec()
```

実行したらこのようなウィンドウ。

![截屏2025-01-13-21.06.31.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/06981334-e58a-4727-f132-b8aa7d568d17.jpeg)

ボタンを押したらこのようにプログレスバーが現れて、終わるまで走ります。

![截屏2025-01-13-21.07.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/693e99d4-a65e-11d5-649e-81ac65b84897.png)

![截屏2025-01-13-21.09.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/1617d061-e103-fa58-9a6b-63131e3c4a28.png)


# 処理を途中で止められるようにする

実行の途中で突然止めたい場合もありますよね。これもマルチスレッドを使っているからできることです。

プログレスバーとは直接関係ないのですが、プログレスバーと一緒に使うとわかりやすいので、ここでもプログレスバーを使う実装例にします。

```python
import sys
from PyQt6.QtWidgets import QApplication,QWidget,QVBoxLayout,QPushButton,QLabel,QProgressBar
from PyQt6.QtCore import pyqtSignal,QThread

class QSosuuSagashi(QThread):
    susumu = pyqtSignal(int)
    
    def __init__(self,qmado):
        super().__init__()
        self.qmado = qmado
        self.shinkou = False # 進行中かを示す変数
    
    def run(self):
        self.qmado.botan.setText('取り消し')
        self.shinkou = True # 進行中にする
        
        self.lis_sosuu = []
        for i in range(2,1000000+1):
            if(not self.shinkou):
                break # 進行中でなくなったらここでループ終わり
            for j in range(2,int(i**0.5)+1):
                if(i%j==0):
                    break
            else:
                self.lis_sosuu.append(i)
            if(i%10000==0):
                self.susumu.emit(i)
        self.qmado.botan.setText('素数探し')
        self.qmado.label.setText(f'{i}までの素数の数は{len(self.lis_sosuu)}')
        self.shinkou = False
    
class Qmado(QWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet('font-size: 19px;')
        
        self.vbl = QVBoxLayout()
        self.setLayout(self.vbl)
        
        self.botan = QPushButton('素数探し')
        self.vbl.addWidget(self.botan)
        self.botan.setFixedWidth(300)
        self.botan.clicked.connect(self.botan_osaretara)
        
        self.pbar = QProgressBar()
        self.pbar.setStyleSheet('''
            QProgressBar {border: 2px solid #292; border-radius: 9px;}
            QProgressBar::chunk {background-color: lightgreen;}
        ''')
        self.pbar.setRange(0,1000000)
        self.pbar.setFormat('🐯 %p%')
        self.vbl.addWidget(self.pbar)
        
        self.label = QLabel()
        self.vbl.addWidget(self.label)
        
        self.qshori = QSosuuSagashi(self)
        self.qshori.susumu.connect(self.pbar_koushin)
        
    def botan_osaretara(self):
        if(not self.qshori.shinkou):
            self.qshori.start()
        else: # 進行中でボタンを押したら止めることにする
            self.qshori.shinkou = False
            
    def pbar_koushin(self,i):
        self.pbar.setValue(i)
        
qAp = QApplication(sys.argv)
qmado = Qmado()
qmado.show()
qAp.exec()
```

そうしたら今回ボタンを押したら「取り消し」と表示されます。

![截屏2025-01-13-21.12.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/1485ea55-d798-8f12-f56c-b713d67a04bf.png)

これを押すことで処理が途中で終わりって、それまでだけの結果が表示されます。

![截屏2025-01-13-21.13.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/a723d242-db46-396e-83c3-a15652c0df77.png)

このように、進行するかを決めるための変数を入れて、ボタンでその変数を変えることで操作するのです。


# プログレスバーのウィンドウ

プログレスバーを個別のウィンドウで表示したい場合、`QProgressBar`よりも`QProgressDialog`を使った方が便利です。これはただプログレスバーだけでなく、取り消しのボタンもついていて、取り消しの実装も簡単にできます。

```python
import sys
from PyQt6.QtWidgets import QApplication,QWidget,QVBoxLayout,QPushButton,QLabel,QProgressDialog
from PyQt6.QtCore import pyqtSignal,QThread

class QSosuuSagashi(QThread):
    susumu = pyqtSignal(int)
    
    def __init__(self,qmado):
        super().__init__()
        self.qmado = qmado
    
    def run(self):
        self.qmado.botan.setEnabled(False)
        self.shinkou = True
        
        self.lis_sosuu = []
        for i in range(2,1000000+1):
            if(not self.shinkou):
                break
            for j in range(2,int(i**0.5)+1):
                if(i%j==0):
                    break
            else:
                self.lis_sosuu.append(i)
            if(i%10000==0):
                self.susumu.emit(i)
        self.qmado.botan.setEnabled(True)
        self.qmado.label.setText(f'{i}までの素数の数は{len(self.lis_sosuu)}')
    
class Qmado(QWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet('font-size: 19px;')
        
        self.vbl = QVBoxLayout()
        self.setLayout(self.vbl)
        
        self.botan = QPushButton('素数探し')
        self.vbl.addWidget(self.botan)
        self.botan.setFixedWidth(300)
        self.botan.clicked.connect(self.sosuu_sagashi)
        
        self.label = QLabel()
        self.vbl.addWidget(self.label)
        
    def sosuu_sagashi(self):
        self.qshori = QSosuuSagashi(self)
        self.qshori.susumu.connect(self.pbar_koushin)
        # プログレスバーウィンドウ作成
        self.pbar = QProgressDialog('実行中','中止',0,1000000,self)
        self.vbl.addWidget(self.pbar)
        # 中止ボタンが押された時に発動する関数を設定
        self.pbar.canceled.connect(self.torikeshi)
        self.qshori.start()
    
    def pbar_koushin(self,i):
        self.pbar.setValue(i)
    
    def torikeshi(self):
        self.qshori.shinkou = False
        
qAp = QApplication(sys.argv)
qmado = Qmado()
qmado.show()
qAp.exec()
```
このように、ボタンを押したらプログレスバーと中止のボタンが付いている新しいウィンドウが出てきます。

![截屏2025-01-13-21.42.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/631b131e-a340-b4ff-bc95-68f96d7a1e42.png)

`QProgressDialog`の引数は順番でこうです。
- 上に表示するテキスト
- ボタンのテキスト
- 開始の段数
- 終わりの段数
- 親ウェジェット

尚、中止ボタンを押したらプログレスバーのウィンドウは閉じられますが、処理は止まるかどうかは別の話です。処理を止めるためのコードを書く必要があります。

中止ボタンを押したら`canceled`シグナルが出てくるので、`connect`で取り消し処理の関数を指定すればいいです。


# 終わりに

以上マルチスレッドとプログレスバーの使い方の説明でした。

確かにかなり複雑に見えてわかりにくい気もしますが、重い処理をするGUIにはやはりかなり必要なので、使い熟せたら便利です。


# 参考

- [【PythonでGUI】PyQt5 -ウィジェット-](https://qiita.com/Nobu12/items/6248c509401b0e666a55)
- [PyQt5 マルチスレッド　２つのやり方　サブクラス式　moveToThread式](https://qiita.com/softbase/items/0b3e7d2de006efc7b16d)
- [Python + PyQt5でGUIアプリを作ってみた](https://qiita.com/softbase/items/0b3e7d2de006efc7b16d)
- [複数のPDFを統合したいときに作ったスクリプト](https://qiita.com/hamukun8686/items/1948e5420ed3cee28376)
- [Pythonで簡単なPyQt5-PyInstallerのアップデート機能を作る。](https://qiita.com/refiaa/items/9d93bceca17ec94ab46b)
- [PyQt5 QThreadで勘違いからメモリリークを起こしていた](https://qiita.com/v2okimochi/items/944504e81f8d75792efa)
- [[PyQt]QProgressBarで遊んでみた ](http://hareoff.blogspot.com/2013/03/pyqtqprogressbar.html)
- [PySide6: QProgressDialog と QThread ](https://bitwalk.blogspot.com/2022/01/pyside6-qprogressdialog-qthread.html)
- [PythonでPyQt5を使用し作成したQProgressBar（プログレスバー）値の更新](https://laboratory.kazuuu.net/updating-a-qprogressbar-value-created-in-python-using-pyqt5/)
- [Qtに関する覚え書き［各種ダイアログ］](http://dorafop.my.coocan.jp/Qt/Qt108.html)
- [PysideのQProgressDialogを、別スレッドで実行させたいです。](https://ja.stackoverflow.com/questions/37960/pysideのqprogressdialogを-別スレッドで実行させたいです)