المقالة المفقودة حول Qt Multithreading في C ++

نشرت: 2022-03-11

يسعى مطورو C ++ إلى إنشاء تطبيقات Qt قوية ومتعددة الخيوط ، لكن تعدد مؤشرات الترابط لم يكن أبدًا سهلاً مع كل ظروف السباق والمزامنة والمآزق والقيود. حسب رصيدك ، لا تستسلم وتجد نفسك تبحث في StackOverflow. ومع ذلك ، فإن اختيار الحل الصحيح والعملي من بين عشرات الإجابات المختلفة ليس بالأمر السهل ، لا سيما بالنظر إلى أن كل حل يأتي مع عيوبه الخاصة.

يعد Multithreading نموذج برمجة وتنفيذ واسع النطاق يسمح بوجود خيوط متعددة في سياق عملية واحدة. تشترك هذه الخيوط في موارد العملية ولكنها قادرة على التنفيذ بشكل مستقل. يوفر نموذج البرمجة المترابطة للمطورين فكرة مجردة مفيدة للتنفيذ المتزامن. يمكن أيضًا تطبيق Multithreading على عملية واحدة لتمكين التنفيذ المتوازي على نظام متعدد المعالجات ..

- ويكيبيديا

الهدف من هذه المقالة هو تجميع المعرفة الأساسية حول البرمجة المتزامنة مع إطار عمل Qt ، وتحديدًا الموضوعات التي يساء فهمها. من المتوقع أن يكون للقارئ خلفية سابقة في Qt و C ++ لفهم المحتوى.

الاختيار بين استخدام QThreadPool و QThread

يوفر إطار عمل Qt العديد من الأدوات لتعدد مؤشرات الترابط. قد يكون اختيار الأداة المناسبة أمرًا صعبًا في البداية ، ولكن في الواقع ، تتكون شجرة القرار من خيارين فقط: إما أن تريد Qt لإدارة سلاسل الرسائل نيابة عنك ، أو تريد إدارة سلاسل الرسائل بنفسك. ومع ذلك ، هناك معايير مهمة أخرى:

  1. المهام التي لا تحتاج إلى حلقة الحدث. على وجه التحديد ، المهام التي لا تستخدم آلية الإشارة / الفتحة أثناء تنفيذ المهمة.
    الاستخدام: QtConcurrent و QThreadPool + QRunnable.

  2. المهام التي تستخدم الإشارات / الفواصل الزمنية وبالتالي تحتاج إلى حلقة الحدث.
    الاستخدام: تم نقل كائنات العامل إلى + 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 بدون أي حلقة حدث عن طريق تجاوز QThread::run() وهذا جيد تمامًا طالما أنك تعرف ما تفعله. على سبيل المثال ، لا تتوقع أن تعمل الطريقة quit() في مثل هذه الحالة.

تشغيل مثيل مهمة واحدة في كل مرة

تخيل أنك بحاجة إلى التأكد من أنه يمكن تنفيذ نسخة مهمة واحدة فقط في كل مرة وأن جميع الطلبات المعلقة لتشغيل نفس المهمة تنتظر في قائمة انتظار معينة. غالبًا ما يكون هذا مطلوبًا عندما تصل مهمة ما إلى مورد حصري ، مثل الكتابة إلى نفس الملف أو إرسال الحزم باستخدام مقبس TCP.

دعنا ننسى علوم الكمبيوتر ونمط المنتج والمستهلك للحظة ونفكر في شيء تافه ؛ شيء يمكن العثور عليه بسهولة في المشاريع الحقيقية.

يمكن أن يكون الحل الساذج لهذه المشكلة هو استخدام QMutex . داخل وظيفة المهمة ، يمكنك ببساطة الحصول على كائن المزامنة (mutex) الذي يسلسل بشكل فعال جميع سلاسل الرسائل التي تحاول تشغيل المهمة. هذا من شأنه أن يضمن أن مؤشر ترابط واحد فقط في كل مرة يمكنه تشغيل الوظيفة. ومع ذلك ، يؤثر هذا الحل على الأداء من خلال تقديم مشكلة تنافس عالية لأنه سيتم حظر كل هذه الخيوط (على كائن المزامنة) قبل أن يتمكنوا من المتابعة. إذا كان لديك العديد من سلاسل الرسائل التي تستخدم مثل هذه المهمة بنشاط وتقوم ببعض الوظائف المفيدة بينهما ، فستكون كل هذه الخيوط نائمة معظم الوقت.

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

لتجنب الخلاف ، نحتاج إلى قائمة انتظار وعامل يعيش في مؤشر ترابط خاص به ومعالجة قائمة الانتظار. هذا إلى حد كبير هو النمط الكلاسيكي للمنتِج والمستهلك . سيختار العامل ( المستهلك ) الطلبات من قائمة الانتظار واحدًا تلو الآخر ، ويمكن لكل منتج ببساطة إضافة طلباته إلى قائمة الانتظار. يبدو الأمر بسيطًا في البداية وقد تفكر في استخدام QQueue و QWaitCondition ، لكن انتظر ودعنا نرى ما إذا كان بإمكاننا تحقيق الهدف بدون هذه العناصر الأولية:

  • يمكننا استخدام QThreadPool لأنه يحتوي على قائمة انتظار من المهام المعلقة

أو

  • يمكننا استخدام QThread::run() الافتراضي لأنه يحتوي على QEventLoop

الخيار الأول هو استخدام 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() بإلغاء جميع الطلبات المعلقة على الفور ، على سبيل المثال عندما يحتاج تطبيقك إلى الإغلاق بسرعة. ومع ذلك ، هناك أيضًا عيب كبير مرتبط بمؤشر ترابط التقارب : من المحتمل أن يتم تنفيذ وظيفة logEventCore في مؤشرات ترابط مختلفة من استدعاء إلى استدعاء. ونحن نعلم أن Qt لديها بعض الفئات التي تتطلب تقارب الخيط : QTimer و QTcpSocket وربما البعض الآخر.

ما تقوله مواصفات Qt حول تقارب مؤشر الترابط: بدأت المؤقتات في سلسلة محادثات واحدة ، ولا يمكن إيقافها من سلسلة رسائل أخرى. وفقط الخيط الذي يمتلك مثيل مأخذ يمكنه استخدام هذا المقبس. هذا يعني أنه يجب عليك إيقاف أي مؤقتات قيد التشغيل في مؤشر الترابط الذي بدأها ويجب عليك استدعاء QTcpSocket :: close () في مؤشر الترابط الذي يمتلك المقبس. يتم تنفيذ كلا المثالين عادة في المدمرات.

يعتمد الحل الأفضل على استخدام QEventLoop المقدم من QThread . الفكرة بسيطة: نستخدم آلية إشارة / فتحة لإصدار الطلبات ، وستعمل حلقة الحدث التي تعمل داخل الخيط كقائمة انتظار تسمح بتنفيذ فتحة واحدة فقط في كل مرة.

 // 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 constructor و 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 finish to deleteLater slot. نقوم أيضًا بتوصيل طريقة الوكيل LogService::logEvent() بـ LogWorker::logEvent() والتي ستستخدم وضع Qt::QueuedConnection نظرًا لوجود مؤشرات ترابط مختلفة.
  • في أداة التدمير ، نضع حدث quit في قائمة انتظار حلقة الحدث. سيتم التعامل مع هذا الحدث بعد معالجة كافة الأحداث الأخرى. على سبيل المثال ، إذا قمنا بإجراء المئات من logEvent() قبل استدعاء التدمير ، فسيقوم المسجل بمعالجتها جميعًا قبل أن يجلب حدث quit. هذا يستغرق وقتًا ، بالطبع ، لذلك يجب أن wait() حتى تنتهي حلقة الحدث. من الجدير بالذكر أن جميع طلبات التسجيل المستقبلية المنشورة بعد حدث الإنهاء لن تتم معالجتها أبدًا.
  • سيتم دائمًا إجراء التسجيل نفسه ( LogWorker::logEvent ) في نفس مؤشر الترابط ، وبالتالي فإن هذا الأسلوب يعمل بشكل جيد للفئات التي تتطلب تقارب مؤشر الترابط . في الوقت نفسه ، يتم تنفيذ مُنشئ و مدمر LogWorker في الخيط الرئيسي (على وجه التحديد تشغيل LogService في مؤشر الترابط) ، وبالتالي ، يجب أن تكون حذرًا للغاية بشأن الكود الذي تقوم بتشغيله هناك. على وجه التحديد ، لا توقف مؤقتات أو تستخدم مآخذ في تدمير العامل ما لم يكن بإمكانك تشغيل المدمر في نفس الموضوع!

تنفيذ إتلاف العامل في نفس الموضوع

إذا كان العامل الخاص بك يتعامل مع أجهزة ضبط الوقت أو المقابس ، فأنت بحاجة إلى التأكد من تنفيذ المدمر في نفس مؤشر الترابط (الخيط الذي أنشأته للعامل والمكان الذي نقلت العامل إليه). الطريقة الواضحة لدعم ذلك هي الفئة الفرعية QThread delete العامل داخل QThread::run() . ضع في اعتبارك النموذج التالي:

 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); }

دعونا نناقش كيف من المفترض أن يعمل هذا:

  • لقد جعلنا LogService هو كائن QThread لأننا كنا بحاجة إلى تنفيذ وظيفة run() . استخدمنا تصنيفًا فرعيًا خاصًا لمنع الوصول إلى وظائف QThread لأننا نريد التحكم في دورة حياة الخيط داخليًا.
  • في دالة Thread::run() ، نقوم بتشغيل حلقة الحدث عن طريق استدعاء تطبيق QThread::run() الافتراضي ، وإتلاف مثيل العامل مباشرة بعد إنهاء حلقة الحدث. لاحظ أنه يتم تنفيذ أداة تدمير العامل في نفس مؤشر الترابط.
  • LogService::logEvent() هي وظيفة الوكيل (إشارة) التي ستنشر حدث التسجيل إلى قائمة انتظار حدث مؤشر الترابط.

وقفة واستئناف المواضيع

فرصة أخرى مثيرة للاهتمام هي أن تكون قادرًا على تعليق واستئناف سلاسل المحادثات المخصصة الخاصة بنا. تخيل أن تطبيقك يقوم ببعض المعالجة التي يجب تعليقها عندما يتم تصغير التطبيق أو قفله أو فقد الاتصال بالشبكة. يمكن تحقيق ذلك من خلال إنشاء قائمة انتظار غير متزامنة مخصصة ستحتفظ بجميع الطلبات المعلقة حتى يتم استئناف العامل. ومع ذلك ، نظرًا لأننا نبحث عن أسهل الحلول ، فسنستخدم (مرة أخرى) قائمة انتظار حلقة الحدث لنفس الغرض.

لتعليق سلسلة رسائل ، من الواضح أننا نحتاجها للانتظار في حالة انتظار معينة. إذا تم حظر الخيط بهذه الطريقة ، فإن حلقة الحدث الخاصة به لا تتعامل مع أي أحداث ويجب على Qt وضع الاحتفاظ في قائمة الانتظار. بمجرد الاستئناف ، ستعالج حلقة الحدث جميع الطلبات المتراكمة. بالنسبة لحالة الانتظار ، نستخدم ببساطة كائن 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 . لهذا السبب ، لا يمكننا استخدام هذا بأمان مع الفانيليا 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 تعمل في سلسلة مخصصة ، ونريد أن تقوم هذه الفئة بتصدير طريقة thread-safe: 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; }

يعمل هذا بشكل موثوق ، ولكن ليس بنفس سرعة استخدام العمليات الذرية "الخالية من القفل". الحل الثالث سريع وآمن (يستخدم المثال std::atomic بدلاً من QAtomicInt ، لكنهما متطابقان من الناحية اللغوية):

 // 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. إنها قراءة رائعة!