Многопоточность в Qt через наследование QThread

В одной из прошлых статей “О Многопоточности в Qt и как создать поток” рассмотрены различные способы выполнения кода в отдельном потоке при использовании фреймворка Qt. Один из способов: наследование от класса QThread и переопределение метода run() который только ленивый уже не обругал и не заклеймил. Также поступил и я но поразмыслив решил взять свои слова обратно и подробнее рассказать об этом способе и его особенностях.

Содержание статьи

1. Как это работает?
2. Пример использования?
3. Достоинства и недостатки

В прошлой статье упоминалось, что использование метода противоречит принципам SOLID. А именно принципу открытости\закрытости (O – “open\closed principle”). Но принцип на то и принцип, что дает лишь общее абстрактное описание, не опираясь на конкретную задачу, а потому возможные некоторые вариации. Разработчику приходится самому “дозировать” те или иные принципы в своих задачах. Поэтому отбрасывать один из способов создания потока только из-за того, что один из принципов слабо реализуется, как минимум не корректно.

1. Как это работает?

Если вы знакомы с языком Java, то это очень похоже на реализацию интерфейса Runnable. Создайте класс с предком QThread и переопределите метод run(). Этот метод будет запущен в отдельном потоке, а все остальные методы-члены и параметры класса, как и сам экземпляр класса продолжат своё существование в том потоке, где был аллоцирован экземпляр.

class ThreadMethod : public QThread {
    private:
    void run() override {
        // this code is executed in another thread
    }
};

Такая реализация позволяет инкапсулировать разделяемые данные (некий буфер) и саму задачу (метод исполняемый в отдельном потоке) в одном классе.

Только метод run() будет выполняться в отдельном потоке, все остальные методы-члены и свойства класса, а также сам экземпляр класса будут существовать в потоке, где произошло создание экземпляра класса

Важно помнить, что в Qt объекты существуют в том потоке, где они созданы, а при взаимодействии с некоторыми классами из других потоков может привести к неопределённому поведению (Undefined behavior).

2. Пример использования?

В одной из программ, разрабатываемых мною, нужен был синхронный доступ к последовательному порту. Доступ к периферии может занимать много времени, поэтому во избежание “заморозки” и подвисания основного потока объекты взаимодействующие с периферией выносятся в другие потоки.

Реализация класса представлена ниже

class DataModel : public QThread {
public:
    DataModel(QStringView port, qint32 baud, int timeout_ms = 0) 
    : m_port(port.toString()),
    m_baud(baud),
    m_timeout(timeout_ms)
    {}
    // Останавливаем выполнение потока и дожидаемся окончания
    ~DataModel() {
        stop();
        wait();
    }
    // Метод отправки данных в последовательный порт
    void send(const QByteArray& msg) {
        QMutexLocker lk(&m_mtx);
        m_queue.enqueue(msg);
        if(isRunning()) {
            m_sem.release();
        } else {
            start();
        }
    }
    // Метод остановки потока
    void stop() {
        QMutexLocker lk(&m_mtx);
        m_stop = true;
        if(isRunning() && m_sem.available() == 0) 
            m_sem.release();
    }


private:
    // метод, работающий в отдельном потоке
    void run() override {
        QScopedPointer<QSerialPort> port(new QSerialPort());
        port->setPortName(m_port);
        port->setBaudRate(m_baud);
        if(!port->open(QIODevice::ReadWrite) {
            emit errorOccured(QString("Can't open %1, error code %2").arg(m_port).arg(port.error()));
            return;
        }
        emit connected(true);
        do {
            m_mtx.lock();
            auto txBuffer = m_queue.dequeue();
            m_mtx.unlock();

            m_port.write(txBuffer);
            if(m_port.waitForBytesWritten(m_timeout)) {
                if(m_port.waitForReadyRead(m_timeout)) {
                    auto rxBuffer = m_port.readAll();
                    emit readyRead(rxBuffer);
                } else {
                    emit errorOccured("Read timeout");
                }
            } else {
                emit errorOccured("Can't write data");
            }

            if(m_sem.available() > 0) {
                m_sem.acquire();
            } else {
                emit queueIsEmpty();
            }
            
        } while(!m_stop);
        if(m_port.isOpen())
            m_port.close();
        emit connected(false);
    }

    QQueue<QByteArray> m_queue;
    QSemaphore m_sem;
    QMutex m_mtx;
    bool m_stop {false};
    QString m_port;
    qint32 m_baud;
    int m_timeout;
singals:
    void readyRead(const QByteArray&);
    void connected(bool);
    void errorOccured(const QString&);
    void queueIsEmpty();
};

Работа с классом осуществляется следующим образом. Создается экземпляр класса, в конструктор которого передается имя последовательного порта, бод рейт и таймаут в мс. Если понадобиться остановить и уничтожить класс, вызывается метод void stop(). Новые данные передаются в качестве аргумента в метод void send(const QByteArray&) и складываются в очередь. Если это первая попытка передачи данных, то запускается поток на выполнение, в противном случае, инкрементируем семафор.

При запуске потока производится подключение к последовательному порту. Если подключение не удалось, то излучаем сигнал с текстом ошибки и выходим из метода void run(). При завершении метода void run() поток будет завершен.

Далее попадаем в цикл, где берем данные из очереди и записываем их в порт и вызываем waitForBytesWritten() – это блокирующий метод, осуществляющий запись данных в порт в течение заданного таймаута (или оставьте аргумент пустым, чтобы метод ждал бесконечно до записи данных). Затем ожидаем чтения данных из порта и копируем их в rxBuffer. Полученные данные выводим в сигнале readyRead(const QByteArray&).

Затем проверяем есть ли необработанные состояния семафора. Если есть – обрабатываем, если нет, выдаем сигнал queueIsEmpty().

3. Достоинства и недостатки

  • Прост в реализации, просто наследуйте класс QThread и переопределите метод void run();
  • Позволяет инкапсулировать разделяемые данные, нет необходимости создания отдельного класса для реализации потокобезопасной структуры хранения данных.
  • Отсутствует обработчик событий, поэтому использование сигналов для объектов, созданных внутри метода run() не возможно.

Благодарю за внимание! В следующей статье рассмотрим применение асинхронности в Qt и C++ как более высокоуровнего API для многопоточных разработок