You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

PyQt5+Vispy程序仅在QThread加sleep才更新GUI的原因与解决

问题描述

使用PyQt5结合Vispy开发实时数据可视化应用,需求为绘制每秒10000个样本、时长100秒的正弦曲线。将数据生成逻辑放在QThread中执行,GUI图形更新逻辑放在主线程,但运行时GUI出现冻结,无法按指定间隔完成更新;仅在QThread的循环中添加极短sleep后GUI才会更新,但更新速度极慢。

相关完整代码:

from PyQt5.QtGui import QCloseEvent
from vispy.app import use_app, Timer
import numpy as np
from PyQt5 import QtWidgets, QtCore
from vispy import scene, visuals
import time
from scipy import signal
from collections import deque
import cProfile
import pstats


class MainWindow(QtWidgets.QMainWindow):
    closing = QtCore.pyqtSignal()
    def __init__(self, canvas_wrapper, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        
        central_widget = QtWidgets.QWidget()
        main_layout = QtWidgets.QHBoxLayout()
        self.canvas_wrapper = canvas_wrapper
        main_layout.addWidget(self.canvas_wrapper.canvas.native)
        central_widget.setLayout(main_layout)
        self.setCentralWidget(central_widget)
    
    def closeEvent(self, event):
        print("Closing main window")
        self.closing.emit()
        return super().closeEvent(event)


class CanvasWrapper:
    def __init__(self, update_interval = .016): 
        self.canvas = scene.SceneCanvas(keys='interactive', size=(600, 600), show=True)
        self.grid = self.canvas.central_widget.add_grid()

        title = scene.Label("Test Plot", color='white')
        title.height_max = 40
        self.grid.add_widget(title, row=0, col=0, col_span=2)

        self.yaxis = scene.AxisWidget(orientation='left',
                         axis_label='Y Axis',
                         axis_font_size=12,
                         axis_label_margin=50,
                         tick_label_margin=10)
        self.yaxis.width_max = 80
        self.grid.add_widget(self.yaxis, row=1, col=0)

        self.xaxis = scene.AxisWidget(orientation='bottom',
                         axis_label='X Axis',
                         axis_font_size=12,
                         axis_label_margin=50,
                         tick_label_margin=20)
        self.xaxis.height_max = 80
        self.grid.add_widget(self.xaxis, row=2, col=1)
        
        right_padding = self.grid.add_widget(row=1, col=2, row_span=1)
        right_padding.width_max = 50

        self.view = self.grid.add_view(row=1, col=1, border_color='white')
        self.view.camera = "panzoom"
        
        self.data = np.empty((2, 2))
        self.line = scene.Line(self.data, parent=self.view.scene)

        self.xaxis.link_view(self.view)
        self.yaxis.link_view(self.view)

        self.update_interval = update_interval
        self.last_update_time = time.time()

    def update_data(self, newData):
        if self.should_update():
            data_array = newData["data"]
            data_array = np.array(data_array)

            x_min, x_max = data_array[:, 0].min(), data_array[:, 0].max()
            y_min, y_max = data_array[:, 1].min(), data_array[:, 1].max()
            
            self.view.camera.set_range(x=(x_min, x_max), y=(y_min, y_max))
            self.line.set_data(data_array)
            
    def should_update(self):
        current_time = time.time()
        if current_time - self.last_update_time >= self.update_interval:
            self.last_update_time = current_time
            return True
        return False


class DataSource(QtCore.QObject):
    new_data = QtCore.pyqtSignal(dict)
    finished = QtCore.pyqtSignal()

    def __init__(self, sample_rate, seconds, seconds_to_display=15, q = 100, parent = None):
        super().__init__(parent)
        self.count = 0
        self.q = q
        self.should_end = False
        self.sample_rate = sample_rate
        self.num_samples = seconds*sample_rate
        self.seconds_to_display = seconds_to_display
        size = self.seconds_to_display*self.sample_rate
        self.buffer = deque(maxlen=size)

    def run_data_creation(self):
        print("Run Data Creation is starting")
        for count in range (self.num_samples):
            if self.should_end:
                print("Data saw it was told to end")
                break
        
            self.update(self.count)
            self.count += 1

            data_dict = {
                "data": self.buffer,
            }
            self.new_data.emit(data_dict)

        print("Data source finished")
        self.finished.emit()
    
    def stop_data(self):
        print("Data source is quitting...")
        self.should_end = True

    def update(self, count):
        x_value = count / self.sample_rate
        y_value = np.sin((count / self.sample_rate) * np.pi)
        self.buffer.append([x_value, y_value])
        

class Main:
    def __init__(self, sample_rate, seconds, seconds_to_display):
        self.app = use_app("pyqt5")
        self.app.create()
        self.sample_rate, self.seconds, self.seconds_to_display = sample_rate, seconds, seconds_to_display
        self.canvas_wrapper = CanvasWrapper()
        self.win = MainWindow(self.canvas_wrapper)
        self.data_thread = QtCore.QThread(parent=self.win)
        self.data_source = DataSource(self.sample_rate, self.seconds)
        self.data_source.moveToThread(self.data_thread)
        self.setup_connections()

    def setup_connections(self):
        self.data_source.new_data.connect(self.canvas_wrapper.update_data)
        self.data_thread.started.connect(self.data_source.run_data_creation)
        self.data_source.finished.connect(self.data_thread.quit, QtCore.Qt.DirectConnection)
        self.win.closing.connect(self.data_source.stop_data, QtCore.Qt.DirectConnection)
        self.data_thread.finished.connect(self.data_source.deleteLater)

    def run(self):
        self.win.show()
        self.data_thread.start()
        self.app.run()
        self.data_thread.quit()
        self.data_thread.wait(5000)


def profile_run():
    visualization_app = Main(10000, 100, 10)
    visualization_app.run()

if __name__ == "__main__":
    cProfile.run('profile_run()', 'profile_out')
    stats = pstats.Stats('profile_out')
    stats.sort_stats('cumulative')
    stats.print_stats(10)
    stats.sort_stats('time').print_stats(10)

添加sleep后的代码片段:

def run_data_creation(self):
    print("Run Data Creation is starting")
    for count in range (self.num_samples):
        if self.should_end:
            print("Data saw it was told to end")
            break
    
        self.update(self.count)
        self.count += 1
        time.sleep(.0000001)

        data_dict = {
            "data": self.buffer,
        }
        self.new_data.emit(data_dict)

    print("Data source finished")
    self.finished.emit()

问题原因

  1. 信号发射频率过高:QThread每秒发射10000次new_data信号,主线程的事件队列被海量更新请求塞满,根本无法处理GUI重绘、用户交互等核心事件,直接导致GUI冻结。
  2. 信号槽连接的队列阻塞:PyQt跨线程信号默认使用Qt.QueuedConnection,所有信号会排队等待主线程处理。即使update_data里有should_update做间隔判断,主线程仍要逐个处理这些信号,排队开销已经耗尽了主线程资源。
  3. sleep的假象:添加sleep后QThread会短暂让出CPU,主线程获得极短时间处理事件队列,所以GUI能勉强更新,但本质还是信号量过大,更新速度必然缓慢。

解决方案

1. 批量发送信号,降低发射频率

不用每个样本都发信号,而是积累一定数量的样本(或每隔固定时间)再发射一次,比如和屏幕刷新率匹配的16ms(约60帧/秒),既保证视觉流畅,又不会压垮主线程。

修改DataSource的run_data_creation:

def run_data_creation(self):
    print("Run Data Creation is starting")
    last_emit_time = time.time()
    emit_interval = 0.016  # 60帧/秒的间隔
    for count in range(self.num_samples):
        if self.should_end:
            print("Data saw it was told to end")
            break
        
        self.update(self.count)
        self.count += 1

        # 每隔固定时间才发射信号
        current_time = time.time()
        if current_time - last_emit_time >= emit_interval:
            data_dict = {
                "data": self.buffer,
            }
            self.new_data.emit(data_dict)
            last_emit_time = current_time

    # 最后发送剩余数据
    data_dict = {"data": self.buffer}
    self.new_data.emit(data_dict)
    print("Data source finished")
    self.finished.emit()

2. 优化数据传递与更新逻辑

  • 直接在DataSource中维护numpy数组,避免每次将deque转为numpy数组的开销;
  • 正弦曲线的y值范围固定在[-1,1],可以提前设置Camera范围,不用每次更新都计算x_min/x_max和y_min/y_max,节省计算时间。

修改CanvasWrapper的初始化和update_data:

class CanvasWrapper:
    def __init__(self, update_interval = .016): 
        # ... 其他初始化代码不变 ...
        # 提前设置Camera范围,正弦曲线y范围固定[-1,1]
        self.view.camera.set_range(x=(0, 10), y=(-1.1, 1.1))

    def update_data(self, newData):
        if self.should_update():
            data_array = np.array(newData["data"])
            # 只更新x范围(因为x是随时间增长的)
            x_max = data_array[:, 0].max()
            x_min = max(0, x_max - 10)  # 保持显示最近10秒的数据
            self.view.camera.set_range(x=(x_min, x_max), y=(-1.1, 1.1))
            self.line.set_data(data_array)

3. 改用主线程主动拉取数据(替代信号推送)

用Vispy的Timer在主线程定时从DataSource读取数据,完全控制更新频率,避免信号队列阻塞:

修改Main类和CanvasWrapper:

class CanvasWrapper:
    def __init__(self, data_source, update_interval = .016): 
        # ... 其他初始化代码不变 ...
        self.data_source = data_source
        # 创建Vispy定时器,定时拉取数据
        self.timer = Timer(interval=update_interval, connect=self.pull_data, start=True)

    def pull_data(self, event):
        data_array = np.array(self.data_source.buffer)
        x_max = data_array[:, 0].max()
        x_min = max(0, x_max - 10)
        self.view.camera.set_range(x=(x_min, x_max), y=(-1.1, 1.1))
        self.line.set_data(data_array)

# 修改Main类的setup_connections,移除new_data信号连接
class Main:
    def __init__(self, sample_rate, seconds, seconds_to_display):
        # ... 其他初始化代码不变 ...
        # 把data_source传给CanvasWrapper
        self.canvas_wrapper = CanvasWrapper(self.data_source)
        # ... 其他代码不变 ...

    def setup_connections(self):
        # 移除new_data信号的连接
        # self.data_source.new_data.connect(self.canvas_wrapper.update_data)
        self.data_thread.started.connect(self.data_source.run_data_creation)
        self.data_source.finished.connect(self.data_thread.quit, QtCore.Qt.DirectConnection)
        self.win.closing.connect(self.data_source.stop_data, QtCore.Qt.DirectConnection)
        self.data_thread.finished.connect(self.data_source.deleteLater)

内容的提问来源于stack exchange,提问作者Ava

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 11:20:56