Многопоточность — свойство платформы выполнять код внутри одного процесса может выполнятся “параллельно” без предписанного порядка во времени. Такой подход полезен когда отделить ресурсоёмкую задачу от остального кода. Например, читать с диска в память большой файл и не «заморозить» графический интерфейс программы. В этой статье поговорим о многопоточности в C++ и о том, как это работает во фреймворке Qt. На примере покажу как запустить в отдельном потоке только один метод объекта, оставив остальные методы «снаружи», обсудим плюсы и минусы такого подхода и как поступить в такой ситуации!
Содержание статьи 1. Как устроена многопоточность в Qt? 2. Создание потока через обертку 3. Переопределение метода run() класса QThread 4. Достоинства и недостатки
1. Как устроена многопоточность в Qt?
В Qt потоками управляет класс QThread. Он представляет собой обёртку для потоков операционной системы и предоставляет кросс-платформенный интерфейс для работы с ними.
Запустить код в отдельном потоке можно двумя способами:
- создание обёртки для нашего класса, который будет жить в отдельном потоке;
- переопределение метода run() в унаследованном от QThread классе.
Это правило делает собственником объектов тот поток, который их создал. Так легче контролировать жизненный цикл объектов и позволит избежать ошибок в работе, когда используемый объект внезапно был удален в другом потоке.
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(), вот он то нам и нужен, давайте его пошагово реализуем!
- Создаем экземпляры обработчика 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 в подавляющем большинстве случаев, поэтому пойдем окольными путями
Обновлено 23.02.22 Информация о нарушении принципов SOLID не совсем корректна. Подробнее об этом способе создания потоков я рассказал в статье Многопоточность в Qt через наследование QThread
Вместо наследования создадим свой класс и передадим один из его методов на выполнение в отдельный поток. В качестве нагрузки используем уже знакомый класс 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. Достоинства и недостатки
- Второй способ требует меньшего количества строк кода, и на один объект меньше. Он проще в реализации, но позволяет выполнять лишь один метод.
- Поскольку только один метод находится в другом потоке в первом способе, то остальные методы могут быть использованы для управления тяжелым объектом из других потоков. Этот факт может выступать как достоинством, так и недостатком этого метода.
- Например, при использовании семафоров или мьютексов во втором способе происходит блокировка всего потока, но т. к. в этом случае в отдельном потоке живет лишь один метод, то остальные могут быть вызваны в других потоках для модификации состояния объекта.
- С другой стороны этот способ инкапсулирует мьютексы и другие управляющие элементы внутри одного объекта, позволяя управлять ими только через методы. В обоих случаях (3 и 4) программисту следует уделить много внимания такой структуре, т. к. в некоторых случаях можно заморозить другой поток, в котором вызывается блокирующий метод тяжелого класса.
- Первый способ создает, выполняет и уничтожает тяжелый объект внутри себя. Такой подход более потокобезопасен т. к. жизненный цикл объектов не зависит от внешних потоков. Также весь объект целиком замирает в ожидании при засыпании потока и управление может быть осуществлено только через внешние объекты, ссылки на которые переданы в тяжелый метод.
Исходя из выше написанного, можно заключить, что ни один из них не является панацеей и не даёт 100% гарантий, но «наломать дров» в первом способе чуть сложнее, чем во втором, если у вас большой и сложный объект. Однако, в случае когда у вас маленькая функция, можно смело использовать второй метод. Окончательное решение за выбор того или иного метода — за вами! Всего доброго!
==============
В следующей статье рассмотрим вопрос синхронизации потоков и разделения доступа к общим ресурсам.