PyQt5桌面应用开发(3):并行设计
本文目录
PyQt5桌面应用系列
- PyQt5桌面应用开发(1):需求分析
- PyQt5桌面应用开发(2):事件循环
- PyQt5桌面应用开发(3):并行设计
- PyQt5桌面应用开发(4):界面设计
- PyQt5桌面应用开发(5):对话框
- PyQt5桌面应用开发(6):文件对话框
- PyQt5桌面应用开发(7):文本编辑+语法高亮与行号
- PyQt5桌面应用开发(8):从QInputDialog转进到函数参数传递
- PyQt5桌面应用开发(9):经典布局QMainWindow
- PyQt5桌面应用开发(10):界面布局基本支持
- PyQt5桌面应用开发(11):摸鱼也要讲基本法,两个字,16
前言
对一个桌面应用程序来说,有一个无法回避的主题就是并行设计。其主要的原因就是在计算机读取数据或者运行耗时计算时,用户界面必须避免“卡死”的状态。粗略分析,有两种情况。第一种情况是跟IO相关的操作。第二种情况是要消耗大量CPU计算的操作。
并行运行需求:IO vs. CPU
IO密集型
IO密集型的情况下,程序花事先无法确定的时间等待IO返回的数据。对于网络通信、文件接收、传感器的数据采集,底层采取定时或者是其他连接方式,最终数据是通过数据集合(文件块、数据包)来得到的。
在这种情况下,即使Python里面具有GIL的限制,多线程也能够非常有效地解决。如果是大批量数据通道,还有一些基于协程的方式。
在PyQt5中,可以通过线程池来解决。事先准备一个线程池,启动IO密集型的线程,通过信号与槽来处理交互。
大概来说:调用线程池和IO线程的主程序,只需要处理:
- 错误
- 完成
- 数据
- 进度
这么几个信号就能够完成任务。
如果需要在IO的同时实时来把数据更新在界面上,那么对进度信号略做改变就能够完成。
CPU密集型
计算或者是CPU密集型的任务,由于Python的限制(GIL),采用线程没有一点的帮助。
那么就应该采取进程的方法,并通过管道来传递数据。这里就有一个设计选择。可以通过PyQt5的进程,也可以通过Python的进程。
这里直接说结论:
- PyQt5的进程需要自己处理Python运算代码的传输、数据传输管道,相当复杂;
- Python提供了良好的进程并行支持,但是不支持传输PyQt5中QObject的子类。
因此,我这里提供一个思路。具体计算(Python代码的函数)的实现放到一个Python提供的进程中完成,返回数据通过队列自动处理的管道完成,这就把CPU计算封装为一个IO(对PyQt5主程序而言)。
与前面的IO密集型任务一样,采用线程池和Qt线程完成。
并行运行设计
PyQt的核心并行机制:QThreadPool
与QRunnable
核心的对象和关系如图,主程序启动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_())
总结
- IO密集型直接用线程(协程)来并行;
- CPU密集型用进程来并行;
- QObject对象没法在Python进程库中传递。