О Многопоточности в Qt и как создать поток

Многопоточность — свойство платформы выполнять код внутри одного процесса может выполнятся “параллельно” без предписанного порядка во времени. Такой подход полезен когда отделить ресурсоёмкую задачу от остального кода. Например, читать с диска в память большой файл и не «заморозить» графический интерфейс программы. В этой статье поговорим о многопоточности в C++ и о том, как это работает во фреймворке Qt. На примере покажу как запустить в отдельном потоке только один метод объекта, оставив остальные методы «снаружи», обсудим плюсы и минусы такого подхода и как поступить в такой ситуации!

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

1. Как устроена многопоточность в Qt?
2. Создание потока через обертку
3. Переопределение метода run() класса QThread
4. Достоинства и недостатки

1. Как устроена многопоточность в Qt?

В Qt потоками управляет класс QThread. Он представляет собой обёртку для потоков операционной системы и предоставляет кросс-платформенный интерфейс для работы с ними.

Помните, что один объект отвечает за один поток, поэтому не стоит бездумно плодить потоки. Создание потока сопряжено с существенными накладными расходами в программе.

Запустить код в отдельном потоке можно двумя способами:

  1. создание обёртки для нашего класса, который будет жить в отдельном потоке;
  2. переопределение метода run() в унаследованном от QThread классе.

Важно! Выделение памяти оператором new для экземпляров класса необходимо выполнять в том потоке, в котором они будут исполнятся.

Это правило делает собственником объектов тот поток, который их создал. Так легче контролировать жизненный цикл объектов и позволит избежать ошибок в работе, когда используемый объект внезапно был удален в другом потоке.

2. Создание потока через обёртку

Допустим, что вся тяжелая работа выполняется в методе doWork() класса HeavyWork, поэтому необходимо перенести его в отдельный поток. Код класса представлен ниже:

#include <iostream>
class HeavyWork : public QObject {
    Q_OBJECT
	public:
	    void doWork() {
	        std::cout << "Начинаем сложную работу";
      	    // Для эмуляции долгого выполнения сложной операции
	        // отправим поток в сон на 1 секунду
	        QThread::sleep(1);
	        std::cout << "Всё готово!";
	    }
	};

Экземпляр такого класса необходимо создать внутри потока. Напрямую сделать этого не возможно, поэтому создадим обработчик, который и будет создавать, взаимодействовать и уничтожать такой объект. Создаем класс Worker с одним методом и сигналом, излучаемым с флагом типа bool, оповещающем об (не) успешности выполненной работы. Все это происходит внутри метода process():

#include <QObject>
class Worker : public QObject {
    Q_OBJECT
    HeavyWork* work;
public slots:
    void process();
signals:
    void finished(bool);
};

void Worker::process() {
    // Этот метод будет запущен при старте потока
    // Аллоцируем наш объект. Теперь это происходит в отдельном потоке
    work = new HeavyWork();
    if(work == nullptr) {
        // Если произошла ошибка, то сигнализируем что поток завершен с отрицательным результатом и покидаем функцию (а с ней и завершается поток)
        emit finished(false);
        return;
    }
    // Делаем сложную работу
    work->doWork();
    // Сигнализируем об успешном выполнении
    emit finished(true);
}

На этом подготовительные операции выполнены и переходим к перемещению обработчика Worker в отдельный поток. Для этого создадим ещё один объект Controller, внутри которого вся магия и произойдет: экземпляр обработчика Worker и поток начнут взаимодействовать.

class Controller : public QObject {
    Q_OBJECT
public:
    void makeThread();
};

Единственный метод класса – makeThread(), вот он то нам и нужен, давайте его пошагово реализуем!

  1. Создаем экземпляры обработчика Worker и экземпляр потока QThread
Worker* worker = new Worker();
QThread* thread = new QThread();

2. Настроим и передадим данные, если нужно, экземпляру Worker. В этой точке у нас есть последний шанс сделать это привычным путем

3. Перемещаем worker в новорожденный поток

worker->moveToThread(thread);

Налаживаем связь между потоком и обработчиком. Ключ на старт!

// При запуске потока запускаем выполнение метода Worker::process()
connect(thread, &QThread::started, worker, &Worker::process);
// При излучении сигнала finished получаем флаг успешности и выводим в консоль соответствующее сообщение
connect(worker, &Worker::finished, this, [](bool state){
    if(state)
        std::cout << "Успех" << std::endl;
    else
        std::cout << "Хьюстон, у нас проблемы!" << std::endl;
});
// Также, по сигналу finished отправляем команду на завершение потока
connect(worker, &Worker::finished, thread, &QThread::quit);
// А потом удаляем экземпляр обработчика
connect(worker, &Worker::finished, worker, &QObject::deleteLater);
// И наконец, когда закончит работу поток, удаляем и его
connect(thread, &QThread::finished, thread, &QObject::deleteLater);

3..2..1.. Поехали! Запускаем выполнение потока

thread->start();

Реализация метода makeThread() завершена. Для проверки выполните следующий код и наблюдайте за стандартным выводом приложения:

Controller ctrl;
ctrl.makeThread();

3. Переопределение метода run() класса QThread

Второй способ заключается в наследовании класса QThread и переопределении метода run(). Однако, этот метод нарушает принципы SOLID в подавляющем большинстве случаев, поэтому пойдем окольными путями и вместо наследования создадим свой класс и передадим один из его методов на выполнение в отдельный поток. В качестве нагрузки используем уже знакомый класс HeavyWork, а выполнять работу будем в классе AnotherController

class AnotherController : public QObject {
    Q_OBJECT
public:
    void makeThread();
};

, где метод makeThread() реализуем следующим образом:
1. Создаем экземпляр класса HeavyWork

HeavyWork* work = new HeavyWork();

2. Создаем поток через статический метод QThread::create() принимающий Function в качестве параметра. Function — это адаптер функциональных объектов. Создать адаптер можно с помощью функции std::bind из стандартной библиотеки шаблонов (STL). Этот адаптер будет запущен при запуске потока. Синтаксис следующий:

QThread* thread = QThread::create(std::bind(&HeavyWork::doWork, work));

Первым аргументом будет указатель на метод doWork класса HeavyWork, а далее передаем контекст — указатель на конкретный класс, метод которого будет запущен. Нечто похожее проделывали в методе connnect для сигналов и слотов.

Далее через запятую указываются аргументы, если они нужны, которые будут переданы в метод doWork, но в нашем случае у метода doWork аргументы отсутствуют.

3. Налаживаем связь

connect(thread, &QThread::finished, work, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &Qobject::deleteLater);

В этом случае сигналов стало меньше, в виду того, что некоторые из них принадлежали объекту Worker, который теперь не используется, однако, ничего не мешает добавить сигналы в класс HeavyWork и связаться с ними.

4. И наконец запускаем выполнение потока.

thread->start();

Метод makeThread() реализован. Запуск программы аналогичен предыдущему случаю

AnotherController anCtrl;
anCtrl.makeThread();

Результат выполнения также аналогичен первому способу. В вашем распоряжении теперь два способа для запуска потока и мы можем обсудить их достоинства и недостатки.

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

  1. Второй способ требует меньшего количества строк кода, и на один объект меньше. Он проще в реализации, но позволяет выполнять лишь один метод.
  2. Поскольку только один метод находится в другом потоке в первом способе, то остальные методы могут быть использованы для управления тяжелым объектом из других потоков. Этот факт может выступать как достоинством, так и недостатком этого метода.
  3. Например, при использовании семафоров или мьютексов во втором способе происходит блокировка всего потока, но т. к. в этом случае в отдельном потоке живет лишь один метод, то остальные могут быть вызваны в других потоках для модификации состояния объекта.
  4. С другой стороны этот способ инкапсулирует мьютексы и другие управляющие элементы внутри одного объекта, позволяя управлять ими только через методы. В обоих случаях (3 и 4) программисту следует уделить много внимания такой структуре, т. к. в некоторых случаях можно заморозить другой поток, в котором вызывается блокирующий метод тяжелого класса.
  5. Первый способ создает, выполняет и уничтожает тяжелый объект внутри себя. Такой подход более потокобезопасен т. к. жизненный цикл объектов не зависит от внешних потоков. Также весь объект целиком замирает в ожидании при засыпании потока и управление может быть осуществлено только через внешние объекты, ссылки на которые переданы в тяжелый метод.

Исходя из выше написанного, можно заключить, что ни один из них не является панацеей и не даёт 100% гарантий, но «наломать дров» в первом способе чуть сложнее, чем во втором, если у вас большой и сложный объект. Однако, в случае когда у вас маленькая функция, можно смело использовать второй метод. Окончательное решение за выбор того или иного метода — за вами! Всего доброго!

==============

В следующей статье рассмотрим вопрос синхронизации потоков и разделения доступа к общим ресурсам.