一、问题的提出
先看标准的Python函数在传递不可变对象(比如整数、元组、字符串)时的运行:
python
class A:
def __init__(self):
self.num = 0
print(f"原对象内存地址: {id(self.num)}")
def func(self, n):
print(f"传递进函数内的对象地址: {id(n)}")
n += 1
print(f"执行以后的函数内对象地址: {id(n)}")
print(f"执行以后的函数内对象值: {self.num}")
A1 = A()
A1.func(A1.num)
运行结果:
python
原对象内存地址: 140732517686160
传递进函数内的对象地址: 140732517686160
执行以后的函数内对象地址: 140732517686192
执行以后的函数内对象值: 0
Python中不可变类型一旦创建,其值不能被修改。当对象被传入Python函数处理后,比如执行 n += 1,会创建一个新的整数对象 (值为原数 + 1),并让变量指向这个新对象。 函数的执行不会改变原有对象的值。
再看一个传递可变对象:
python
lst = [1,2,3]
print(f"原列表内存地址: {id(lst)}")
def func(l):
print(f"函数内列表内存地址: {id(l)}")
l.append(4)
func(lst)
print(lst)
运行结果:
python
原列表内存地址: 2523675881664
函数内列表内存地址: 2523675881664
[1, 2, 3, 4]
对于可变对象,由于对象是可变的,函数在传进来的对象地址上直接进行修改,不需要创建一个新的对象。
基于这个原理,当需要传递数据体积比较大的不可变类型对象到函数中并进行修改时,可以将其转变为可变类型,比如将字符串转为列表、把字节串转为数组,函数中在其原地址上对数据直接操作,避免创建一个新的对象, 以提高效率**。**
python
import time
class A:
def __init__(self, s):
self.string = s
def func(self, s, loop=100000): # 增加循环次数
time1 = time.perf_counter()
for _ in range(loop):
s += "X" # 重复执行字符串追加
cost = time.perf_counter() - time1
print(f"A(字符串)总耗时: {cost:.4f}秒")
class B:
def __init__(self, lst):
self.string_list = lst
def func(self, s, loop=100000):
time1 = time.perf_counter()
for _ in range(loop):
s.append("X") # 重复执行列表追加
cost = time.perf_counter() - time1
print(f"B(列表)总耗时: {cost:.4f}秒")
# 读取文件内容(如果文件为空,可手动初始化一个长字符串)
try:
with open("1.txt", "r", encoding="utf-8", errors="ignore") as f:
str1 = f.read() or "test" * 100 # 确保有基础内容
str1_list = list(str1)
A1 = A(str1)
A1.func(A1.string) # 循环10万次
B1 = B(str1_list)
B1.func(B1.string_list)
except FileNotFoundError:
print("错误:未找到1.txt文件,请确保文件存在于当前目录!")
运行结果:
python
A(字符串)总耗时: 1.9945秒
B(列表)总耗时: 0.0470秒
由于不再创建新对象,对于CPU和内存的开销都是减少的。
那么,Qt的信号槽机制,传递信号时,可不可以也通过传递可变对象,避免复制对象呢?
python
from PySide6.QtCore import QObject, Signal, Slot
class Sender(QObject):
signal1 = Signal(list)
def __init__(self):
super().__init__()
self.data = [1, 2, 3]
print(f"原列表内存地址: {id(self.data)}")
@Slot(list)
def on_signal1(self, lst, use_signal=True):
if use_signal:
print(f"信号槽机制接收到的列表内存地址: {id(lst)}")
else:
print(f"普通调用函数接收到的列表内存地址: {id(lst)}")
lst.append(4) # 修改接收的列表
s = Sender()
s.signal1.connect(s.on_signal1) # 连接信号与槽
s.signal1.emit(s.data) # 发射信号,并执行槽函数
print(f"信号槽机制调用函数执行以后的原始数据:{s.data}") # [1, 2, 3]
s.on_signal1(s.data, use_signal=False) # 不使用信号槽机制,直接调用槽函数
print(f"普通调用(不使用信号槽机制)函数执行过以后的原始数据:{s.data}") # [1, 2, 3, 4]
运行结果:
python
原列表内存地址: 1610567920448
信号槽机制接收到的列表内存地址: 1610572055360
信号槽机制调用函数执行以后的数据:[1, 2, 3]
普通调用函数接收到的列表内存地址: 1610567920448
普通调用(不使用信号槽机制)函数执行过以后的原始数据:[1, 2, 3, 4]
看得出,与Python的普通函数调用不同,在Qt信号槽机制下,当一个槽函数基于它连接的信号的发射而运行时,即使在传递可变对象(如列表、字典、数组) 时,也会对其进行隐式复制 ,而不是直接传递其内存引用,这就导致信号发射时,原对象会被复制一份后再发射给槽函数,槽函数接收到的是新的对象(内存地址不同)。我们知道,槽函数是在它的线程中排队执行的,这样做的好处是保证了信号的时效性以及实现了信号与槽的解耦,保证槽函数接收到的是"信号触发瞬间的数据",同时,在槽函数中修改数据也不会对信号源对象产生影响,规避了数据竞争的风险。
然而,缺点就是,不能像使用普通Python函数那样,通过在槽函数中传递可变对象的方式来避免复制对象,节约开销。
二、解决信号槽机制下传递大体积数据的问题
如果需要通过信号槽机制频繁传递"大块头的"对象,比如媒体对象的字节数组,每发射一次信号就要复制一次体积庞大的对象,程序的运行效率就特别低下。
可以使用下面的方法:
方案 1:传递 "内存地址 / 引用"(最推荐,零拷贝)
既然序列化拷贝开销大,核心思路就是避免拷贝,直接让目标线程访问原数据的内存地址。
- 实现原理:利用 Python 的
ctypes/multiprocessing或 Qt 的QMutex+ 共享内存,传递 "数据的内存标识" 而非数据本身;信号传递的是 "通知" 而非数据本身。 - 核心要求:必须保证数据在访问期间不被销毁 / 修改(用锁保护);
- 代码示例(QMutex 保护共享数据):
python
import sys
from PySide6.QtCore import (Signal, Slot, QThread, QMutex,
QMutexLocker, Qt)
from PySide6.QtWidgets import QApplication, QPushButton, QWidget, QVBoxLayout
# 共享数据容器(线程安全)
class SharedData:
def __init__(self):
self.mutex = QMutex() # 线程锁
self.large_data = None # 存储大数据(如二进制流、超大列表)
# 工作线程:生成/处理大数据
class WorkerThread(QThread):
# 只传递"数据已准备好"的信号,不传递数据本身
data_ready = Signal()
def __init__(self, shared_data):
super().__init__()
self.shared_data = shared_data
def run(self):
# 模拟生成100MB的大数据
large_binary = b"x" * 1024 * 1024 * 100 # 100MB二进制数据
# 加锁写入共享数据(保证线程安全)
with QMutexLocker(self.shared_data.mutex):
self.shared_data.large_data = large_binary
# 只发"通知",不发数据
self.data_ready.emit()
# 主窗口:接收通知并读取共享数据
class MainWindow(QWidget):
def __init__(self, shared_data):
super().__init__()
self.shared_data = shared_data
self.init_ui()
def init_ui(self):
self.btn = QPushButton("生成大数据并通知主线程")
self.btn.clicked.connect(self.start_worker)
layout = QVBoxLayout()
layout.addWidget(self.btn)
self.setLayout(layout)
self.setWindowTitle("跨线程传大数据优化")
def start_worker(self):
self.worker = WorkerThread(self.shared_data)
self.worker.data_ready.connect(self.on_data_ready, Qt.QueuedConnection) # 显式声明使用QueuedConnection连接,避免主线程阻塞
self.worker.start()
@Slot()
def on_data_ready(self):
# 加锁读取共享数据(避免数据被修改/销毁)
with QMutexLocker(self.shared_data.mutex):
data = self.shared_data.large_data # 注意这里是浅拷贝(若 large_data 是复杂对象,仅复制引用),若后续需要修改 data,需手动深拷贝,否则仍可能影响共享数据。
# 处理数据
print(f"主线程读取到大数据,大小:{len(data) / 1024 / 1024:.1f} MB")
if __name__ == "__main__":
app = QApplication(sys.argv)
# 创建全局共享数据容器
shared_data = SharedData()
window = MainWindow(shared_data)
window.show()
sys.exit(app.exec())
- 核心优化点:
- 信号
data_ready不传递任何大数据,只传递 "数据已准备好" 的通知; - 大数据存储在
SharedData中,通过QMutex保证线程安全访问; - 全程无数据拷贝,零序列化开销。
- 信号
方案 2:使用 Qt 共享内存(QSharedMemory),优点:多进程也可适用
适合超大数据(如 GB 级文件、视频流),数据存储在操作系统级的共享内存段,多线程 / 多进程均可访问:
python
import sys
from PySide6.QtCore import QSharedMemory
from PySide6.QtCore import Signal, Slot, QThread
from PySide6.QtWidgets import QApplication, QWidget, QPushButton
# 工作线程:写入共享内存
class WriteThread(QThread):
write_finished = Signal()
def run(self):
# 创建共享内存段(命名为唯一标识)
self.shm = QSharedMemory("MyLargeData")
if self.shm.isAttached():
self.shm.detach()
# 100MB数据
large_data = b"x" * 1024 * 1024 * 100
# 设置共享内存大小
if not self.shm.create(len(large_data)):
print("共享内存创建失败")
return
# 写入数据
self.shm.lock()
self.shm.data()[:len(large_data)] = large_data
self.shm.unlock()
self.write_finished.emit()
# 主线程:读取共享内存
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.btn = QPushButton("读取共享内存数据")
self.btn.clicked.connect(self.read_shm)
self.btn.show()
@Slot()
def read_shm(self):
shm = QSharedMemory("MyLargeData")
if not shm.attach():
print("无法连接共享内存")
return
# 读取数据
shm.lock()
data = bytes(shm.data())
shm.unlock()
shm.detach()
print(f"读取到共享数据大小:{len(data) / 1024 / 1024:.1f} MB")
if __name__ == "__main__":
app = QApplication(sys.argv)
writer = WriteThread()
window = MainWindow()
writer.write_finished.connect(window.read_shm)
writer.start()
sys.exit(app.exec())
三、理解信号槽机制的数据解耦天然优势
在Qt信号槽机制下,通过信号发射对象时,会复制一份后再发射给槽函数,这个复制得到的对象与原对象是解耦的,而且它创建、销毁的整个生命周期都是由Qt体系内部管理的,无须开发者参与。这也就是信号槽机制的最大优势,信号只管发送无需关心发送出去的数据被谁利用,槽函数函数只管接收无需关心数据是谁发出的,利用这个天然的解耦隔离特性,在不同的线程和程序模块之间传递和共享数据时,可以把数据生产者设计成信号发射方,处理数据的功能模块设计成槽函数,在不使用线程锁的前提下很方便地规避数据竞争风险,同时有利于程序的模块化设计,代码易于移植和复用。
python
import random
import sys
import time
from PySide6.QtCore import QObject, Signal, Slot, QThread, QTimer
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel, QPushButton
class CameraObject(QObject):
signal_buff = Signal(list) # 模拟图像帧信号
def __init__(self):
super().__init__()
self.is_grabbing = False
def grab_image(self): # 模拟相机采图
if self.is_grabbing: # 用is_grabbing控制循环
# 生成模拟图像数据(5个0-100的随机数组成的字符串)
str1 = ""
for i in range(5):
str1 += str(random.randint(0, 100))
# 发送数据(转成列表,每个字符作为一个元素)
self.signal_buff.emit(list(str1))
print(f"发送图像数据: {str1}")
# 2秒钟后再次启动,模拟采样帧率
QTimer.singleShot(2000, self.grab_image)
@Slot()
def start_grab(self):
print(f"开始在线程{self.thread().objectName()}内采图")
self.is_grabbing = True
self.grab_image()
@Slot()
def stop_grab(self):
self.is_grabbing = False
print("停止采图")
class YoloObject(QObject):
signal_result = Signal(str)
def __init__(self):
super().__init__()
@Slot(list)
def on_signal_buff(self, image):
print(f"接收到图像数据: {image}")
r = 0
for n in image:
r += int(n)
QTimer.singleShot(1000, lambda :self.signal_result.emit(f"yolo结果: {r}"))
def about_quit():
thread1.quit()
thread1.wait()
thread1.deleteLater()
thread2.quit()
thread2.wait()
thread2.deleteLater()
if __name__ == "__main__":
app = QApplication(sys.argv)
# 创建工作者和线程实体
cam = CameraObject()
yolo = YoloObject()
thread1 = QThread()
thread1.setObjectName("相机采图线程")
thread2 = QThread()
thread2.setObjectName("yolo处理线程")
# 创建画面以及连接信号和槽
form = QWidget()
layout = QVBoxLayout(form)
label = QLabel("yolo结果:")
layout.addWidget(label)
btn1 = QPushButton("开始采图")
layout.addWidget(btn1)
btn2 = QPushButton("停止采图")
layout.addWidget(btn2)
btn1.clicked.connect(cam.start_grab)
btn2.clicked.connect(cam.stop_grab)
# 连接信号和槽
cam.signal_buff.connect(yolo.on_signal_buff) # 把相机采集到的帧数据发送给yolo
yolo.signal_result.connect(lambda t:label.setText(t)) # yolo的运算结果发给主线程
# 将工作者移动到线程中
cam.moveToThread(thread1)
yolo.moveToThread(thread2)
thread1.start()
thread2.start()
form.show()
app.aboutToQuit.connect(about_quit)
sys.exit(app.exec())
总结:
1. 数据传递的 "自动解耦" 逻辑
- 当信号携带对象(如自定义结构体、QObject 子类实例等)发射时,Qt 会自动复制该对象(基于对象的可复制性,如实现拷贝构造、或通过 Qt 元对象系统支持),槽函数接收的是 "副本" 而非原对象。
- 副本的生命周期完全由 Qt 管理:发射时创建、槽函数处理完后自动销毁,开发者无需手动 new/delete,避免内存泄漏。
- 核心效果:原对象与槽函数接收的副本完全解耦 ------ 发送方修改原对象,不会影响已发出的副本;槽函数修改副本,也不会反向影响发送方,数据传递过程 "无干扰"。
2. 线程与模块间的 "无锁安全" 优势
- 数据竞争的核心风险是 "多线程同时操作同一数据",而信号槽的 "副本传递" 天然规避了这个问题:
- 生产者线程发射信号时,数据被复制,原对象仍归生产者线程支配;
- 消费者线程(或模块)的槽函数仅操作副本,与生产者的原数据无共享,因此无需线程锁即可保证线程安全。
- 这让跨线程、跨模块的数据传递变得简单:无需关心线程同步细节,只需定义 "信号(数据输出)" 和 "槽(数据处理)",Qt 底层负责副本传递和线程调度(如自动切换到槽函数所在线程执行)。
3. 工程化层面的 "模块化" 价值
- 信号发射方(生产者)的职责仅为 "产生数据并发出信号",无需知道哪些槽函数会接收(即 "不关心谁用");
- 槽函数(消费者)的职责仅为 "接收数据并处理",无需知道信号来自哪个对象(即 "不关心谁发");
- 这种 "生产者 - 消费者" 的解耦,让程序模块边界清晰:修改生产者逻辑不影响消费者,替换消费者模块不改动生产者,代码可移植性、复用性大幅提升(例如同一信号可连接多个不同功能的槽函数,或同一槽函数可接收多个不同信号的数据)。
- 并非所有对象都能自动复制:需确保对象支持拷贝(如未禁用拷贝构造),或通过
Q_DECLARE_METATYPE注册自定义类型,让 Qt 元对象系统能识别并复制;
4. 传递大数据时避免复制
- 若传递大数据(如大文件缓冲区),复制会有性能开销,此时可采用"只传递事件消息,共享数据内存"的方式,但这会牺牲部分解耦特性 ------ 这也体现了 "便捷性" 与 "性能" 的矛盾和取舍,大多数场景下,Qt默认的副本传递是 "通用性、安全性" 的最优解。