Потоки и многопоточность

Таймеры QTimer в многопоточном окружении: подводные камни

2 просмотров

Введение

QTimer — один из самых часто используемых классов Qt, но его поведение в многопоточном контексте вызывает регулярную путаницу: таймер привязан к event loop конкретного потока, и попытка использовать его «напрямую» из другого потока приводит к тому, что он либо не срабатывает вовсе, либо срабатывает не в том потоке, где ожидалось. В статье разберём, как корректно работать с таймерами в многопоточных приложениях.

Концепция

QTimer (как и любой QObject) принадлежит потоку, в котором он создан, и для срабатывания требует работающего event loop именно в этом потоке. Если создать QTimer в основном потоке, а затем попытаться запустить его (start()) из рабочего потока без moveToThread, таймер всё равно будет привязан к основному потоку, и попытка вызвать его методы из другого потока без предосторожностей небезопасна (нарушает однопотоковость доступа к QObject).

Правильный подход — либо создавать и запускать таймер непосредственно в том потоке, где он должен работать (что требует наличия там event loop, то есть QThread::exec() или собственного цикла обработки событий), либо использовать статические методы вроде QTimer::singleShot(), которые удобны, но всё равно требуют, чтобы вызывающий поток имел event loop.

Пример кода

#include <QCoreApplication>
#include <QThread>
#include <QTimer>
#include <QDebug>

class PeriodicWorker : public QObject
{
    Q_OBJECT
public:
    void start()
    {
        // Таймер создаётся ВНУТРИ слота, который выполнится
        // уже в потоке Worker после moveToThread + thread->start()
        m_timer = new QTimer(this);
        connect(m_timer, &QTimer::timeout, this, &PeriodicWorker::onTick);
        m_timer->start(500);
        qDebug() << "Таймер запущен в потоке" << QThread::currentThreadId();
    }

private slots:
    void onTick()
    {
        qDebug() << "Тик таймера в потоке" << QThread::currentThreadId();
    }

private:
    QTimer *m_timer = nullptr;
};

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);

    auto *thread = new QThread;
    auto *worker = new PeriodicWorker;
    worker->moveToThread(thread);

    // ВАЖНО: запуск таймера происходит через started(), то есть
    // уже после того, как объект "переехал" в новый поток и там
    // запустился event loop
    QObject::connect(thread, &QThread::started, worker, &PeriodicWorker::start);

    thread->start();

    QTimer::singleShot(3000, &app, [thread, worker]() {
        thread->quit();
        thread->wait();
        worker->deleteLater();
        thread->deleteLater();
        qApp->quit();
    });

    return app.exec();
}

Пояснения к коду

Ключевой момент примера — таймер m_timer создаётся не в конструкторе PeriodicWorker (который выполняется в основном потоке при создании объекта), а в методе start(), вызванном через сигнал QThread::started уже после того, как объект был перемещён в новый поток и этот поток запустил собственный event loop. Это гарантирует, что QTimer создаётся и живёт именно в рабочем потоке, и его сигнал timeout() будет доставлен и обработан там же.

Главный поток в это время продолжает работать со своим собственным QTimer::singleShot, который через 3 секунды останавливает рабочий поток и завершает приложение — оба таймера работают полностью независимо, каждый в своём потоке.

Подводные камни

  • Создание QTimer в конструкторе объекта, который затем перемещается в другой поток через moveToThread. Хотя сам объект QTimer корректно «переедет» вместе со своим родителем, более безопасный и явный паттерн — создавать таймер непосредственно в коде, который точно выполняется в целевом потоке (как в примере, через слот started), чтобы не зависеть от тонкостей перемещения дочерних объектов.
  • Вызов timer->start() из чужого потока. Методы QTimer, не являющиеся потокобезопасными по документации, нельзя безопасно вызывать из потока, отличного от того, которому принадлежит объект — для управления таймером из другого потока нужно использовать QMetaObject::invokeMethod() с Qt::QueuedConnection.
  • Отсутствие event loop в рабочем потоке. Если в QThread::run() переопределён без вызова exec() (то есть поток выполняет линейный код без цикла обработки событий), таймеры, созданные в этом потоке, никогда не сработают — QTimer принципиально зависит от event loop.
  • Точность таймеров не гарантирована под нагрузкой. QTimer — программный, а не аппаратный таймер; если event loop потока перегружен другими задачами (длинные слоты, тяжёлые вычисления в том же потоке), фактическое время срабатывания таймера может существенно отклоняться от заданного интервала.