この記事ではPyQtにおいてマルチスレッドを使う方法について説明します。これはPyQtで重くて時間かかる処理を行いたい時にかなり重要です。ついでに、関連があるのでプログレスバーの作り方も説明します。
ここでPyQt6を使いますが、PyQt5も基本的にあまり違いがありません。PyQt6の基本とPyQt5との微妙な違いは以前書いた記事を参考に。
本来のやり方の問題点
本来の方法で何がいけないか説明するために、まずはマルチスレッドなしの単純な方法の実装をしてみます。
例えば、PyQtでボタン押したらある重い処理を行うとします。ここでは例として「素数検出」にします。本題とは関係ないのでアルゴリズムについては割愛します。
ではボタンを押したら素数が検出されて数が表示される簡単なGUIを作ってみます。
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()
これを実行したらボタンを持つウィンドウが現れます。
そしてボタンを押したら……、なんかウィンドウが止まってコントロールできなくなって、2秒くらい(パソコンによって違いますが)経ったら漸く結果が現れます。
このように、処理が行われている2秒くらいの間はGUIが固まって何もすることはできません。それだけでなく、setEnabled
とsetText
の効果も見えません。本来なら処理中の間はボタンの表示を変えたはずなのに、今の結果はただ固まっただけ。つまり処理が始まる前の変化も処理が終わるまで反映されません。
このままではやはり思い通りの仕様にならなくて困るでしょう。それを解決するためにPyQtではマルチスレッドを使うという方法が一般的です。
マルチスレッドの導入
では上述のコードをそのままマルチスレッドを使って書き換えます。書き方はかなり複雑で面倒に見えますが、後で説明します。
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()
これで実行したら同じようにウィンドウとボタンが出てきますが、今回ボタンを押したらちゃんと「処理中」に変わります。そして処理終了後の結果は同じ。
ここでマルチスレッドを使うためにQThread
というクラスを継承したクラスのオブジェクトを使います。まずはそのクラスを定義して、その中で実行したい処理をrun
というメソッドとして定義するのです。そして.start
メソッドを実行することでrun
で定義された内容は別のスレッドで実行されます。
別のスレッドで実行することでウィンドウが固まることはなくなります。
スレッドでやってはいけないこと
こうやって重い処理は別のスレッドでやることができて順調に操作ができそうですが、実は気をつけるべきところがいくつかあります。
例えばスレッドでウェジェットを作ることはできません。
この例を見てみましょう。
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
というシグナルが放出されるのです。それと同じものです。そのようなシグナルを自分で作成することができます。
書き方はこのようになります。
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()
このように書き換えることでラベルを追加することができます。因みに今回実行するたびにラベルが追加されるので、もう一度ボタンを押したら新しいラベルがまた現れます。
ここで処理が終わった後shori_owari
というシグナルを放出するように書いてあります。そのシグナルに応じて実行する関数を定義したらそれが普通にメインのスレッドで実行されて、ウェジェットを追加するなど普通の操作ができます。
処理完了のシグナル
さっきの例ではわざわざ処理が終わった後のシグナルを作ったのですが、実際はそもそもQThread
は実行が終わった後finished
というシグナルを放出します。つまりこれを自分で作る必要がなく、これを使えばいいです。
このように書き換えても結果は同じです。
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にプログレスバーを追加してみましょう。
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()
実行したらこのようなウィンドウ。
ボタンを押したらこのようにプログレスバーが現れて、終わるまで走ります。
処理を途中で止められるようにする
実行の途中で突然止めたい場合もありますよね。これもマルチスレッドを使っているからできることです。
プログレスバーとは直接関係ないのですが、プログレスバーと一緒に使うとわかりやすいので、ここでもプログレスバーを使う実装例にします。
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()
そうしたら今回ボタンを押したら「取り消し」と表示されます。
これを押すことで処理が途中で終わりって、それまでだけの結果が表示されます。
このように、進行するかを決めるための変数を入れて、ボタンでその変数を変えることで操作するのです。
プログレスバーのウィンドウ
プログレスバーを個別のウィンドウで表示したい場合、QProgressBar
よりもQProgressDialog
を使った方が便利です。これはただプログレスバーだけでなく、取り消しのボタンもついていて、取り消しの実装も簡単にできます。
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()
このように、ボタンを押したらプログレスバーと中止のボタンが付いている新しいウィンドウが出てきます。
QProgressDialog
の引数は順番でこうです。
- 上に表示するテキスト
- ボタンのテキスト
- 開始の段数
- 終わりの段数
- 親ウェジェット
尚、中止ボタンを押したらプログレスバーのウィンドウは閉じられますが、処理は止まるかどうかは別の話です。処理を止めるためのコードを書く必要があります。
中止ボタンを押したらcanceled
シグナルが出てくるので、connect
で取り消し処理の関数を指定すればいいです。
終わりに
以上マルチスレッドとプログレスバーの使い方の説明でした。
確かにかなり複雑に見えてわかりにくい気もしますが、重い処理をするGUIにはやはりかなり必要なので、使い熟せたら便利です。
参考
- 【PythonでGUI】PyQt5 -ウィジェット-
- PyQt5 マルチスレッド 2つのやり方 サブクラス式 moveToThread式
- Python + PyQt5でGUIアプリを作ってみた
- 複数のPDFを統合したいときに作ったスクリプト
- Pythonで簡単なPyQt5-PyInstallerのアップデート機能を作る。
- PyQt5 QThreadで勘違いからメモリリークを起こしていた
- [PyQt]QProgressBarで遊んでみた
- PySide6: QProgressDialog と QThread
- PythonでPyQt5を使用し作成したQProgressBar(プログレスバー)値の更新
- Qtに関する覚え書き[各種ダイアログ]
- PysideのQProgressDialogを、別スレッドで実行させたいです。