الاستعداد لـ C ++ 20. دراسة حالة Coroutines TS Real

في C ++ 20 ، فرصة العمل مع coroutines خارج منطقة الجزاء على وشك الظهور. هذا الموضوع قريب ومثير للاهتمام بالنسبة لنا في Yandex.Taxi (لتلبية احتياجاتنا الخاصة ، نقوم بتطوير إطار غير متزامن). لذلك ، سوف نظهر اليوم لقراء هبر باستخدام مثال حقيقي لكيفية العمل مع Coruteines C ++ بدون مكدس.

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


void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto finally = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetworkThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(finally); }); } else { writerQueue.PushTask(finally); } }); } else { finally(); } }); } 


مقدمة


Coroutines أو coroutines هي القدرة على إيقاف وظيفة من التنفيذ في مكان محدد سلفا ؛ تمر في مكان ما بالحالة الكاملة لوظيفة التوقف مع المتغيرات المحلية ؛ تشغيل الوظيفة من نفس المكان الذي أوقفناها فيه.
هناك العديد من النكهات من coroutines: مكدسة ومكدسة. سنتحدث عن هذا في وقت لاحق.

بيان المشكلة


لدينا العديد من قوائم انتظار المهام. تحتوي كل مهمة على مهام معينة: هناك قائمة انتظار لرسم الرسومات ، وهناك قائمة انتظار لتفاعلات الشبكة ، وهناك قائمة انتظار للعمل مع قرص. جميع قوائم الانتظار هي أمثلة لفئة WorkQueue التي تحتوي على طريقة PushTask باطلة (std :: function <void ()> مهمة) ؛. تعيش قوائم الانتظار لفترة أطول من جميع المهام الموضوعة فيها (يجب ألا يحدث الموقف الذي دمرناه قائمة انتظار عندما تكون هناك مهام معلقة فيها).

تقوم الدالة FuncToDealWith () من المثال بتنفيذ بعض المنطق في قوائم انتظار مختلفة ، وبناءً على نتائج التنفيذ ، تضع مهمة جديدة في قائمة الانتظار.

نعيد كتابة "المعكرونة" لرد الاتصال في شكل رمز زائف خطي ، وضع علامة في قائمة الانتظار التي يجب تنفيذ التعليمات البرمجية الأساسية:

 void CoroToDealWith() { InCurrentThread(); // =>   writerQueue InWriterThread1(); if (NeedNetwork()) { // =>   networkQueue auto v = InNetworkThread(); if (v) { // =>   UIQueue InUIThread(); } } // =>   writerQueue InWriterThread2(); ShutdownAll(); } 

أريد تحقيق هذه النتيجة تقريبًا.

هناك قيود:

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

الحل


إعادة كتابة الدالة FuncToDealWith


في Coroutines TS ، يتم ضبط Coroutine عن طريق تعيين نوع القيمة المرجعة للدالة. إذا كان النوع يفي بمتطلبات معينة ، فعندئذٍ يمكنك استخدام الكلمات الرئيسية الجديدة co_await / co_return / co_yield داخل نص الوظيفة. في هذا المثال ، للتبديل بين قوائم الانتظار ، سنستخدم co_yield:

 CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetworkThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); } 

اتضح أنه مشابه جدًا للرمز الكاذب من القسم الأخير. يتم إخفاء كل "السحر" للعمل مع coroutines في فئة CoroTask.

CoroTask


في أبسط (في حالتنا) ، تتكون محتويات فئة "الموالف" من coroutine من اسم مستعار واحد فقط:

 #include <experimental/coroutine> struct CoroTask { using promise_type = PromiseType; }; 


promo_type هو نوع بيانات يجب أن نكتبه بأنفسنا. يحتوي على منطق يصف:

  • ماذا تفعل عند الخروج من coroutine
  • ما يجب القيام به عند إدخال كوروتين لأول مرة
  • الذي يحرر الموارد
  • ماذا تفعل مع الاستثناءات التي تطير من coroutine
  • كيفية إنشاء كائن CoroTask
  • ماذا تفعل إذا كانت داخل corutins تسمى co_yield

يجب تسمية نوع الاسم المستعار الواعد بهذه الطريقة. إذا قمت بتغيير الاسم المستعار إلى شيء آخر ، فسوف يقسم المحول البرمجي ويقول أنك كتبت CoroTask بشكل غير صحيح. يمكن تغيير اسم CoroTask كيفما تشاء.

لكن لماذا يعتبر CoroTask ضروريًا إذا تم وصف كل شيء في نوع الوعد؟
في الحالات الأكثر تعقيدًا ، يمكنك إنشاء CoroTask التي ستسمح لك بالتواصل مع روتين موقوف ، وإرسال واستقبال البيانات منه ، وإيقاظه وتدميره.

PromiseType


الوصول إلى الجزء الممتع. نصف سلوك كوروتين:

 class WorkQueue; // forward declaration class PromiseType { public: //      `co_return;`     , ... void return_void() const { /* ...    :) */ } //        ,  CoroTask, ... auto initial_suspend() const { // ...       . return std::experimental::suspend_never{}; } //      - , ... auto final_suspend() const { // ...        //      . return std::experimental::suspend_never{}; } //     , ... void unhandled_exception() const { // ...   (  ). std::terminate(); } //    CoroTask,    , ... auto get_return_object() const { // ...  CoroTask. return CoroTask{}; } //     co_yield, ... auto yield_value(WorkQueue& wq) const; // ... <  > }; 

في الكود أعلاه ، يمكنك ملاحظة نوع البيانات std :: التجريبية التجريبية :: suspend_never. هذا هو نوع بيانات خاص يقول أن Corutin لا يحتاج إلى إيقاف. هناك أيضًا نقيضها - النوع std :: التجريبية التجريبية :: suspend_always ، الذي يخبرك بإيقاف corutin. هذه الأنواع هي ما يسمى بانتظار. إذا كنت مهتمًا ببنيتها الداخلية ، فلا داعي للقلق ، فسوف نكتب Awaitables قريبًا.

أكثر مكان غير تافه في الكود أعلاه هو final_suspend (). الوظيفة لها تأثيرات غير متوقعة. لذا ، إذا لم نوقف التنفيذ في هذه الوظيفة ، فإن الموارد المخصصة للمرتب من قبل المترجم ستنظف المترجم من أجلنا. ولكن إذا أوقفنا في هذه الوظيفة تنفيذ coroutine (على سبيل المثال ، عن طريق إرجاع std :: التجريبية التجريبية :: suspend_always {}) ، فسيتعين عليك تحرير الموارد يدويًا من مكان ما في الخارج: سيكون عليك حفظ مؤشر ذكي إلى coroutine في مكان ما والاتصال به صراحة تدمير (). لحسن الحظ ، هذا ليس ضروريا لمثالنا.

غير صحيح PromiseType :: المحصول_قيمة


يبدو أن كتابة PromiseType :: extract_value بسيطة للغاية. لدينا خط. coroutine ، الذي يجب تعليقه ، وبالتالي وضع:

 auto PromiseType::yield_value(WorkQueue& wq) { //        std::experimental::coroutine_handle<> this_coro = std::experimental::coroutine_handle<>::from_promise(*this); //    .  this_coro  operator(),    // wq      .   , //     ,  operator(),  //   . wq.PushTask(this_coro); //     . return std::experimental::suspend_always{}; } 

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

PromiseType الصحيح : * الناتج_القيمة


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

 auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { WorkQueue& wq; constexpr bool await_ready() const noexcept { return false; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; return schedule_for_execution{wq}; } 

يجب أن تحتوي الفئات std :: التجريبية التجريبية :: suspend_always و std :: التجريبية التجريبية :: suspend_never و Schedule_for_execution و Awaitables الأخرى على 3 وظائف. يتم استدعاء await_ready للتحقق مما إذا كان يجب إيقاف coroutine. يتم استدعاء await_suspend بعد إيقاف البرنامج ، ويتم تمرير مقبض coroutine توقف إليه. يسمى await_resume عند استئناف تنفيذ coroutine.
وماذا يمكن كتابته في skrabs الثلاثي std :: التجريبية :: coroutine_handle <>؟
يمكنك تحديد نوع PromiseType هناك ، وسيعمل المثال بنفس الطريقة تمامًا :)

std :: التجريبية: :: coroutine_handle <> (الملقب بـ std :: التجريبية: :: coroutine_handle <void>) هو النوع الأساسي لجميع std :: التجريبية: :: coroutine_handle <DataType> ، حيث يجب أن يكون DataType هو نوع_النوع لـ coroutine الحالي. إذا لم تكن بحاجة إلى الوصول إلى المحتويات الداخلية لـ DataType ، فيمكنك كتابة std :: التجريبية التجريبية :: coroutine_handle <>. يمكن أن يكون ذلك مفيدًا في الأماكن التي تريد فيها التجريد من نوع معين من نوع prom_type واستخدام نوع المحو.

تم


يمكنك تجميع وتشغيل المثال عبر الإنترنت والتجربة بكل طريقة .

وإذا لم أكن أحب co_yield ، فهل يمكنني استبداله بشيء؟
يمكن استبداله ب co_await. للقيام بذلك ، قم بإضافة الوظيفة التالية إلى PromiseType:

 auto await_transform(WorkQueue& wq) { return yield_value(wq); } 

ولكن ماذا لو لم أكن أحب co_await؟
الشيء سيئ. لا شيء يتغير.


ورقة الغش


CoroTask هي فئة تضبط سلوك كوروتين. في الحالات الأكثر تعقيدًا ، يسمح لك بالتواصل مع كوروتين متوقف وأخذ أي بيانات منه.

CoroTask :: وعد_يصف كيف ومتى تتوقف coroutines ، وكيفية تحرير الموارد ، وكيفية بناء CoroTask.

Awaitables (std :: التجريبية: :: suspend_always و std :: التجريبية: suspend_never و Schedule_for_execution وغيرها) تخبر المترجم بما يجب فعله مع coroutine في نقطة معينة (ما إذا كان من الضروري إيقاف corutin ، وما يجب فعله مع توقف corutin وما يجب فعله عندما يستيقظ corutin) .

التحسينات


يوجد خلل في PromiseType. حتى إذا كنا نعمل حاليًا في قائمة انتظار المهام الصحيحة ، فسيستمر استدعاء co_yield في تعليق النظام الأساسي وإعادة وضعه في قائمة انتظار المهام نفسها. سيكون من الأفضل بكثير عدم إيقاف تنفيذ coroutine ، ولكن لمواصلة التنفيذ على الفور.

دعونا نصلح هذا العيب. للقيام بذلك ، قم بإضافة حقل خاص إلى PromiseType:

 WorkQueue* current_queue_ = nullptr; 

في ذلك ، سوف نحتفظ بمؤشر إلى قائمة الانتظار التي نقوم بتنفيذها حاليًا.

بعد ذلك ، قرص نوع PromiseType :::

 auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { const bool do_resume; WorkQueue& wq; constexpr bool await_ready() const noexcept { return do_resume; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; const bool do_not_suspend = (current_queue_ == &wq); current_queue_ = &wq; return schedule_for_execution{do_not_suspend, wq}; } 

هنا قمنا بتعديل الجدول الزمني للالتنفيذ :: await_ready (). الآن تخبر هذه الوظيفة المترجم أنه لا يلزم تعليق coroutine إذا كانت قائمة انتظار المهام الحالية تتطابق مع تلك التي نحاول البدء بها.

تم. يمكنك تجربة بكل طريقة .

عن الأداء


في المثال الأصلي ، مع كل استدعاء لـ WorkQueue :: PushTask (std :: function <void ()> f) ، أنشأنا مثيلًا للفئة std :: function <void ()> من lambda. في الشفرة الحقيقية ، غالبًا ما تكون هذه الأحجار الكبيرة كبيرة الحجم ، ولهذا السبب يضطر std :: function <void ()> إلى تخصيص الذاكرة ديناميكيًا لتخزين lambdas.

في مثال Coroutine ، نقوم بإنشاء حالات std :: function <void ()> من std :: التجريبية التجريبية :: coroutine_handle <>. يعتمد حجم std :: التجريبية: :: coroutine_handle <> على التنفيذ ، لكن معظم التطبيقات تحاول إبقاء حجمها عند الحد الأدنى. لذا ، في صلصلة ، حجمها يساوي sizeof (باطل *). عند إنشاء std :: function <void ()> ، لا يتم التخصيص الديناميكي من الكائنات الصغيرة.
الإجمالي - مع Coroutines ، تخلصنا من العديد من التخصيصات الديناميكية غير الضرورية.

لكن! لا يستطيع المحول البرمجي في الغالب حفظ كل المحتوى على المكدس. وبسبب هذا ، يمكن تخصيص ديناميكي إضافي واحد عند دخول CoroToDealWith.

مكدس مقابل مكدس


لقد عملنا للتو مع Coroutines Stackless ، والتي تتطلب الدعم من المترجم للعمل معها. هناك أيضًا Coroutines المكدسة التي يمكن تنفيذها بالكامل على مستوى المكتبة.

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

الملخص


لقد درسنا المثال الأساسي وحصلنا على CoroTask من فئة عالمية ، والتي يمكن استخدامها لإنشاء كوروتينات أخرى.

تصبح الشفرة معها أكثر قابلية للقراءة وأكثر إنتاجية قليلاً من النهج الساذج:
كانمع coroutines
 void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto fin = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(fin); }); } else { writerQueue.PushTask(fin); } }); } else { fin(); } }); } 
 CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); } 

كانت هناك لحظات:

  • كيفية استدعاء coroutine آخر من corutin وانتظار اكتماله
  • ما هي الأشياء المفيدة التي يمكنك حشرها في CoroTask
  • مثال يجعل الفرق بين Stackless و Stackful

أخرى


إذا كنت ترغب في التعرف على المستجدات الأخرى للغة C ++ أو التواصل شخصيًا مع زملائك على الإيجابيات ، فقم بإلقاء نظرة على مؤتمر C ++ Russia. وستعقد الجلسة التالية في 6 أكتوبر في نيجني نوفغورود .

إذا كان لديك ألم مرتبط بـ C ++ وتريد تحسين شيء ما في اللغة أو تريد فقط مناقشة الابتكارات المحتملة ، فمرحبًا بك في https://stdcpp.ru/ .

حسنًا ، إذا فاجأك أن Yandex.Taxi لديه عدد كبير من المهام التي لا تتعلق بالرسوم البيانية ، فعندئذ آمل أن يكون هذا مفاجأة سارة لك :) تعال لزيارتنا في 11 أكتوبر ، سنتحدث عن C ++ والمزيد.

Source: https://habr.com/ru/post/ar420861/


All Articles