2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

QtAdvent Calendar 2024

Day 18
Qiita100万記事感謝祭!記事投稿キャンペーン開催のお知らせ

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

Last updated at Posted at 2025-01-13

この記事では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()

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

截屏2025-01-13-16.46.18.jpg

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

截屏2025-01-13-16.47.53.png

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

このままではやはり思い通りの仕様にならなくて困るでしょう。それを解決するために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()

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

截屏2025-01-13-17.33.09.jpg

ここでマルチスレッドを使うために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()

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

截屏2025-01-13-18.09.36.png

ここで処理が終わった後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()

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

截屏2025-01-13-21.06.31.jpg

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

截屏2025-01-13-21.07.12.png

截屏2025-01-13-21.09.42.png

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

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

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

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

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

截屏2025-01-13-21.13.30.png

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

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

プログレスバーを個別のウィンドウで表示したい場合、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()

このように、ボタンを押したらプログレスバーと中止のボタンが付いている新しいウィンドウが出てきます。

截屏2025-01-13-21.42.02.png

QProgressDialogの引数は順番でこうです。

  • 上に表示するテキスト
  • ボタンのテキスト
  • 開始の段数
  • 終わりの段数
  • 親ウェジェット

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

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

終わりに

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

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

参考

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?