C++의 Qt 멀티스레딩에 대한 누락된 기사
게시 됨: 2022-03-11C++ 개발자는 강력한 다중 스레드 Qt 응용 프로그램을 구축하기 위해 노력하지만 이러한 모든 경쟁 조건, 동기화, 교착 상태 및 라이브록으로 인해 다중 스레드는 결코 쉬운 일이 아닙니다. 당신의 신용에, 당신은 포기하지 않고 StackOverflow를 샅샅이 뒤지는 자신을 발견합니다. 그럼에도 불구하고 12가지 다른 답변에서 적절하고 효과적인 솔루션을 선택하는 것은 특히 각 솔루션에 고유한 단점이 있다는 점을 감안할 때 매우 간단합니다.
멀티스레딩은 하나의 프로세스 컨텍스트 내에서 여러 스레드가 존재할 수 있도록 하는 광범위한 프로그래밍 및 실행 모델입니다. 이러한 스레드는 프로세스의 리소스를 공유하지만 독립적으로 실행할 수 있습니다. 스레드 프로그래밍 모델은 개발자에게 동시 실행의 유용한 추상화를 제공합니다. 멀티스레딩은 하나의 프로세스에 적용하여 멀티프로세싱 시스템에서 병렬 실행을 가능하게 할 수도 있습니다..
– 위키피디아
이 기사의 목표는 Qt 프레임워크를 사용한 동시 프로그래밍에 대한 필수 지식, 특히 가장 많이 오해받는 주제를 모으는 것입니다. 독자는 내용을 이해하기 위해 Qt 및 C++에 대한 이전 배경 지식이 있어야 합니다.
QThreadPool
과 QThread
사용 중 선택
Qt 프레임워크는 멀티스레딩을 위한 많은 도구를 제공합니다. 올바른 도구를 선택하는 것은 처음에는 어려울 수 있지만 실제로 의사 결정 트리는 두 가지 옵션으로 구성됩니다. Qt가 스레드를 관리하도록 하거나 스레드를 직접 관리하려는 것입니다. 그러나 다른 중요한 기준이 있습니다.
이벤트 루프가 필요하지 않은 작업. 특히 작업 실행 중에 신호/슬롯 메커니즘을 사용하지 않는 작업.
사용: QtConcurrent 및 QThreadPool + QRunnable.신호/슬롯을 사용하므로 이벤트 루프가 필요한 작업.
사용: 작업자 개체를 + QThread로 이동했습니다.
Qt 프레임워크의 뛰어난 유연성을 통해 "누락된 이벤트 루프" 문제를 해결하고 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; }
그러나 이러한 "해결 방법"은 위험하고 비효율적이므로 피하십시오. 스레드 풀(MyTask 실행)의 스레드 중 하나가 신호 대기로 인해 차단되면 풀에서 다른 작업을 실행할 수 없습니다.
또한 QThread::run()
메소드를 재정의하여 이벤트 루프 없이 QThread
를 실행할 수 있으며 이는 수행 중인 작업을 알고 있는 한 완벽합니다. 예를 들어, 그러한 경우에는 quit()
메소드가 작동할 것으로 기대하지 마십시오.
한 번에 하나의 작업 인스턴스 실행
한 번에 하나의 작업 인스턴스만 실행할 수 있고 동일한 작업을 실행하기 위해 보류 중인 모든 요청이 특정 대기열에서 대기하고 있는지 확인해야 한다고 상상해 보십시오. 이것은 작업이 동일한 파일에 쓰기 또는 TCP 소켓을 사용하여 패킷을 보내는 것과 같이 배타적 리소스에 액세스할 때 종종 필요합니다.
컴퓨터 과학과 생산자-소비자 패턴은 잠시 잊고 사소한 것을 생각해 봅시다. 실제 프로젝트에서 쉽게 찾을 수 있는 것입니다.
이 문제에 대한 순진한 해결책은 QMutex
를 사용하는 것입니다. 작업 함수 내에서 작업 실행을 시도하는 모든 스레드를 효과적으로 직렬화하는 뮤텍스를 획득할 수 있습니다. 이렇게 하면 한 번에 하나의 스레드만 함수를 실행할 수 있습니다. 그러나 이 솔루션은 모든 스레드가 계속 진행되기 전에 (뮤텍스에서) 차단되기 때문에 높은 경합 문제를 도입하여 성능에 영향을 줍니다. 그러한 작업을 적극적으로 사용하고 그 사이에 유용한 작업을 수행하는 많은 스레드가 있는 경우 이러한 모든 스레드는 대부분의 시간 동안 잠자기 상태가 됩니다.
void logEvent(const QString & event) { static QMutex lock; QMutexLocker locker(& lock); // high contention! logStream << event; // exclusive resource }
경합을 피하기 위해 대기열과 자체 스레드에 상주하며 대기열을 처리하는 작업자가 필요합니다. 이것은 거의 고전적인 생산자-소비자 패턴입니다. 작업자( 소비자 )는 대기열에서 하나씩 요청을 선택하고 각 생산자 는 단순히 요청을 대기열에 추가할 수 있습니다. 처음에는 간단하게 QQueue
와 QWaitCondition
을 사용하는 것을 생각할 수도 있지만 잠시만 기다려서 다음 기본 요소 없이 목표를 달성할 수 있는지 봅시다.
- 보류 중인 작업 대기열이 있으므로
QThreadPool
을 사용할 수 있습니다.
또는
-
QEventLoop
이 있기 때문에 기본QThread::run()
을 사용할 수 있습니다.
첫 번째 옵션은 QThreadPool
을 사용하는 것입니다. QThreadPool
인스턴스를 만들고 QThreadPool::setMaxThreadCount(1)
를 사용할 수 있습니다. 그런 다음 QtConcurrent::run()
을 사용하여 요청을 예약할 수 있습니다.
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; };
이 솔루션에는 한 가지 이점이 있습니다. QThreadPool::clear()
를 사용하면 예를 들어 애플리케이션을 빠르게 종료해야 할 때 보류 중인 모든 요청을 즉시 취소 할 수 있습니다. 그러나 thread-affinity 에 연결된 중요한 단점도 있습니다. logEventCore
함수는 호출마다 다른 스레드에서 실행될 가능성이 높습니다. 그리고 우리는 QTimer
에 스레드 친화도 QTcpSocket
필요한 클래스가 있다는 것을 알고 있습니다.
스레드 선호도에 대해 Qt 사양이 말하는 것: 한 스레드에서 시작된 타이머는 다른 스레드에서 중지할 수 없습니다. 그리고 소켓 인스턴스를 소유한 스레드만이 이 소켓을 사용할 수 있습니다. 이것은 타이머를 시작한 스레드에서 실행 중인 타이머를 모두 중지해야 하고 소켓을 소유한 스레드에서 QTcpSocket::close()를 호출해야 함을 의미합니다. 두 예제 모두 일반적으로 소멸자에서 실행됩니다.
더 나은 솔루션은 QThread
에서 제공하는 QEventLoop
사용에 의존합니다. 아이디어는 간단합니다. 신호/슬롯 메커니즘을 사용하여 요청을 발행하고 스레드 내부에서 실행되는 이벤트 루프는 한 번에 하나의 슬롯만 실행할 수 있도록 하는 큐 역할을 합니다.
// 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); };
LogWorker
생성자와 logEvent
의 구현은 간단하므로 여기에서는 제공하지 않습니다. 이제 스레드와 작업자 인스턴스를 관리할 서비스가 필요합니다.
// 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(); }
이 코드가 어떻게 작동하는지 논의해 봅시다:
- 생성자에서 스레드와 작업자 인스턴스를 만듭니다. 작업자는 새 스레드로 이동되기 때문에 부모를 받지 않습니다. 이 때문에 Qt는 작업자의 메모리를 자동으로 해제할 수 없으므로
QThread::finished
신호를deleteLater
슬롯에 연결하여 이를 수행해야 합니다. 또한 프록시 메소드LogService::logEvent()
를 다른 스레드로 인해Qt::QueuedConnection
모드를 사용할LogWorker::logEvent()
에 연결합니다. - 소멸자에서
quit
이벤트를 이벤트 루프의 큐에 넣습니다. 이 이벤트는 다른 모든 이벤트가 처리 된 후에 처리됩니다. 예를 들어 소멸자 호출 직전에 수백 개의logEvent()
호출을 수행한 경우 로거는 종료 이벤트를 가져오기 전에 모두 처리합니다. 물론 시간이 걸리므로 이벤트 루프가 종료될 때까지wait()
해야 합니다. 종료 이벤트 이후 에 게시되는 모든 향후 로깅 요청은 처리되지 않는다는 점을 언급할 가치가 있습니다. - 로깅 자체(
LogWorker::logEvent
)는 항상 동일한 스레드에서 수행되므로 이 접근 방식은 thread-affinity 가 필요한 클래스에 적합합니다. 동시에LogWorker
생성자와 소멸자는 메인 스레드(특히LogService
가 실행 중인 스레드)에서 실행되므로 여기서 실행 중인 코드에 대해 매우 주의해야 합니다. 특히, 동일한 스레드에서 소멸자를 실행할 수 없다면 타이머를 중지하거나 작업자의 소멸자에서 소켓을 사용하지 마십시오!
동일한 스레드에서 작업자 소멸자 실행
작업자가 타이머 또는 소켓을 처리하는 경우 소멸자가 동일한 스레드(작업자를 위해 생성하고 작업자를 이동한 스레드)에서 실행되는지 확인해야 합니다. 이것을 지원하는 명백한 방법은 QThread
를 서브클래스화하고 QThread::run()
메소드 내에서 작업자를 delete
하는 것입니다. 다음 템플릿을 고려하십시오.
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; };
이 템플릿을 사용하여 이전 예제의 LogService
를 재정의합니다.

// 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); }
이것이 어떻게 작동하는지 논의해 봅시다:
- 사용자 정의
run()
함수를 구현해야 했기 때문에LogService
를QThread
객체로 만들었습니다. 스레드의 수명 주기를 내부적으로 제어하기를 원하기 때문에QThread
의 기능에 액세스하는 것을 방지하기 위해 private 서브클래싱을 사용했습니다. -
Thread::run()
함수에서 기본QThread::run()
구현을 호출하여 이벤트 루프를 실행하고 이벤트 루프가 종료된 직후 작업자 인스턴스를 파괴합니다. 작업자의 소멸자는 동일한 스레드에서 실행됩니다. -
LogService::logEvent()
는 로깅 이벤트를 스레드의 이벤트 큐에 게시하는 프록시 함수(신호)입니다.
스레드 일시 중지 및 재개
또 다른 흥미로운 기회는 사용자 지정 스레드를 일시 중단하고 재개할 수 있다는 것입니다. 애플리케이션이 최소화되거나 잠기거나 네트워크 연결이 끊어졌을 때 일시 중단해야 하는 일부 처리를 애플리케이션에서 수행하고 있다고 상상해 보십시오. 이는 작업자가 재개될 때까지 보류 중인 모든 요청을 보유할 사용자 지정 비동기 대기열을 구축하여 달성할 수 있습니다. 그러나 가장 쉬운 솔루션을 찾고 있기 때문에 동일한 목적으로 이벤트 루프의 대기열을 (다시) 사용할 것입니다.
스레드를 일시 중단하려면 특정 대기 조건에서 기다려야 합니다. 스레드가 이런 식으로 차단되면 이벤트 루프는 이벤트를 처리하지 않으며 Qt는 큐에 keep을 넣어야 합니다. 재개되면 이벤트 루프는 누적된 모든 요청을 처리합니다. 대기 조건의 경우 QMutex가 필요한 QWaitCondition
객체를 사용하기만 하면 QMutex
. 모든 작업자가 재사용할 수 있는 일반 솔루션을 설계하려면 모든 일시 중단/재개 논리를 재사용 가능한 기본 클래스에 넣어야 합니다. SuspendableWorker
라고 합시다. 이러한 클래스는 두 가지 방법을 지원해야 합니다.
-
suspend()
는 대기 조건에서 대기 중인 스레드를 설정하는 차단 호출입니다. 일시 중단 요청을 대기열에 게시하고 처리될 때까지 기다리면 됩니다.QThread::quit()
+wait()
와 매우 유사합니다. -
resume()
은 실행을 계속하기 위해 잠자는 스레드를 깨우도록 대기 조건에 신호를 보냅니다.
인터페이스와 구현을 검토해 보겠습니다.
// 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); }
일시 중단된 스레드는 quit
이벤트를 수신하지 않습니다. 이러한 이유로 우리는 quit를 게시하기 전에 스레드를 재개하지 않는 한 이것을 바닐라 QThread
와 함께 안전하게 사용할 수 없습니다. 이것을 사용자 정의 Thread<T>
템플릿에 통합하여 방탄으로 만들어 보겠습니다.
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; };
이러한 변경으로 우리는 quit 이벤트를 게시하기 전에 스레드를 재개할 것입니다. 또한 Thread<TWorker>
는 SuspendableWorker
여부에 관계없이 모든 종류의 작업자가 전달되도록 허용합니다.
사용법은 다음과 같습니다.
LogService logService; logService.logEvent("processed event"); logService.suspend(); logService.logEvent("queued event"); logService.resume(); // "queued event" is now processed.
휘발성 대 원자
이것은 일반적으로 잘못 이해되는 주제입니다. 대부분의 사람들은 volatile
변수를 사용하여 여러 스레드에서 액세스하는 특정 플래그를 제공할 수 있으며 이것이 데이터 경쟁 조건으로부터 보호된다고 믿습니다. 그것은 거짓이고 QAtomic*
클래스(또는 std::atomic
)를 이 목적으로 사용해야 합니다.
현실적인 예를 생각해 보겠습니다. 전용 스레드에서 작동하는 TcpConnection
연결 클래스와 이 클래스가 스레드로부터 안전한 메서드인 bool isConnected()
를 내보내길 원합니다. 내부적으로 클래스는 소켓 이벤트를 수신합니다. 내부 부울 플래그를 유지하기 위해 connected
및 disconnected
됨:
// 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; }
_connected
멤버를 volatile
으로 만드는 것은 문제를 해결하지 않으며 isConnected()
를 스레드로부터 안전하게 만들지 않습니다. 이 솔루션은 99%의 시간 동안 작동하지만 나머지 1%는 당신의 삶을 악몽으로 만들 것입니다. 이 문제를 해결하려면 여러 스레드에서 변수 액세스를 보호해야 합니다. 이 목적으로 QReadWriteLocker
를 사용합시다.
// 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; }
이것은 안정적으로 작동하지만 "잠금 없는" 원자 연산을 사용하는 것만큼 빠르지는 않습니다. 세 번째 솔루션은 빠르고 스레드로부터 안전합니다(예제는 QAtomicInt
대신 std::atomic
을 사용하지만 의미적으로는 동일합니다):
// 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; }
결론
이 기사에서 우리는 Qt 프레임워크를 사용한 동시 프로그래밍에 대한 몇 가지 중요한 문제를 논의하고 특정 사용 사례를 해결하기 위한 솔루션을 설계했습니다. 원자적 프리미티브 사용, 읽기-쓰기 잠금 등과 같은 간단한 주제를 많이 고려하지 않았지만 이에 관심이 있는 경우 아래에 의견을 남기고 그러한 자습서를 요청하십시오.
Qmake 탐색에 관심이 있으시면 최근에 The Vital Guide to Qmake를 출판했습니다. 잘 읽었습니다!