Размышление об Active Object в контексте Qt6. Часть 2.5
Ссылки на статьи
Предисловие
Статья выпущена как дополнение к предыдущей и показывает, как можно сделать Active object, работающий асинхронно в среде Qt, но при этом не использующий события.
Иногда бывает слишком обременительно документировать использование кодов событий, поэтому желательно делать всё через сигналы.
Как это работает
Исходя из документации Qt, при вызове QObject: connect, можно указать Qt: ConnectionType. Если вкратце, то нас волнует возможность определять с помощью этого перечисление указывать, как вызывать слот.
Qt: DirectConnection — слот будет вызван напрямую при излучении сигнала, к которому он привязан. При этом обработка слота будет проводиться потоком, вызвавшим сигнал.
Qt: QueuedConnection — излучение сигнала будет преобразовано в событие, которое будет отправлено в очередь событий. Точно также, как мы это делали в предыдущей статье. Обработку слота при этом проводит поток-владелец QObject-а.
Qt: AutoConnection — если излучатель сигнала (sender) и его получатель (receiver) находятся в одном потоке, то слот этого QObject-а будет обработан напрямую потоком-владельцем. Если же они находятся в разных потоках, то сигнал пакуется в событие, которое помещается в очередь событий. Таким образом гарантируется, что слот всегда будет обрабатываться потоком-владельцем QObject-а.
Остальные перечислители рассматривать здесь я не буду, поскольку они не нужны нам в реализации класса Active object.
Нам как раз идеально подойдёт Qt: AutoConnection.
Реализуем
Для начала обернём задачу вывода на экран в целостную структуру. Эта структура является внутренней для класса SignalBasedAsyncQDebugPrinter, т.к. больше нигде не нужна. Получается практически тот же самый PrinterMessageEvent из предыдущей части.
PrinterMessage
class PrinterMessage {
private:
QPromise m_promise;
const QString m_message;
public:
PrinterMessage(const QString &message);
const QString& message() const;
QPromise& promise();
};
SignalBasedAsyncQDebugPrinter::PrinterMessage::PrinterMessage(const QString &message)
:m_message{ message } {}
const QString& SignalBasedAsyncQDebugPrinter::PrinterMessage::message() const {
return m_message;
}
QPromise &SignalBasedAsyncQDebugPrinter::PrinterMessage::promise() {
return m_promise;
}
Теперь опишем сам SignalBasedAsyncQDebugPrinter. Реализация очень близка к EventBasedAsyncQDebugPrinter из предыдущего примера с той разницей, что используется сигнально-слотовое соединение, а значит, нужно иметь собственную очередь для передачи задач.
EventBasedAsyncQDebugPrinter
class SignalBasedAsyncQDebugPrinter : public QObject {
private:
Q_OBJECT
class PrinterMessage {/*...*/};
private:
std::queue m_messages;
std::mutex m_mutex;
public:
explicit SignalBasedAsyncQDebugPrinter(QObject *parent = nullptr);
QFuture print(const QString& message);
private slots:
void handleNextMessage();
signals:
void nextMessageReceived();
};
SignalBasedAsyncQDebugPrinter::SignalBasedAsyncQDebugPrinter(QObject *parent)
:QObject{ parent } {
QObject::connect(this, &SignalBasedAsyncQDebugPrinter::nextMessageReceived, this, &SignalBasedAsyncQDebugPrinter::handleNextMessage);
}
QFuture SignalBasedAsyncQDebugPrinter::print(const QString &message) {
auto task = PrinterMessage{ message };
auto future = task.promise().future();
{
std::lock_guard locker{ m_mutex };
m_messages.emplace(std::move(task));
}
emit this->nextMessageReceived();
return future;
}
void SignalBasedAsyncQDebugPrinter::handleNextMessage() {
std::queue buffer;
{
std::lock_guard locker{ m_mutex };
buffer.swap(m_messages);
}
while(not buffer.empty()) {
qDebug() << buffer.front().message();
buffer.front().promise().finish();
buffer.pop();
}
}
Для connect также существует перегрузка вида
connect(pointer, &SomeClass::signal, &SomeClass::slot);
Эта форма существует специально для связывания сигнала и слота одного и того же объекта. Но нам она не подходит, поскольку будет преобразована в
connect(pointer, &SomeClass::signal,pointer, &SomeClass::slot, Qt::DirectConnection);
В целом и полностью код похож на код event-based active object из предыдущей статьи.
В методе print мы создаём задачу, под блокировкой помещаем её в очередь, уведомляем сами себя через сигнал о том, что в очереди лежит новая задача и возвращаем промис на неё.
В методе handleNextMessage мы под блокировкой забираем всю очередь задач и обрабатываем её в цикле.
Немного о блокировках
Отдельно стоит отметить блокировку на 28-й строке в методе print. Если не снять блокировку до того, как вызвать emit this→nextMessageReceived ();, то если метод print вызван потоком-владельцем SignalBasedAsyncQDebugPrinter, то тут же напрямую будет вызван метод handleNextMessage, который снова попытается захватить тот же самый мьютекс, в результате чего мы получаем deadlock.
Решить это можно несколькими путями.
Самый простой — в принципе запретить прямой вызов слота из сигнала. Для этого нужно вызывать метод connect в конструкторе с флагом Qt: QueuedConnection. Тогда даже независимо от того, какой поток вызвал print, слот будет вызван не сразу, а только по прошествии события через цикл событий.
QObject: connect с Qt: ConnectionType: QueuedConnection
QObject::connect(this, &SignalBasedAsyncQDebugPrinter::nextMessageReceived, &SignalBasedAsyncQDebugPrinter::handleNextMessage, Qt::ConnectionType::QueuedConnection);
Второй способ — тот, который представлен в реализации выше: просто обернуть lock_guard в лишний скоуп, что даст нам RAII0-разблокировку.
Третий способ — немного облагороженный второй. Нужно добавить метода для добавления задачи в очередь и забирания всей очереди. Мьютекс будут использовать только эти два метода. Если я где-то ошибусь — просьба поправить.
Реализация с двумя служебными методами
class SignalBasedAsyncQDebugPrinter : public QObject {
//Всё то же самое, что и раньше
//...
private:
void emplaceTaskInQueue(PrinterMessage&& task);
std::queue takeTaskQueue();
};
QFuture SignalBasedAsyncQDebugPrinter::print(const QString &message) {
auto task = PrinterMessage{ message };
auto future = task.promise().future();
emplaceTaskInQueue(std::move(task));
emit this->nextMessageReceived();
return future;
}
void SignalBasedAsyncQDebugPrinter::handleNextMessage() {
std::queue buffer{ takeTaskQueue() };
while(not buffer.empty()) {
qDebug() << buffer.front().message();
buffer.front().promise().finish();
buffer.pop();
}
}
void SignalBasedAsyncQDebugPrinter::emplaceTaskInQueue(PrinterMessage &&task) {
std::lock_guard locker{ m_mutex };
m_messages.emplace(std::move(task));
}
std::queue SignalBasedAsyncQDebugPrinter::takeTaskQueue() {
std::queue buffer;
std::lock_guard locker{ m_mutex };
m_messages.swap(buffer);
return buffer;//NRVO, копирований нет
}
Заключение
Задача не использовать события зачастую возникает, когда вы пишете собственную библиотеку. Тогда, чтобы не пересекаться с событиями, определяемыми вашими пользователями, приходится вовсе отказываться от их использования.
Этот класс является повторением класса из предыдущей статьи, но не использует события, за счёт чего такая конструкция, возможно, станет более приемлемой в некоторых ситуациях.
Код примера, как обычно, на GitHub.