关于Qt高频率信号槽合并的误解和方案

使用Qt信号槽的时候,总会有这样的需求:线程频繁触发信号,希望槽在执行时,仅处理一次。这样的场景确实普遍存在,比如线程不断产生不同的数据,而槽因为某些原因(槽比较耗时或者正在处理其他事情),仅希望在“最后一次”信号触发时执行实际的业务逻辑(举例与实际技术没有太大关系,只是业务上的需求)。

这样似乎需要在槽执行时先判断是不是有多次触发信号,然而Qt没有提供这样的接口。

标题使用误解一词,是考虑到初学者可能还不理解Qt信号槽的触发原理。本文也做了简单的解释。

本文的测试代码,需要在各个线程设置合理的休眠来线程的忙碌场景,才能得出准确的结论。

信号槽的背后

关于这个需求,首先要理解信号槽背后的一些机制,可以先看一下 Qt信号槽与事件循环的关系 这篇文章理解一下。

信号的触发实际是调用信号这个方法,如果推断出需要队列方式,就会生成一个QEvent::MetaCall事件(可以打印一下看看事件),对应的QMetaCallEvent里有应该调用哪个槽(方法)的信息。接收者所在的事件循环处理到该事件时,根据信息去调用对应的槽。

也就时说,信号槽的队列调用实际是事件循环里有大量的QEvent::MetaCall事件

延时的思路

通常关于高频信号槽的处理会使用延时方案,也就是在槽里使用QTimer,设置一个延时。当第一次收到消息,启动定时器,后续的槽执行时判断是否超时,没有超时则忽略或者重启定时器;超时则再次启动定时器。真正的执行逻辑放到定时器的超时槽里。这样就可以降低实际业务逻辑的执行频率。

// connect(timer, SIGNAL(timeout()), this, SIGNAL(processData()));
void Widget::onThreadData(int val){
	timer->start(); //选择始终重启定时器
}

void Widget::processData(){
	// 业务代码
}

但这样有一些问题,业务的执行时机总是延迟至少一个定时器时长。如果选择每次重启定时器,则可能业务逻辑很长时间都不会被执行,另外定时器的时长又不好确定。

尽可能快的需求

使用延时方案其实已经能应付一些场景。上面说到槽的执行是对应到事件循环里的QEvent::MetaCall,如果仅仅是想在第一次槽执行的时候,将此时队列里属于该槽的QEvent::MetaCall合并成一个,应该怎么做呢?

假设线程里是这样的:

void ThreadWork::procress(){
    for(int i = 0; i < 10; i++){
        emit threadData(i);
    }
    QThread::msleep(100); // 休眠100ms模拟一下频率变化
    for(int i = 10; i < 20; i++){
        emit threadData(i);
    }
}

这个方法执行时,会在接收者的队列里先后插入20个QMetaCallEvent,可能不连续。假设接收者所在线程此时正在忙,等到事件循环处理QMetaCallEvent时,可能已经累积了一些,比如有10个(这里用100ms休眠模拟,不一定是10个),想将10个合并到一起。

其实还是延时的思路,只不过使用 QTimer::singleShot 启动一个 0ms 的延时,该函数内部判断如果是0,则直接产生一个队列调用。这样,事件队列尾部会插入一个新的 QMetaCallEvent,将ThreadWork::threadData信号触发的QMetaCallEvent“隔离出来”,然后用一个变量做个标记。

void Widget::onThreadData(int val){
    if(!waiting){
        waiting = true;
        QTimer::singleShot(0, this, SLOT(processData()));
    }
}

void Widget::processData(){
    waiting = false;
    // 业务代码
}

由于槽执行使用队列执行,同一线程,不存在竞争。当然距离真正的合并事件还是有点差距,毕竟onThreadData每次都在调用。

当然也可以自己写个自定义事件来隔离。也可以利用自定义事件或者lambda,将参数缓存起来,交给实际的业务逻辑。

再严格一点

假设接收者只有一个槽,且只会有一个信号跟该槽连接,也就是功能非常单一。这样可以使用提供的一个接口:

static void QCoreApplication::removePostedEvents(QObject *receiver, int eventType = 0);

当槽第一次执行时,调用removePostedEvents,可以移除掉队列里的所有的QEvent::MetaCall 事件。假设ThreadWork2类实例处于另外的线程,与 ThreadWork::threadData 信号连接:

void ThreadWork2::onThreadData(int val){
    QCoreApplication::removePostedEvents(this, QEvent::MetaCall);
    // 业务代码
}

与上面不同的是,removePostedEvents在业务代码前后,或中间任意位置调用会有差异,因为队列的事件插入可以是多线程的。缺点是,直接移除了事件,后续信号参数也没法保留,但针对某些场景可以提升一点效率。

总结

大概就这些了,各位开发者根据自己的需求选择合适的方案,也可以提出更好的办法。

你可能感兴趣的:(Qt,Qt技术总结,Qt常见问题,qt,信号,合并,事件,QTimer)