O artigo que faltava sobre o Qt Multithreading em C++

Publicados: 2022-03-11

Os desenvolvedores de C++ se esforçam para construir aplicativos Qt multithread robustos, mas o multithreading nunca foi fácil com todas essas condições de corrida, sincronização e deadlocks e livelocks. Para seu crédito, você não desiste e se vê vasculhando o StackOverflow. No entanto, escolher a solução certa e funcional de uma dúzia de respostas diferentes não é trivial, especialmente porque cada solução vem com suas próprias desvantagens.

Multithreading é um modelo difundido de programação e execução que permite a existência de vários threads dentro do contexto de um processo. Essas threads compartilham os recursos do processo, mas são capazes de executar de forma independente. O modelo de programação encadeada fornece aos desenvolvedores uma abstração útil de execução simultânea. O multithreading também pode ser aplicado a um processo para permitir a execução paralela em um sistema de multiprocessamento.

– Wikipédia

O objetivo deste artigo é agregar o conhecimento essencial sobre programação concorrente com o framework Qt, especificamente os tópicos mais incompreendidos. Espera-se que o leitor tenha experiência prévia em Qt e C++ para entender o conteúdo.

Escolhendo entre usar QThreadPool e QThread

O framework Qt oferece muitas ferramentas para multithreading. Escolher a ferramenta certa pode ser um desafio no começo, mas, na verdade, a árvore de decisão consiste em apenas duas opções: você quer que o Qt gerencie as threads para você ou você quer gerenciar as threads sozinho. No entanto, existem outros critérios importantes:

  1. Tarefas que não precisam do loop de eventos. Especificamente, as tarefas que não estão usando o mecanismo de sinal/slot durante a execução da tarefa.
    Use: QtConcurrent e QThreadPool + QRunnable.

  2. Tarefas que usam sinal/slots e, portanto, precisam do loop de eventos.
    Uso: Objetos de trabalho movidos para + QThread.

A grande flexibilidade do framework Qt permite que você resolva o problema do “loop de evento ausente” e adicione um ao QRunnable :

 class MyTask : public QObject, public QRunnable { Q_OBJECT public: void MyTask::run() { _loop.exec(); } public slots: // you need a signal connected to this slot to exit the loop, // otherwise the thread running the loop would remain blocked... void finishTask() { _loop.exit(); } private: QEventLoop _loop; }

Tente evitar essas “soluções alternativas”, porém, porque elas são perigosas e não eficientes: se um dos threads do pool de threads (executando MyTask) estiver bloqueado devido à espera de um sinal, ele não poderá executar outras tarefas do pool.

texto alternativo

Você também pode executar um QThread sem nenhum loop de eventos substituindo QThread::run() e isso está perfeitamente bem desde que você saiba o que está fazendo. Por exemplo, não espere que o método quit() funcione nesse caso.

Executando uma instância de tarefa por vez

Imagine que você precisa garantir que apenas uma instância de tarefa por vez possa ser executada e que todas as solicitações pendentes para executar a mesma tarefa estejam aguardando em uma determinada fila. Isso geralmente é necessário quando uma tarefa está acessando um recurso exclusivo, como gravar no mesmo arquivo ou enviar pacotes usando o soquete TCP.

Vamos esquecer a ciência da computação e o padrão produtor-consumidor por um momento e considerar algo trivial; algo que pode ser facilmente encontrado em projetos reais.

Uma solução ingênua para este problema poderia ser usar um QMutex . Dentro da função de tarefa, você pode simplesmente adquirir o mutex efetivamente serializando todos os threads que tentam executar a tarefa. Isso garantiria que apenas um thread por vez pudesse executar a função. No entanto, essa solução afeta o desempenho ao introduzir um problema de alta contenção porque todos esses threads seriam bloqueados (no mutex) antes que pudessem prosseguir. Se você tiver muitos encadeamentos usando ativamente essa tarefa e fazendo algum trabalho útil no meio, todos esses encadeamentos estarão apenas dormindo a maior parte do tempo.

 void logEvent(const QString & event) { static QMutex lock; QMutexLocker locker(& lock); // high contention! logStream << event; // exclusive resource }

Para evitar contenção, precisamos de uma fila e de um trabalhador que viva em seu próprio encadeamento e processe a fila. Esse é praticamente o padrão clássico produtor-consumidor . O trabalhador ( consumidor ) estaria selecionando solicitações da fila uma a uma, e cada produtor pode simplesmente adicionar suas solicitações à fila. Parece simples no começo e você pode pensar em usar QQueue e QWaitCondition , mas espere e vamos ver se podemos atingir o objetivo sem esses primitivos:

  • Podemos usar o QThreadPool , pois possui uma fila de tarefas pendentes

Ou

  • Podemos usar o padrão QThread::run() porque tem QEventLoop

A primeira opção é usar QThreadPool . Podemos criar uma instância QThreadPool e usar QThreadPool::setMaxThreadCount(1) . Então podemos usar QtConcurrent::run() para agendar requisições:

 class Logger: public QObject { public: explicit Logger(QObject *parent = nullptr) : QObject(parent) { threadPool.setMaxThreadCount(1); } void logEvent(const QString &event) { QtConcurrent::run(&threadPool, [this, event]{ logEventCore(event); }); } private: void logEventCore(const QString &event) { logStream << event; } QThreadPool threadPool; };

Esta solução tem um benefício: QThreadPool::clear() permite que você cancele instantaneamente todas as solicitações pendentes, por exemplo, quando seu aplicativo precisa ser encerrado rapidamente. No entanto, há também uma desvantagem significativa que está conectada à afinidade de thread : a função logEventCore provavelmente será executada em diferentes threads de chamada para chamada. E sabemos que o Qt tem algumas classes que requerem afinidade de thread : QTimer , QTcpSocket e possivelmente outras.

O que a especificação Qt diz sobre afinidade de thread: temporizadores iniciados em um thread, não podem ser interrompidos em outro thread. E apenas o thread que possui uma instância de soquete pode usar esse soquete. Isso implica que você deve parar qualquer cronômetro em execução no thread que os iniciou e deve chamar QTcpSocket::close() no thread que possui o soquete. Ambos os exemplos geralmente são executados em destruidores.

A melhor solução depende do uso do QEventLoop fornecido pelo QThread . A ideia é simples: usamos um mecanismo de sinal/slot para emitir requisições, e o event loop rodando dentro da thread servirá como uma fila permitindo que apenas um slot por vez seja executado.

 // the worker that will be moved to a thread class LogWorker: public QObject { Q_OBJECT public: explicit LogWorker(QObject *parent = nullptr); public slots: // this slot will be executed by event loop (one call at a time) void logEvent(const QString &event); };

A implementação do construtor LogWorker e logEvent é direta e, portanto, não é fornecida aqui. Agora precisamos de um serviço que irá gerenciar o thread e a instância do trabalhador:

 // interface class LogService : public QObject { Q_OBJECT public: explicit LogService(QObject *parent = nullptr); ~LogService(); signals: // to use the service, just call this signal to send a request: // logService->logEvent("event"); void logEvent(const QString &event); private: QThread *thread; LogWorker *worker; }; // implementation LogService::LogService(QObject *parent) : QObject(parent) { thread = new QThread(this); worker = new LogWorker; worker->moveToThread(thread); connect(this, &LogService::logEvent, worker, &LogWorker::logEvent); connect(thread, &QThread::finished, worker, &QObject::deleteLater); thread->start(); } LogService::~LogService() { thread->quit(); thread->wait(); } 

texto alternativo

Vamos discutir como esse código funciona:

  • No construtor, criamos uma instância de thread e de trabalho. Observe que o trabalhador não recebe um pai, pois ele será movido para o novo thread. Por causa disso, o Qt não poderá liberar a memória do trabalhador automaticamente e, portanto, precisamos fazer isso conectando o sinal QThread::finished ao slot deleteLater . Também conectamos o método proxy LogService::logEvent() ao LogWorker::logEvent() que usará o modo Qt::QueuedConnection devido a diferentes threads.
  • No destruidor, colocamos o evento quit na fila do loop de eventos. Este evento será tratado depois que todos os outros eventos forem tratados. Por exemplo, se tivermos feito centenas de chamadas logEvent() antes da chamada do destruidor, o logger tratará de todas elas antes de buscar o evento quit. Isso leva tempo, é claro, então devemos wait() até que o loop de eventos termine. Vale ressaltar que todas as futuras solicitações de log postadas após o evento quit nunca serão processadas.
  • O log em si ( LogWorker::logEvent ) sempre será feito no mesmo thread, portanto, essa abordagem está funcionando bem para classes que exigem afinidade de thread . Ao mesmo tempo, o construtor e o destruidor do LogWorker são executados no encadeamento principal (especificamente o encadeamento em que o LogService está sendo executado) e, portanto, você precisa ter muito cuidado com o código que está executando lá. Especificamente, não pare os temporizadores ou use soquetes no destruidor do trabalhador, a menos que você possa estar executando o destruidor no mesmo encadeamento!

Executando o destruidor do trabalhador no mesmo thread

Se o seu trabalhador estiver lidando com temporizadores ou soquetes, você precisa garantir que o destruidor seja executado no mesmo thread (o thread que você criou para o trabalhador e para onde você moveu o trabalhador). A maneira óbvia de suportar isso é criar uma subclasse de QThread e delete o trabalhador dentro QThread::run() . Considere o seguinte modelo:

 template <typename TWorker> class Thread : QThread { public: explicit Thread(TWorker *worker, QObject *parent = nullptr) : QThread(parent), _worker(worker) { _worker->moveToThread(this); start(); } ~Thread() { quit(); wait(); } TWorker worker() const { return _worker; } protected: void run() override { QThread::run(); delete _worker; } private: TWorker *_worker; };

Usando este modelo, redefinimos LogService do exemplo anterior:

 // interface class LogService : public Thread<LogWorker> { Q_OBJECT public: explicit LogService(QObject *parent = nullptr); signals: void **logEvent**(const QString &event); }; // implementation LogService::**LogService**(QObject *parent) : Thread<LogWorker>(new LogWorker, parent) { connect(this, &LogService::logEvent, worker(), &LogWorker::logEvent); }

Vamos discutir como isso deve funcionar:

  • Fizemos LogService para ser o objeto QThread porque precisávamos implementar a função run() personalizada. Usamos subclasses privadas para evitar o acesso às funções do QThread , pois queremos controlar o ciclo de vida do thread internamente.
  • Na função Thread::run() , executamos o loop de eventos chamando a implementação padrão QThread::run() e destruímos a instância do trabalhador logo após o encerramento do loop de eventos. Observe que o destruidor do trabalhador é executado no mesmo thread.
  • LogService::logEvent() é a função proxy (sinal) que postará o evento de log na fila de eventos do thread.

Pausando e retomando os tópicos

Outra oportunidade interessante é poder suspender e retomar nossos tópicos personalizados. Imagine que seu aplicativo está fazendo algum processamento que precisa ser suspenso quando o aplicativo é minimizado, bloqueado ou simplesmente perde a conexão de rede. Isso pode ser feito criando uma fila assíncrona personalizada que reterá todas as solicitações pendentes até que o trabalho seja retomado. No entanto, como estamos procurando soluções mais fáceis, usaremos (novamente) a fila do loop de eventos para o mesmo propósito.

Para suspender um encadeamento, claramente precisamos que ele aguarde uma determinada condição de espera. Se o thread estiver bloqueado dessa maneira, seu loop de eventos não está manipulando nenhum evento e o Qt deve colocar keep na fila. Uma vez retomado, o loop de eventos estará processando todas as solicitações acumuladas. Para a condição de espera, simplesmente usamos o objeto QWaitCondition que também requer um QMutex . Para projetar uma solução genérica que possa ser reutilizada por qualquer trabalhador, precisamos colocar toda a lógica de suspensão/retomada em uma classe base reutilizável. Vamos chamá-lo de SuspendableWorker . Tal classe deve suportar dois métodos:

  • suspend() seria uma chamada de bloqueio que define o thread esperando em uma condição de espera. Isso seria feito postando uma solicitação de suspensão na fila e aguardando até que ela fosse tratada. Muito parecido com QThread::quit() + wait() .
  • resume() sinalizaria a condição de espera para acordar o thread adormecido para continuar sua execução.

Vamos revisar a interface e a implementação:

 // interface class SuspendableWorker : public QObject { Q_OBJECT public: explicit SuspendableWorker(QObject *parent = nullptr); ~SuspendableWorker(); // resume() must be called from the outer thread. void resume(); // suspend() must be called from the outer thread. // the function would block the caller's thread until // the worker thread is suspended. void suspend(); private slots: void suspendImpl(); private: QMutex _waitMutex; QWaitCondition _waitCondition; };
 // implementation SuspendableWorker::SuspendableWorker(QObject *parent) : QObject(parent) { _waitMutex.lock(); } SuspendableWorker::~SuspendableWorker() { _waitCondition.wakeAll(); _waitMutex.unlock(); } void SuspendableWorker::resume() { _waitCondition.wakeAll(); } void SuspendableWorker::suspend() { QMetaObject::invokeMethod(this, &SuspendableWorker::suspendImpl); // acquiring mutex to block the calling thread _waitMutex.lock(); _waitMutex.unlock(); } void SuspendableWorker::suspendImpl() { _waitCondition.wait(&_waitMutex); }

Lembre-se de que um thread suspenso nunca receberá um evento de quit . Por esse motivo, não podemos usar isso com segurança com o QThread vanilla, a menos que retomemos o tópico antes de postar quit. Vamos integrar isso em nosso modelo Thread<T> personalizado para torná-lo à prova de balas.

texto alternativo

 template <typename TWorker> class Thread : QThread { public: explicit Thread(TWorker *worker, QObject *parent = nullptr) : QThread(parent), _worker(worker) { _worker->moveToThread(this); start(); } ~Thread() { resume(); quit(); wait(); } void suspend() { auto worker = qobject_cast<SuspendableWorker*>(_worker); if (worker != nullptr) { worker->suspend(); } } void resume() { auto worker = qobject_cast<SuspendableWorker*>(_worker); if (worker != nullptr) { worker->resume(); } } TWorker worker() const { return _worker; } protected: void run() override { QThread::*run*(); delete _worker; } private: TWorker *_worker; };

Com essas alterações, retomaremos o tópico antes de postar o evento quit. Além disso, Thread<TWorker> ainda permite que qualquer tipo de trabalhador seja passado, independentemente de ser um SuspendableWorker ou não.

O uso seria o seguinte:

 LogService logService; logService.logEvent("processed event"); logService.suspend(); logService.logEvent("queued event"); logService.resume(); // "queued event" is now processed.

volátil vs atômico

Este é um tópico comumente mal compreendido. A maioria das pessoas acredita que variáveis volatile podem ser usadas para servir certos sinalizadores acessados ​​por vários threads e que isso preserva as condições de corrida de dados. Isso é falso, e as classes QAtomic* (ou std::atomic ) devem ser usadas para essa finalidade.

Vamos considerar um exemplo realista: uma classe de conexão TcpConnection que funciona em uma thread dedicada, e queremos que essa classe exporte um método thread-safe: bool isConnected() . Internamente, a classe estará escutando eventos de soquete: connected e disconnected para manter um sinalizador booleano interno:

 // pseudo-code, won't compile class TcpConnection : QObject { Q_OBJECT public: // this is not thread-safe! bool isConnected() const { return _connected; } private slots: void handleSocketConnected() { _connected = true; } void handleSocketDisconnected() { _connected = false; } private: bool _connected; }

Tornar o membro _connected volatile não resolverá o problema e não tornará isConnected() thread-safe. Essa solução funcionará 99% das vezes, mas o 1% restante tornará sua vida um pesadelo. Para corrigir isso, precisamos proteger o acesso variável de vários threads. Vamos usar QReadWriteLocker para esta finalidade:

 // pseudo-code, won't compile class TcpConnection : QObject { Q_OBJECT public: bool isConnected() const { QReadLocker locker(&_lock); return _connected; } private slots: void handleSocketConnected() { QWriteLocker locker(&_lock); _connected = true; } void handleSocketDisconnected() { QWriteLocker locker(&_lock); _connected = false; } private: QReadWriteLocker _lock; bool _connected; }

Isso funciona de forma confiável, mas não tão rápido quanto usar operações atômicas “sem bloqueio”. A terceira solução é rápida e segura para threads (o exemplo está usando std::atomic em vez de QAtomicInt , mas semanticamente estes são idênticos):

 // pseudo-code, won't compile class TcpConnection : QObject { Q_OBJECT public: bool isConnected() const { return _connected; } private slots: void handleSocketConnected() { _connected = true; } void handleSocketDisconnected() { _connected = false; } private: std::atomic<bool> _connected; }

Conclusão

Neste artigo, discutimos várias preocupações importantes sobre programação concorrente com o framework Qt e projetamos soluções para tratar de casos de uso específicos. Não consideramos muitos dos tópicos simples, como o uso de primitivas atômicas, bloqueios de leitura e gravação e muitos outros, mas se você estiver interessado neles, deixe seu comentário abaixo e peça um tutorial desse tipo.

Se você estiver interessado em explorar o Qmake, também publiquei recentemente The Vital Guide to Qmake. É uma ótima leitura!