ألهمتني التعليقات على مقالة " كيف تنام بشكل صحيح وخاطئ" كتابة هذه المقالة.
ستركز هذه المقالة على تطوير تطبيقات متعددة الخيوط ، وقابلية تطبيق القفل بدون قفل على بعض الحالات التي نشأت أثناء العمل على LAppS ، على وظيفة النوم النانوي والعنف في برنامج جدولة المهام.
NB: C++ Linux, POSIX.1-2008 a ( ).
بشكل عام ، كل شيء فوضوي جدًا ، آمل أن يكون قطار الفكر في العرض التقديمي واضحًا. إذا كنت مهتمًا ، فأنا أطلب قطة.
البرامج الموجهة للأحداث تنتظر دائمًا شيئًا ما. سواء كانت واجهة المستخدم الرسومية أو خادم الشبكة ، فإنهم ينتظرون أي أحداث: إدخال لوحة المفاتيح ، أحداث الماوس ، حزمة البيانات التي تصل عبر الشبكة. لكن كل البرامج تنتظر بشكل مختلف. لا يجب أن تنتظر الأنظمة الخالية من القفل على الإطلاق. على الأقل يجب أن يحدث استخدام خوارزميات بدون قفل حيث لا تحتاج إلى الانتظار ، وحتى ضار. لكننا نتحدث عن أنظمة تنافسية (متعددة الخيوط) ، ومن الغريب أن الخوارزميات الخالية من القفل تنتظر أيضًا. نعم ، إنهم لا يمنعون تنفيذ الخيوط المتوازية ، لكنهم هم أنفسهم ينتظرون الفرصة للقيام بشيء ما دون الحجب.
يستخدم LAppS كائنات المزاج و الإشارات بنشاط كبير. في الوقت نفسه ، لا توجد إشارات في معيار C ++. الآلية مهمة للغاية ومريحة ، ولكن يجب أن تعمل C ++ على الأنظمة التي لا تحتوي على دعم إشارة ، وبالتالي لا يتم تضمين الإشارات في المعيار. علاوة على ذلك ، إذا كنت أستخدم الإشارات لأنها مريحة ، فعندئذٍ يجب تغيير المزامنة لأنني مضطر إلى ذلك.
يؤدي سلوك كائن المزامنة في حالة القفل التنافسي () ، مثل sem_wait () في Linux ، إلى وضع مؤشر الترابط المنتظر في نهاية قائمة انتظار جدولة المهام ، وعندما يكون في الأعلى ، يتكرر التحقق دون العودة إلى منطقة المستخدم ، يتم إعادة سلسلة الرسائل إلى قائمة الانتظار إذا الحدث المتوقع لم يحدث بعد. هذه نقطة مهمة جدا
وقررت أن أتحقق مما إذا كان يمكنني رفض إشارات std :: mutex و POSIX ، ومضاهاة std :: atomic ، ونقل الحمل في الغالب إلى userland. فشل في الواقع ، ولكن أول الأشياء أولاً.
أولاً ، لدي عدة أقسام يمكن أن تكون فيها هذه التجارب مفيدة:
- أقفال في LibreSSL (الحالة 1) ؛
- الحجب عند نقل الحزم المستلمة إلى تطبيقات Lua (الحالة 2) ؛
- في انتظار أحداث الحمولة جاهزة لتتم معالجتها بواسطة تطبيقات Lua (الحالة 3).
لنبدأ مع أقفال غير مانعة للحظر. دعنا نكتب كائن المزامنة لدينا باستخدام atomics ، كما هو موضح في بعض الخطب التي كتبها H. Sutter (لا يوجد رمز أصلي ، وبالتالي ، من الذاكرة وبالتالي لا يتزامن الرمز مع 100 ٪ الأصلي ، وفي Satter كان هذا الرمز مرتبطًا بتقدم C ++ 20 ، لذلك هناك اختلافات). وعلى الرغم من بساطة هذا الرمز ، إلا أن هناك عيوبًا فيه.
#include <atomic> #include <pthread.h> namespace test { class mutex { private: std::atomic<pthread_t> mLock; public: explicit mutex():mLock{0} { } mutex(const mutex&)=delete; mutex(mutex&)=delete; void lock() { pthread_t locked_by=0;
على عكس std :: mutex :: unlock () ، فإن سلوك test :: mutex: unlock () عند محاولة إلغاء القفل من مؤشر ترابط آخر هو أمر حتمي. سيتم طرح استثناء. هذا أمر جيد ، على الرغم من أنه لا يتفق مع السلوك القياسي. وما هو السيئ في هذه الفئة؟ الخبر السيئ هو أن أسلوب test :: mutex: lock () سيستهلك بلا خجل موارد وحدة المعالجة المركزية في الحصص الزمنية المخصصة لمؤشر الترابط ، في محاولات للسيطرة على كائن المزامنة الذي يمتلكه مؤشر ترابط آخر بالفعل. على سبيل المثال حلقة في الاختبار :: mutex: lock () ستكون مضيعة لموارد وحدة المعالجة المركزية. ما هي خياراتنا للتغلب على هذا الوضع؟
يمكننا استخدام Schedule_yield () (كما هو مقترح في أحد التعليقات على المقالة أعلاه). هل هو بهذه البساطة؟ أولاً ، من أجل استخدام الجدولة الزمنية () ، من الضروري أن تستخدم سلاسل التنفيذ سياسات SCHED_RR ، SCHED_FIFO لتحديد أولوياتها في برنامج جدولة المهام. وإلا ، فإن استدعاء Schedule_yield () سيكون مضيعة لموارد وحدة المعالجة المركزية. ثانيًا ، ستؤدي المكالمة المتكررة جدًا إلى Schedule_yield () إلى زيادة استهلاك وحدة المعالجة المركزية. علاوة على ذلك ، فإن استخدام سياسات الوقت الفعلي في تطبيقك ، وشريطة عدم وجود تطبيقات أخرى في الوقت الفعلي في النظام ، سيحد من قائمة انتظار المجدول مع السياسة المحددة لموضوعاتك فقط. يبدو أن هذا أمر جيد! لا ، ليس جيدًا. النظام بأكمله سيصبح أقل استجابة ، لأنه مشغول بالمهمة ذات الأولوية. سوف يكون CFQ في القلم. ولكن هناك مؤشرات ترابط أخرى في التطبيق ، وغالبًا ما ينشأ موقف عندما يتم وضع مؤشر الترابط الذي استحوذ على كائن المزامنة في نهاية قائمة الانتظار (انتهت صلاحية الحصة) ، والموضوع الذي ينتظر تحرير كائن المزامنة أمامه مباشرة. في تجربتي (الحالة 2) ، أعطت هذه الطريقة نفس النتائج (3.8٪ أسوأ) من std :: mutex ، لكن النظام أقل استجابة وزاد استهلاك وحدة المعالجة المركزية بنسبة 5٪ -7٪.
يمكنك تجربة تغيير الاختبار :: mutex :: lock () مثل هذا (سيئ أيضًا):
void lock() { pthread_t locked_by=0; while(!mLock.compare_exchange_strong(locked_by,pthread_self())) { static thread_local const struct timespec pause{0,4};
هنا يمكنك تجربة مدة النوم بالنانو ثانية ، وكان 4 ns من التأخير هو الأمثل لوحدة المعالجة المركزية الخاصة بي وكان انخفاض الأداء نسبة إلى std :: mutex في نفس الحالة 2 كان 1.2٪. ليس حقيقة أن النوم نانو 4ns. في الواقع ، أو أكثر (في الحالة العامة) أو أقل (إذا تمت مقاطعته). كان السقوط (!) في استهلاك وحدة المعالجة المركزية 12٪ -20٪. على سبيل المثال كان هذا حلم صحي.
لدى OpenSSL و LibreSSL وظيفتان تقومان بإعداد الاسترجاعات لحظرها عند استخدام هذه المكتبات في بيئة متعددة الخيوط. يبدو هذا:
// callback void openssl_crypt_locking_function_callback(int mode, int n, const char* file, const int line) { static std::vector<std::mutex> locks(CRYPTO_num_locks()); if(n>=static_cast<int>(locks.size())) { abort(); } if(mode & CRYPTO_LOCK) locks[n].lock(); else locks[n].unlock(); } // callback-a CRYPTO_set_locking_callback(openssl_crypt_locking_function_callback); // id CRYPTO_set_id_callback(pthread_self);
والآن أسوأ شيء هو أن استخدام الاختبار أعلاه: mutex mutex في LibreSSL يقلل من أداء LAppS مرتين تقريبًا. علاوة على ذلك ، بغض النظر عن الخيار (حلقة انتظار فارغة ، Sched_yield () ، nanosleep ()).
بشكل عام ، نحذف الحالة 2 والحالة 1 ، ونبقى مع std :: mutex.
دعنا ننتقل إلى الإشارات. هناك العديد من الأمثلة على كيفية تنفيذ الإشارات باستخدام std :: condition_variable. جميعهم يستخدمون std :: mutex أيضًا. ومحاكاة الإشارة هذه أبطأ (وفقًا لاختباراتي) من أجهزة إشارة نظام POSIX.
لذلك ، سنقوم بعمل إشارة على الذرات:
class semaphore { private: std::atomic<bool> mayRun; mutable std::atomic<int64_t> counter; public: explicit semaphore() : mayRun{true},counter{0} { } semaphore(const semaphore&)=delete; semaphore(semaphore&)=delete; const bool post() const { ++counter; return mayRun.load(); } const bool try_wait() { if(mayRun.load()) { if(counter.fetch_sub(1)>0) return true; else { ++counter; return false; } }else{ throw std::system_error(ENOENT,std::system_category(),"Semaphore is destroyed"); } } void wait() { while(!try_wait()) { static thread_local const struct timespec pause{0,4}; nanosleep(&pause,nullptr); } } void destroy() { mayRun.store(false); } const int64_t decrimentOn(const size_t value) { if(mayRun.load()) { return counter.fetch_sub(value); }else{ throw std::system_error(ENOENT,std::system_category(),"Semaphore is destroyed"); } } ~semaphore() { destroy(); } };
أوه ، هذا إشارة أسرع عدة مرات من إشارة النظام. نتيجة اختبار منفصل لهذه الإشارة مع موفر واحد و 20 كونسيرمار:
OS semaphores test. Started 20 threads waiting on a semaphore Thread(OS): wakes: 500321 Thread(OS): wakes: 500473 Thread(OS): wakes: 501504 Thread(OS): wakes: 502337 Thread(OS): wakes: 498324 Thread(OS): wakes: 502755 Thread(OS): wakes: 500212 Thread(OS): wakes: 498579 Thread(OS): wakes: 499504 Thread(OS): wakes: 500228 Thread(OS): wakes: 499696 Thread(OS): wakes: 501978 Thread(OS): wakes: 498617 Thread(OS): wakes: 502238 Thread(OS): wakes: 497797 Thread(OS): wakes: 498089 Thread(OS): wakes: 499292 Thread(OS): wakes: 498011 Thread(OS): wakes: 498749 Thread(OS): wakes: 501296 OS semaphores test. 10000000 of posts for 20 waiting threads have taken 9924 milliseconds OS semaphores test. Post latency: 0.9924ns ======================================= AtomicEmu semaphores test. Started 20 threads waiting on a semaphore Thread(EmuAtomic) wakes: 492748 Thread(EmuAtomic) wakes: 546860 Thread(EmuAtomic) wakes: 479375 Thread(EmuAtomic) wakes: 534676 Thread(EmuAtomic) wakes: 501014 Thread(EmuAtomic) wakes: 528220 Thread(EmuAtomic) wakes: 496783 Thread(EmuAtomic) wakes: 467563 Thread(EmuAtomic) wakes: 608086 Thread(EmuAtomic) wakes: 489825 Thread(EmuAtomic) wakes: 479799 Thread(EmuAtomic) wakes: 539634 Thread(EmuAtomic) wakes: 479559 Thread(EmuAtomic) wakes: 495377 Thread(EmuAtomic) wakes: 454759 Thread(EmuAtomic) wakes: 482375 Thread(EmuAtomic) wakes: 512442 Thread(EmuAtomic) wakes: 453303 Thread(EmuAtomic) wakes: 480227 Thread(EmuAtomic) wakes: 477375 AtomicEmu semaphores test. 10000000 of posts for 20 waiting threads have taken 341 milliseconds AtomicEmu semaphores test. Post latency: 0.0341ns
هذه الإشارة مع وظيفة مجانية تقريبًا () ، وهي أسرع 29 مرة من النظام ، وهي أيضًا سريعة جدًا في إيقاظ الخيوط التي تنتظرها: 29325 تنبيهًا لكل مللي ثانية ، مقابل 1007 تنبيهًا لكل مللي ثانية من النظام. لديها سلوك قطعي مع إشارة تدمّر أو إشارة تدميرية. وبطبيعة الحال ، سيجفولت عند محاولة استخدام واحد مدمر بالفعل.
(¹) في الواقع ، مرات عديدة في مللي ثانية لا يمكن تأجيل الدفق وإيقاظه من قبل المجدول. لأن post () لا يمنع ، في هذا الاختبار الاصطناعي ، انتظر () غالبًا ما يجد نفسه في موقف لا تحتاج فيه إلى النوم. في الوقت نفسه ، تقرأ 7 خيوط على الأقل بالتوازي قيمة الإشارة.
ولكن استخدامه في حالة 3 في LAppS يؤدي إلى خسائر في الأداء بغض النظر عن وقت النوم. يستيقظ في كثير من الأحيان للتحقق ، وتصل الأحداث في LAppS بشكل أبطأ بكثير (كمون الشبكة ، الكمون من جانب العميل الذي يولد الحمل ، إلخ). والتحقق أقل من ذلك يعني أيضًا فقد الأداء.
علاوة على ذلك ، فإن استخدام النوم في مثل هذه الحالات وبطريقة مماثلة ضار تمامًا ، لأنه على جهاز آخر ، قد يتبين أن النتائج مختلفة تمامًا (كما في حالة توقف تعليمات المجمّع مؤقتًا) ، ولكل طراز CPU ، يجب عليك أيضًا تحديد وقت التأخير.
تكمن ميزة كائنات المزامنة ونظام الإشارة في أن مؤشر ترابط التنفيذ لا يستيقظ حتى يحدث حدث (فتح كائن المزامنة أو زيادة الإشارة). لا تضيع دورات CPU إضافية - الربح.
بشكل عام ، كل شيء من هذا الشرير ، تعطيل iptables على نظامي يعطي من 12 ٪ (مع TLS) إلى 30 ٪ (بدون TLS) مكاسب أداء ...