PyQt5桌面应用开发(3):并行设计

PyQt5桌面应用系列

前言

对一个桌面应用程序来说,有一个无法回避的主题就是并行设计。其主要的原因就是在计算机读取数据或者运行耗时计算时,用户界面必须避免“卡死”的状态。粗略分析,有两种情况。第一种情况是跟IO相关的操作。第二种情况是要消耗大量CPU计算的操作。

并行运行需求:IO vs. CPU

IO密集型

IO密集型的情况下,程序花事先无法确定的时间等待IO返回的数据。对于网络通信、文件接收、传感器的数据采集,底层采取定时或者是其他连接方式,最终数据是通过数据集合(文件块、数据包)来得到的。

在这种情况下,即使Python里面具有GIL的限制,多线程也能够非常有效地解决。如果是大批量数据通道,还有一些基于协程的方式。

在PyQt5中,可以通过线程池来解决。事先准备一个线程池,启动IO密集型的线程,通过信号与槽来处理交互。

大概来说:调用线程池和IO线程的主程序,只需要处理:

  1. 错误
  2. 完成
  3. 数据
  4. 进度
    这么几个信号就能够完成任务。

如果需要在IO的同时实时来把数据更新在界面上,那么对进度信号略做改变就能够完成。

CPU密集型

计算或者是CPU密集型的任务,由于Python的限制(GIL),采用线程没有一点的帮助。

那么就应该采取进程的方法,并通过管道来传递数据。这里就有一个设计选择。可以通过PyQt5的进程,也可以通过Python的进程。

这里直接说结论:

  1. PyQt5的进程需要自己处理Python运算代码的传输、数据传输管道,相当复杂;
  2. Python提供了良好的进程并行支持,但是不支持传输PyQt5中QObject的子类。

因此,我这里提供一个思路。具体计算(Python代码的函数)的实现放到一个Python提供的进程中完成,返回数据通过队列自动处理的管道完成,这就把CPU计算封装为一个IO(对PyQt5主程序而言)。

与前面的IO密集型任务一样,采用线程池和Qt线程完成。

并行运行设计

PyQt的核心并行机制:QThreadPoolQRunnable

核心的对象和关系如图,主程序启动QThreadPool,里面运行的都是QRunnable的子类,称为Worker。这里的Worker分为进程和线程两类。进程类封装一个Python进程。两个都通过信号与槽跟PyQt5主程序交互。
在这里插入图片描述

QThreadPool的方法如下图,通过start函数来启动QRunnable对象。还可以管理最大线程数。
在这里插入图片描述
QRunnable的run函数就是线程执行的主要操作。这里设计的两个子类,构造函数都是一个函数对象和这个函数的参数、命名参数。
在这里插入图片描述
而交互的信号也可以专门定义一个类WorkerSignals.
在这里插入图片描述

CPU密集型进程

对于CPU密集型的进程,专门设计一个WorkerProcess类,来进行同样的封装,为一个值得注意的是,创建这个类的QProcessWorker必须维持一个队列,也就是下图中构造函数中的第三个参数:multiple_process_queue。
在这里插入图片描述
完成设计之后,就可以将前面的采集音频的程序做亿点点改造。可以选择用进程也可以选择用线程。二者都能够保证采集和播放的过程大体不影响界面的响应(操作图片、变更频域和时域)。值得注意的是,进程的方式,启动时间会大大高于线程。

代码

PyQt5桌面应用并行库

这部分代码用户无需修改,定义了信号类(必须是QObject的子类)、进程类、线程任务、进程任务。

import multiprocessing as mp
import sys

from PyQt5.QtCore import QObject, pyqtSignal, QRunnable, pyqtSlot


class WorkerSignals(QObject):
    """
    Defines the signals available from a running worker thread.

    Supported signals are:

    finished
        No data

    error
        `tuple` (exctype, value, traceback.format_exc() )

    result
        `object` data returned from processing, anything

    progress
        `int` indicating % progress

    """
    finished = pyqtSignal()
    error = pyqtSignal(tuple)
    result = pyqtSignal(object)
    progress = pyqtSignal(int)


class QtThreadWorker(QRunnable):
    """
    Worker thread

    Inherits from QRunnable to handler worker thread setup, signals and wrap-up.

    :param callback: The function callback to run on this worker thread. Supplied args and
                     kwargs will be passed through to the runner.
    :type callback: function
    :param args: Arguments to pass to the callback function
    :param kwargs: Keywords to pass to the callback function
    """

    def __init__(self, fn, *args, **kwargs):
        super(QtThreadWorker, self).__init__()

        # Store constructor arguments (re-used for processing)
        self.fn = fn
        self.args = args
        self.kwargs = kwargs
        self.signals: WorkerSignals = WorkerSignals()

    @pyqtSlot()
    def run(self):
        """
        Initialise the runner function with passed args, kwargs.
        """

        # Retrieve args/kwargs here; and fire processing using them
        try:
            result = self.fn(*self.args, **self.kwargs)
        except:
            import traceback
            traceback.print_exc()
            exctype, value = sys.exc_info()[:2]
            self.signals.error.emit((exctype, value, traceback.format_exc()))
        else:
            self.signals.result.emit(result)  # Return the result of the processing
        finally:
            self.signals.finished.emit()  # Done


class WorkerProcess(mp.Process):
    def __init__(self, fn, multiple_process_queue, *args, **kwargs):
        super(WorkerProcess, self).__init__()
        self.fn = fn
        self.args = args
        self.kwargs = kwargs
        self.multiple_process_queue = multiple_process_queue

    def run(self):
        result = self.fn(*self.args, **self.kwargs)
        self.multiple_process_queue.put(result)


class QtProcessWorker(QRunnable):
    """
    Worker thread

    Inherits from QRunnable to handler worker thread setup, signals and wrap-up.

    :param callback: The function callback to run on this worker thread. Supplied args and
                     kwargs will be passed through to the runner.
    :type callback: function
    :param args: Arguments to pass to the callback function
    :param kwargs: Keywords to pass to the callback function
    """

    def __init__(self, fn, *args, **kwargs):
        super(QtProcessWorker, self).__init__()

        # Store constructor arguments (re-used for processing)
        self.args = args
        self.kwargs = kwargs
        self.signals: WorkerSignals = WorkerSignals()
        self.result = None
        self.queue = mp.Queue()

        self.p = WorkerProcess(fn, self.queue, *self.args, **self.kwargs)

    @pyqtSlot()
    def run(self):
        """
        Initialise the runner function with passed args, kwargs.
        """

        # Retrieve args/kwargs here; and fire processing using them
        try:
            self.p.start()
            self.result = self.queue.get(block=True)
        except:
            import traceback
            traceback.print_exc()
            exctype, value = sys.exc_info()[:2]
            self.signals.error.emit((exctype, value, traceback.format_exc()))
        else:
            self.signals.result.emit(self.result)  # Return the result of the processing
        finally:
            self.signals.finished.emit()  # Done



并行库应用实例


import sys

import numpy as np
import sounddevice as sd
from PyQt5.QtCore import Qt, pyqtSignal, QThreadPool
from PyQt5.QtGui import QIntValidator, QIcon
from PyQt5.QtWidgets import QWidget, QPushButton, QCheckBox, QLineEdit, QVBoxLayout, QHBoxLayout, QLabel, QApplication, \
    QMainWindow
from matplotlib import pyplot as plt
from matplotlib.axes import Axes
from matplotlib.backends.backend_qt5 import NavigationToolbar2QT as NavigationToolbar
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure

from ui.worker import QtThreadWorker, QtProcessWorker


def toggle_button(button: QPushButton, enable: bool):
    button.setEnabled(enable)
    button.repaint()


def record_sound(time, samples):
    rate = samples / time

    sound_recorded = sd.rec(samples, samplerate=rate, channels=1, blocking=True)

    n = len(sound_recorded)
    t = np.linspace(0, time, n)

    fft = np.fft.fft(sound_recorded)
    fft_freq = np.fft.fftfreq(len(fft), 1 / rate)
    n = len(fft) // 2

    return t, sound_recorded, fft_freq[:n], fft.real[:n]


class QSoundWidget(QWidget):
    record = pyqtSignal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self.sound = None
        self.freq = None
        self.default_window_bg_color = [240.0 / 255] * 3
        self.rate = 48000
        self._init_components()
        self._setup_layout()

        self.thread_pool = QThreadPool()
        self.thread_pool.setMaxThreadCount(8)

    def start_button_clicked(self):
        text = self.start_button.text()
        self.start_button.setText("Recording...")
        toggle_button(self.start_button, False)

        if self.use_process.isChecked():
            worker = QtProcessWorker(record_sound, self.get_sound_duration(), self.get_sound_duration() * self.rate)
        else:
            worker = QtThreadWorker(record_sound, self.get_sound_duration(), self.get_sound_duration() * self.rate)

        worker.signals.result.connect(self.update_record)
        worker.signals.finished.connect(lambda: self.finish_record(text))

        self.thread_pool.start(worker)

    def finish_record(self, text):
        self.start_button.setText(text)
        toggle_button(self.start_button, True)

    def update_record(self, result):
        t, sound_recorded, fft_freq, fft_real = result
        self.sound = t, sound_recorded
        self.freq = fft_freq, fft_real
        self.draw_series()

    def draw_series(self):
        if self.sound is None:
            return
        self.figure.clear(True)

        ax: Axes = self.figure.add_subplot(111)
        (x, y), title, label, scale = (
            self.freq,
            "Recorded sound in frequency domain",
            "Frequency (Hz)",
            "log"
        ) if self.is_frequency_domain.isChecked() else (
            self.sound,
            "Recorded sound in time domain",
            "Time (s)",
            "linear"
        )

        ax.scatter(x, y)
        ax.set_title(title)
        ax.set_xlabel(label)
        ax.set_ylabel("Amplitude")
        ax.set_yscale(scale)
        ax.grid(True)

        self.canvas.draw()

    def get_sound_duration(self):
        try:
            return int(self.record_duration.text())
        except ValueError:
            return 2

    def play_sound(self):
        if self.sound is None:
            return
        toggle_button(self.play_button, False)
        if self.use_process.isChecked():
            worker = QtProcessWorker(sd.play, self.sound[1], self.rate, blocking=True)
        else:
            worker = QtThreadWorker(sd.play, self.sound[1], self.rate, blocking=True)

        worker.signals.finished.connect(lambda: toggle_button(self.play_button, True))

        self.thread_pool.start(worker)

    def _init_components(self):
        self.figure: Figure = plt.figure()
        self.figure.tight_layout()
        self.figure.set_facecolor(self.default_window_bg_color)

        self.canvas = FigureCanvas(self.figure)
        self.toolbar = NavigationToolbar(self.canvas, self)

        self.use_process = QCheckBox("Use process", self)

        self.start_button = QPushButton("Start recording", self)
        self.start_button.setIcon(QIcon("../icons/record.png"))

        self.is_frequency_domain = QCheckBox("Frequency domain", self)
        self.record_duration = QLineEdit("2", self)
        self.is_show_toolbar = QCheckBox("Show Toolbar", self)

        self.play_button = QPushButton("Play", self)
        self.play_button.setIcon(QIcon("../icons/play.png"))

        self.is_show_toolbar.setChecked(True)
        self.record_duration.setValidator(QIntValidator(1, 10))

        self.is_frequency_domain.setChecked(False)

        self.start_button.clicked.connect(self.start_button_clicked)
        self.is_frequency_domain.stateChanged.connect(self.draw_series)
        self.is_show_toolbar.stateChanged.connect(lambda: self.toolbar.setVisible(self.is_show_toolbar.isChecked()))
        self.play_button.clicked.connect(self.play_sound)

    def _setup_layout(self):
        self.layout = QVBoxLayout(self)

        buttons = QWidget(self)
        self.button_layout = QHBoxLayout(buttons)

        for widget in (QLabel("Record duration: ", self), self.record_duration,
                       QLabel("s", self), self.use_process, self.start_button,
                       self.is_frequency_domain, self.is_show_toolbar,
                       self.play_button):
            self.button_layout.addWidget(widget)

        self.layout.addWidget(buttons)
        self.layout.addWidget(self.canvas, stretch=1)
        self.layout.addWidget(self.toolbar)

        self.layout.setAlignment(self.toolbar, Qt.AlignCenter)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = QMainWindow()
    w.resize(1024, 768)
    w.move(100, 100)
    w.setWindowTitle('Record and show sound')

    w.setCentralWidget(QSoundWidget(w))

    w.show()
    sys.exit(app.exec_())


总结

  1. IO密集型直接用线程(协程)来并行;
  2. CPU密集型用进程来并行;
  3. QObject对象没法在Python进程库中传递。
posted @ 2023-03-13 10:47  大福是小强  阅读(128)  评论(0编辑  收藏  举报  来源