当接收者忙碌时,Qt信号会发生什么?

12
在我的应用程序中,我有一个QTimer实例,它的timeout()信号连接到主窗口对象中的一个槽,导致它定期被调用。该槽使用相机拍照并将其保存到磁盘。
我想知道如果在接收器(主线程上的窗口对象)当前正在忙于执行先前的操作时(例如拍摄和保存先前的图片),信号会发生什么情况(来自QTimer执行的单独线程)。是否会将调用排队并在前一个调用终止后执行?整个想法是让它以规律间隔运行,但这些调用是否可能排队然后在控制返回到事件循环时随机执行,从而造成混乱?我该如何避免这种情况?理论上,槽应该执行得很快,但假设硬件出现问题并出现停顿。
在这种情况下,我希望呼叫被丢弃而不是排队,并且更有用的是,在发生这种情况时能够做出反应(警告用户、终止执行)。

我的直觉是考虑将拍照并保存到磁盘的逻辑从主GUI线程移动到单独的线程中。这样做应该可以通过为剩余间隔时间设置自己的定时器,使照片线程更容易地在常规间隔上运行。 - Digikata
@Digikata:实际上,它的安排方式是GUI线程拥有计时器并具有定期调用的插槽,但所有该插槽所做的只是发出信号以供处理捕获和保存事务的不同线程使用。 - neuviemeporte
如果你想要获得准确的时间间隔计时,这个问题是比较小的。我会在回答中详细解释一下... - Digikata
4个回答

6
此时其他答案都有相关的上下文,但需要知道的关键是,如果定时器回调信号一个不同的线程中的槽,那么该连接要么是QueuedConnection或者是BlockingQueuedConnection。
因此,如果您使用定时器来尝试进行某种常规处理,则在计时器触发和槽实际执行之间会增加一些时间抖动,因为接收对象位于其自己的线程中运行独立的事件循环。这意味着它在将事件放入队列时可能正在执行任何数量的其他任务,并且在完成处理这些事件之前,图片线程将不会执行定时器事件。
定时器应该在与照片逻辑相同的线程中。将定时器放在与相机拍摄相同的线程中,使连接直接,并为您提供更好的定时间隔稳定性。特别是如果照片捕获和保存具有偶尔异常的持续时间。
大致流程如下,假设间隔为10秒:
- 设置10秒的计时器 - 计时器触发 - 保存开始时间 - 拍照 - 将照片保存到磁盘(出于某种奇怪的原因,需要3秒钟) - 计算10 -(当前时间 - 开始时间)= 7秒 - 设置7秒的超时
您还可以在此处设置一些逻辑,以检测跳过的间隔(例如其中一个操作需要11秒才能完成...)

感谢让我们重新回到正轨。 :) 无论如何,我理解您在这里提倡使用 singleShot() 定时器。更新间隔的想法很好,如果值为负数,我也会知道照片是否需要多个间隔时间... 需要处理一下。 :) - neuviemeporte
不使用线程,而是使用固定10秒间隔的计时器,最简单的解决方案有什么问题吗?您能详细说明一下吗?它确实考虑了连接到超时的插槽执行所需的时间。如果当前时间和上次应该执行的时间之间的时间大于10秒,则不要采取行动,而是不要拍摄(如果有任何等待事件,则会清空事件队列)。我经常在OpenGL渲染中使用它以固定速率运行,即使渲染插槽每次执行所需的时间不同,它也可以非常精确地工作(我进行了测量)。 - Boris Dalstein
@Boris 更简单的解决方案更可取,除非您正在进行的处理可能会超过间隔,并且您希望在遵守原始时间间隔边界的同时恢复处理。原帖作者暗示可能存在异常情况,例如照片捕获中的硬件故障,并对这些边界表示担忧。 - Digikata
就像我说的,我有90%的把握,在这种情况下,你不必改变计时器间隔。只需在槽中检查上次执行是否太长(例如,35秒),如果是,则在接下来的n个执行(在此情况下为3个执行)中什么也不做,计时器将会回到以t0+10*k的倍数发出超时信号,其中t0是起始时间。但是当我有时间尝试并且100%确定时,我会将其发布为答案。(如果我错了,请在此处评论;-)) - Boris Dalstein
2
+1 ;-) 我进行了一些实验。如果停顿时间长于计时器间隔的两倍(即错过两次或更多点击),您确实是正确的。否则,如果只有可能错过一次点击(这是我一直遇到的情况),计时器可以正确地补上。因此,如果您对停顿时间没有上限(这显然是 OP 的情况),那么您的解决方案就是正确的。 - Boris Dalstein
只有一个小提示,我的发布的方法也不是完美的,取决于时间计算的实现方式,您可能会在长时间运行中看到一些漂移。如果您使用绝对时间来计算目标开始时间,您可以更好地减少漂移。 - Digikata

4

我在进行一些实验后详细介绍了 QTimer 在接收器忙碌时的行为。

以下是实验用的源代码:(将QT += testlib 添加到项目文件中)

#include <QtGui>
#include <QtDebug>
#include <QTest>

struct MyWidget: public QWidget
{
    QList<int> n;    // n[i] controls how much time the i-th execution takes
    QElapsedTimer t; // measure how much time has past since we launch the app

    MyWidget()
    {
        // The normal execution time is 200ms
        for(int k=0; k<100; k++) n << 200; 

        // Manually add stalls to see how it behaves
        n[2] = 900; // stall less than the timer interval

        // Start the elapsed timer and set a 1-sec timer
        t.start();
        startTimer(1000); // set a 1-sec timer
    } 

    void timerEvent(QTimerEvent *)
    {
        static int i = 0; i++;

        qDebug() << "entering:" << t.elapsed();
        qDebug() << "sleeping:" << n[i]; QTest::qSleep(n[i]);
        qDebug() << "leaving: " << t.elapsed() << "\n";
    }   
};  

int main(int argc, char ** argv)
{
    QApplication app(argc, argv);   
    MyWidget w;
    w.show();
    return app.exec();
}

当执行时间小于时间间隔时

那么,预期的是,定时器稳定地每秒拍摄一次。它会考虑执行所花费的时间,然后方法timerEvent总是从1000ms的倍数开始:

entering: 1000 
sleeping: 200 
leaving:  1201 

entering: 2000 
sleeping: 900 
leaving:  2901 

entering: 3000 
sleeping: 200 
leaving:  3201 

entering: 4000 
sleeping: 200 
leaving:  4201 

当由于接收者忙碌而错过了仅一次点击时
n[2] = 1500; // small stall (longer than 1sec, but less than 2sec)

当一个stall完成后,下一个slot会立即被调用,但是随后的调用仍然是1000ms的倍数:

entering: 1000 
sleeping: 200 
leaving:  1200 

entering: 2000 
sleeping: 1500 
leaving:  3500 // one timer click is missed (3500 > 3000)

entering: 3500 // hence, the following execution happens right away
sleeping: 200 
leaving:  3700 // no timer click is missed (3700 < 4000)

entering: 4000 // normal execution times can resume
sleeping: 200 
leaving:  4200 

entering: 5000 
sleeping: 200 
leaving:  5200 

如果由于时间累积而错过了接下来的点击,只要每次执行中只有一个点击被错过,这个方法也可以奏效:
n[2] = 1450; // small stall 
n[3] = 1450; // small stall 

输出:

entering: 1000 
sleeping: 200 
leaving:  1201 

entering: 2000 
sleeping: 1450 
leaving:  3451 // one timer click is missed (3451 > 3000)

entering: 3451 // hence, the following execution happens right away
sleeping: 1450 
leaving:  4901 // one timer click is missed (4901 > 4000)

entering: 4902 // hence, the following execution happens right away
sleeping: 200 
leaving:  5101 // one timer click is missed (5101 > 5000)

entering: 5101 // hence, the following execution happens right away
sleeping: 200 
leaving:  5302 // no timer click is missed (5302 < 6000)

entering: 6000 // normal execution times can resume
sleeping: 200 
leaving:  6201 

entering: 7000 
sleeping: 200 
leaving:  7201 

当接收者非常忙碌,错过了多次点击时。
n[2] = 2500; // big stall (more than 2sec)

如果错过了两个或更多的点击,那么问题就会出现。执行时间与第一次执行不同步,而是与停顿结束的时刻同步:
entering: 1000 
sleeping: 200 
leaving:  1200 

entering: 2000 
sleeping: 2500 
leaving:  4500 // two timer clicks are missed (3000 and 4000)

entering: 4500 // hence, the following execution happens right away
sleeping: 200 
leaving:  4701 

entering: 5500 // and further execution are also affected...
sleeping: 200 
leaving:  5702 

entering: 6501 
sleeping: 200 
leaving:  6702 

结论

必须使用Digikata的解决方案,如果停滞时间可能超过计时器间隔的两倍,否则不需要,并且上述简单实现方法有效。如果您更喜欢以下行为:

entering: 1000 
sleeping: 200 
leaving:  1200 

entering: 2000 
sleeping: 1500 
leaving:  3500 // one timer click is missed 

entering: 4000 // I don't want t execute the 3th execution
sleeping: 200 
leaving:  4200 

那么你仍然可以使用简单的实现方式,只需检查 enteringTime < expectedTime + epsilon 是否为真。如果是真的,就拍照;如果是假的,则不进行任何操作。


3
您可以使用Qt::(Blocking)QueuedConnection连接类型来使用connect方法,以避免直接连接立即触发。
由于您有单独的线程,应该使用阻塞版本。但是,当您希望避免为接收器调用而没有单独的线程时,应考虑非阻塞变体。
请参见官方文档获取详细信息。
从文档中方便起见:

Qt::QueuedConnection

当控件返回到接收者线程的事件循环时,将调用插槽。插槽在接收者线程中执行。

Qt::BlockingQueuedConnection

与QueuedConnection相同,除了当前线程会阻塞,直到插槽返回。此连接类型仅应在发射器和接收器位于不同线程的情况下使用。

您可能想要写的是,您不想要直接连接,而是排队连接。

QCoreApplication::removePostedEvents ( QObject * receiver, int eventType ) 可以用于清理队列,如果队列中堆积了过多的重型任务,可以使用事件类型MetaCall。此外,您还可以始终使用标志与插槽通信,以便在设置后退出。

有关详细信息,请参见以下论坛讨论:http://qt-project.org/forums/viewthread/11391


3
答案是肯定的。当您的QTimer和接收器在不同的线程中时,调用被放入接收器的事件队列中。如果您的拍照或保存例程占用了执行时间,您的事件可能会被延迟很长时间。但这对于所有事件都是一样的。如果一个例程不将控制权还给事件循环,您的GUI会挂起。您可以使用:

Qt::BlockingQueuedConnection 与QueuedConnection相同,除了当前线程会一直阻塞,直到槽返回。此连接类型只应在发射器和接收器位于不同线程的情况下使用。

但是,像这样的情况很可能暗示着您的逻辑有问题。

你知道队列有多深吗?同时有多少信号可能会被卡在那里?有没有办法手动清空队列? - neuviemeporte
@neuviemeporte:QCoreApplication::removePostedEvents(QObject *receiver, int eventType)……但是,你当然可以使用原子标志来与槽通信,如果设置了该标志,则退出。我应该将这些信息放入我的回复中吗? - László Papp
不,我不知道事件队列有多深。而且,据我所知,它没有任何文档记录,因此您也不应该使用这些信息。这可能会随着Qt的任何更新而改变。信号/槽的事件类型是Event :: MetaCall。因此,您可以尝试像Laszlo Papp建议的那样使用QCoreApplication :: removePostedEvents。 - Greenflow
事件循环的最大大小有记录吗?在哪里记录? - Greenflow
我并不打算使用那些信息(可能也没有办法),只是出于好奇想知道而已。我猜测队列并不是无限深的,到了某个点上,事件就会以FIFO的方式被丢弃。 - neuviemeporte
1
这就是我在这里的原因之一:面对我从未想过的问题。我试图进行核实。在一个非官方的来源中,有人声称队列仅受内存限制。这是有道理的。我不知道为什么它应该被人为地限制。如果事件对象被放在堆栈上,这可能是一个限制。我想明天必须查看qt源代码。这让我有些感兴趣。 - Greenflow

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接