PyQt4 の UI 操作のマルチスレッド処理

PyQt4 のシグナル/スロットの新旧スタイルの違いを比較するサンプル - forest book で紹介したサンプル、指定したディレクトリ配下にあるファイル数を数える簡単なアプリケーションでは、大量のファイルがあるディレクトリを指定した場合、全てのファイル数が数え上げられまで待つ必要がありました。私の環境では、数万件のファイルがあるディレクトリを指定すると数秒を要するため、ユーザビリティがよくありません。そこで QtCore.QThread *1 を継承したクラスを実装することでファイルの数え上げ処理を別スレッドで実行するようにします。 *2

先ずはサンプルコードから紹介します。

#!/usr/env/bin python
# -*- coding: utf-8 -*-

import sys
import os
from PyQt4 import QtCore, QtGui

class Walker(QtCore.QThread):

    sig_status = QtCore.pyqtSignal(long)

    def __init__(self, parent=None):
        super(Walker, self).__init__(parent)
        self.path = ""
        self.stopped = False
        self.mutex = QtCore.QMutex()

    def setup(self, path):
        self.path = path
        self.stopped = False

    def stop(self):
        with QtCore.QMutexLocker(self.mutex):
            self.stopped = True

    def run(self):
        file_num = 0
        for root, dirs, files in os.walk(self.path):
            if self.stopped:
                return
            file_num += len(files)
            self.sig_status.emit(file_num)
        self.stop()
        self.finished.emit()

class Dialog(QtGui.QDialog):

    def __init__(self, parent=None):
        super(Dialog, self).__init__(parent)
        self.directory = QtGui.QLineEdit("./")
        self.label = QtGui.QLabel("file num: 0")
        self.search_button = QtGui.QPushButton("&search")
        self.search_button.clicked.connect(self.search_files)
        self.stop_button = QtGui.QPushButton("&stop")
        self.stop_button.clicked.connect(self.stop_search)
        
        hbox = QtGui.QHBoxLayout()
        hbox.addWidget(self.directory)
        hbox.addWidget(self.search_button)
        hbox.addWidget(self.stop_button)
        hbox.addWidget(self.label)
        self.setLayout(hbox)
        self.setWindowTitle("Asynchronous New-style Signal and Slot Sample")
       
        self.walker = Walker()
        self.walker.sig_status.connect(self.update_status)
        self.walker.finished.connect(self.finish_search)

    @QtCore.pyqtSlot()
    def search_files(self):
        self.search_button.setEnabled(False)
        self.stop_button.setEnabled(True)
        path = str(self.directory.text())
        if not os.access(path, os.R_OK):
            return
        self.walker.setup(path)
        self.walker.start()

    @QtCore.pyqtSlot()
    def stop_search(self):
        self.walker.stop()
        self.walker.wait()
        self.search_button.setEnabled(True)
        self.stop_button.setEnabled(False)

    @QtCore.pyqtSlot(long)
    def update_status(self, file_num):
        self.label.setText("file num: %d" % file_num)
    
    @QtCore.pyqtSlot()
    def finish_search(self):
        self.walker.wait()
        self.search_button.setEnabled(True)
        self.stop_button.setEnabled(False)

def main():
    app = QtGui.QApplication(sys.argv)
    dialog = Dialog()
    dialog.show()
    sys.exit(app.exec_())

if __name__ == "__main__":
    main()

Walker クラスにファイル数を数え上げる処理を実装します。新スタイルのシグナル/スロットを使用することで明示的にシグナルを定義しているクラスが Walker だと分かり易くて良いですね。

    self.walker = Walker()
    self.walker.sig_status.connect(self.update_status)
    self.walker.finished.connect(self.finish_search)

QtCore.QThread では予め3つのシグナル(finished(), started(), terminated())が定義されています。Walker の run メソッド終了時に発生する finished シグナルに対するスロットとして finish_search を、QtCore.pyqtSignal() で定義したシグナル sig_status には update_status を対応付けています。

スレッド処理でもう1つ気を付けることとして、インスタンス変数 stopped は複数のスレッドからアクセスされるので変更時に QtCore.QMutex() で with 文でロックを取得しています。with 文は Python 2.5 以上 *3 で使用することができます。

        self.mutex = QtCore.QMutex()
... (snip)
    def stop(self):
        with QtCore.QMutexLocker(self.mutex):
            self.stopped = True

この with ブロックは以下の try...finally で書いても等価です。with 文を使用した方が可読性が良いですね。

    def stop(self):
        try:
            self.mutex.lock()
            self.stopped = True
        finally:
            self.mutex.unlock()

実行結果。

ディレクトリを入力して検索ボタンをクリックすると、処理中のファイル数が表示されます。また停止ボタンで処理を中断することもできます。

以前のものよりもずっと良くなりましたが、もう少し改良してみます。停止ボタンで数え上げ処理を中止せずに、一旦、停止状態にしておき、同じディレクトリを指定した状態で検索ボタンがクリックされたら、途中から処理を継続するようにします。Walker クラスの run() メソッドで yield を使用して一時停止できるかを試してみましたが、どうもダメのようです。代替案としてスリープ処理を入れることで目的の振る舞いを実現できましたが、用途によってはあまり効率的ではないかもしれません。 *4

     def run(self):
         file_num = 0
         for root, dirs, files in os.walk(self.path):
-            if self.stopped:
-                return
+            while self.stopped:
+                self.msleep(100)
             file_num += len(files)
             self.sig_status.emit(file_num)
         self.stop()

この改良を行った最終的なソースは以下になります。

#!/usr/env/bin python
# -*- coding: utf-8 -*-

import sys
import os
from PyQt4 import QtCore, QtGui

class Walker(QtCore.QThread):

    sig_status = QtCore.pyqtSignal(long)

    def __init__(self, parent=None):
        super(Walker, self).__init__(parent)
        self.path = ""
        self.stopped = False
        self.mutex = QtCore.QMutex()

    def setup(self, path):
        self.path = path
        self.stopped = False

    def stop(self):
        with QtCore.QMutexLocker(self.mutex):
            self.stopped = True

    def restart(self):
        with QtCore.QMutexLocker(self.mutex):
            self.stopped = False

    def run(self):
        file_num = 0
        for root, dirs, files in os.walk(self.path):
            while self.stopped:
                self.msleep(100)
            file_num += len(files)
            self.sig_status.emit(file_num)
        self.stop()
        self.finished.emit()

class Dialog(QtGui.QDialog):

    def __init__(self, parent=None):
        super(Dialog, self).__init__(parent)
        self.directory = QtGui.QLineEdit("./")
        self.label = QtGui.QLabel("file num: 0")
        self.search_button = QtGui.QPushButton("&search")
        self.search_button.clicked.connect(self.search_files)
        self.stop_button = QtGui.QPushButton("&stop")
        self.stop_button.clicked.connect(self.stop_search)
        
        hbox = QtGui.QHBoxLayout()
        hbox.addWidget(self.directory)
        hbox.addWidget(self.search_button)
        hbox.addWidget(self.stop_button)
        hbox.addWidget(self.label)
        self.setLayout(hbox)
        self.setWindowTitle("Asynchronous New-style Signal and Slot Sample")
       
        self.path = None
        self.walker = Walker()
        self.walker.sig_status.connect(self.update_status)
        self.walker.finished.connect(self.finish_search)

    @QtCore.pyqtSlot()
    def search_files(self):
        self.search_button.setEnabled(False)
        self.stop_button.setEnabled(True)
        current_path = str(self.directory.text())
        if not os.access(current_path, os.R_OK):
            return
        if current_path == self.path:
            self.walker.restart()
        else:
            self.path = current_path
            if self.walker.isRunning:
                self.walker.terminate()
                self.walker.wait()
            self.walker.setup(current_path)
            self.walker.start()

    @QtCore.pyqtSlot()
    def stop_search(self):
        self.walker.stop()
        self.search_button.setEnabled(True)
        self.stop_button.setEnabled(False)

    @QtCore.pyqtSlot(long)
    def update_status(self, file_num):
        self.label.setText("file num: %d" % file_num)
    
    @QtCore.pyqtSlot()
    def finish_search(self):
        self.search_button.setEnabled(True)
        self.stop_button.setEnabled(False)

def main():
    app = QtGui.QApplication(sys.argv)
    dialog = Dialog()
    dialog.show()
    sys.exit(app.exec_())

if __name__ == "__main__":
    main()