ali.bayat
ali.bayat
خواندن ۸ دقیقه·۳ سال پیش

راهنمای کامل Coroutines در ++C


استاندارد ۲۰ زبان سی پلاس پلاس پشتیبانی اولیه از کوروتین ها (Coroutines) را برای ما به ارمغان آورد. این ویژگی در استاندارد ۲۰ عمدتا برای اجراکنندگان کتابخانه ها (library implementors) در دسترس است. احتمالا در نسخه ۲۳ پشتیبانی های بیشتری جهت پیاده سازی روش های متداول تر استفاده را نیز داشته باشیم.

اما Coroutine اصلا چیست؟

کوروتین (Coroutine) به رشته (Thread) های سبکی گفته می‌شود که با استفاده از آنها، می‌توانیم کدهای غیر مسدودکننده (Non Blocking) بنویسیم و از ویژگی ناهمگامی (asynchronous) هم بهره ببریم.

اگر بخواهیم کمی دقیق تر بگیم:

یک Coroutine تابعی است که می تواند اجرا را به حالت تعلیق درآورد تا این اجرا بعداً از سر گرفته شود. کوروتین ها بدون استک (پشته) هستند و از طریق بازگشت دادن تماس به تماس گیرنده، اجرا را به حالت تعلیق در می آورند. این در حالی است که داده هایی که برای از سرگیری اجرا لازمند جدا از استک ذخیره می شوند. این عملکرد به کدهای متوالی اجازه می دهد که به صورت ناهمزمان اجرا شوند.

پس هر تابعی که شامل کلیدواژه های co_return ، co_yield یا co_await باشد، به شکلی یک کوروتین را پیاده سازی می‌کند.

  • از کلیدواژه co_await برای تعلیق اجرا تا زمان از سرگیری استفاده می‌شود.
  • از کلیدواژه co_yield برای تعلیق اجرای یک مقدار استفاده می‌شود.
  • از کلیدواژه co_return برای تکمیل اجرای یک مقدار استفاده می‌شود.


به صورت بنیادی کوروتین ها در استاندارد ۲۰ سی پلاس پلاس، لایه ای مبتنی بر توابع آبجکت‌ ها هستند. پس با استفاده از آنها، کامپایلر عملا یک چارچوب کد در اطراف کوروتین شما ایجاد می‌کند; که این کد به ۲ نکته زیر وابستگی دارد:

  • نوع پرامس (Promise Types)
  • مقدار بازگشتی (return) مشخص شده توسط کاربر

تا زمانی که نوعی از انواع استاندارد را در C ++ 23 داشته باشیم ، خودمان باید این کدها را بنویسیم.

در این نوشته سعی شده تا جای ممکن از مترادف ها و معانی کلمات تخصصی استفاده شود، اما برای برخی از کلمات تخصصی بهترین شکل ترجمه، استفاده از خود کلمات است...
لذا در این نوشته برخی از کلمات بدون ترجمه هستند.



محدودیت ها

کوروتین ها نمی‌توانند از آرگومان های متغیر، عبارات بازگشتی ساده، یا انواع Placeholder ها (خودکار یا مفهومی) استفاده کنند.

توابع Constexpr، سازنده (constructor) ، تخریب کننده (destructor) و تابع اصلی (main) نمی توانند کوروتین باشند.


اجرا

هر کوروتین در اجرا با موارد زیر در ارتباط است:

  • آبجکت پرامس (Promise) که از داخل کوروتین تغییر می‌کند و کوروتین نتیجه یا اکسپشن خود را از طریق این شی ارسال می کند.
  • هندل (handle) کوروتین که از خارج کوروتین تغییر می‌کند و برای از سرگیری اجرای کوروتین و یا منحل کردن آن استفاده می‌شود.
  • استیت (state) کوروتین که یک آبجکت داخلی است و شامل موارد زیر است:
  • ۱. آبجکت پرامس.
  • ۲. پارامتر ها.
  • ۳. نوعی ارائه از نقطه تعلیق فعلی (پس خواهیم دانست که ادامه اجرا را از کجا از سر بگیریم، یا برای منحل کردن آن چه متغیرهای محلی را که مقدار دهی شده اند، از حافظه پاک کنیم.)
  • ۴. متغیرهای محلی و موقتی که طول عمر آنها در نقطه تعلیق فعلی موجود است.


و سلسله مراتبی که با اجرای یک کوروتین رخ می‌دهند، به شرح زیر است:

  • با استفاده از کلیدواژه new ، آبجکت استِیتی برای کوروتین اختصاص داده می‌شود.
  • همه پارامترهای تابع در استیت کوروتین کپی می‌شوند. پارامترهایی که مقدار دارند جابه‌جا و یا کپی می‌شوند، و پارامترهای مرجع (رفرنس) به عنوان مرجع باقی می‌مانند.
  • متد سازنده آبجکت پرامس فراخوانی می‌شود.
  • متد promise.get_return_object فراخوانی شده و نتیجه آن در یک متغیر محلی ذخیره می‌شود.
  • سپس متد promise.initial_suspend فراخوانی می‌شود و نتیجه آن به co_await پاس داده می‌شود.
  • هنگامی که یک کوروتین به نقطه تعلیق می رسد، برای از سرگیری اجرا شروع به اجرای بدنه کوروتین می کند.
  • در صورت لزوم، آبجکت بازگشتی که قبلاً حاصل شده است، پس از تبدیل به نوع برگشتی کوروتین، به (تماس گیرنده/از سر گیرنده) بازگردانده می شود.

یک کوروتین که کاری انجام نمی‌دهد

بیایید نگاهی به یک کوروتین بسیار ساده بیندازیم، کوروتینی که کاری انجام نمی‌دهد:

در نگاه اول به نظر می‌رسد: که تنها برای فراخوانی یک Coroutine که کار خاصی هم انجام نمی‌دهد، آماده سازی زیادی داشته ایم. اما این مکانی عالی برای شروع بررسی کد چارچوبی است که توسط کامپایلر تولید شده است.

این نمودار کدهای چارچوب تولید شده به دور Coroutine را نمایش می‌دهد
این نمودار کدهای چارچوب تولید شده به دور Coroutine را نمایش می‌دهد

بعدا درباره co_await صحبت خواهیم کرد. اما در نمودار بالا قسمت هایی به صورت نقطه نقطه مشخص شده اند، چرا که فراخوانی co_await روی Instance ای از نوع std::suspend_never بلافاصله مقدار را بازگشت (return) می‌دهد.

قبل از ادامه، بگذارید این موضوع را کمی بیشتر بررسی کنیم. اگر ما نوع بازگشتی ()initial_suspend را به std::suspend_always تغییر دهیم، چه اتفاقی می‌افتد؟

به دیاگرام زیر دقت کنید

ما با این تغییر یک مشکل ایجاد کرده ایم و کوروتین در حال حاضر گیر کرده است. فراخوانی co_await روی Instance ای از {}std::suspend_always منجر به تعلیق کوروتین می‌شود. و در نتیجه کنترل به تماس گیرنده (Caller) باز می گرداند. با این حال ، تماس گیرنده (تابع main) هیچ راهی برای از سرگیری Coroutine ندارد. برای حل این مشکل می‌توانیم به شکل زیر عمل کنیم:

ما بایستی coroutine_handle را در اختیار تماس گیرنده قرار دهیم. برای انجام این کار آن را از طریق ()get_return_object پاس می‌دهیم. بدین ترتیب تماس گیرنده می‌تواند یکی از متد های ()resume یا ()destroy را بر روی Coroutine تعلیق شده فراخوانی کند.

توجه داشته باشید که فراخوانی این متد ها در Coroutine ای که معلق نیست، رفتار نامشخصی را در پی دارد.

همچنین منطقی است که نوع تماس گیرنده را به move-only تغییر داد تا از سردرگمی های احتمالی در مدیریت مالکیت (Ownership) نیز جلوگیری شود.

نموادر یک Coroutine با فراخوانی متد resume
نموادر یک Coroutine با فراخوانی متد resume

یک Coroutine معلق در قالب داده ای خالص موجود است. پس با پاس دادن آن بین تِرد (thread) ها، می‌توانیم به راحتی آن را مدیریت کنیم. بعدا هنگام کار با co_await بیشتر به این موضوع می‌پردازیم.

تا اینجای کار Coroutine ما کار خاصی انجام نمی‌دهد. حال به سراغ مثال جالب تری می‌رویم، که یک جِنِریتور (generator) است.



جنریتور ها متکی به کلیدواژه co_yield هستند. در واقع عبارت co_yield expr میانبری است برای co_await promise.yield_value(expr) . در اینجا ما از std::suspend_always استفاده می کنیم ، زیرا می خواهیم کوروتین را تا هنگام فراخوانی get_next متوقف کنیم.

از آنجا که این کوروتین حاوی یک حلقه بی پایان است ، هرگز به طور طبیعی از حافظه پاک نمی شود. هر چند که کوروتین تنها در حین فراخوانی get_next اجرا میشود و می توانیم با خیال راحت destroy را فراخوانی کرده و آن را پاک کنیم.

در حال حاظر قابلیت استفاده از این کوروتین به دلیل سبک استفاده از get_next ممکن است کمی عجیب به نظر برسد. برای مثالی واضح تر میتوانید مثال CppReference را بررسی کنید.



آبجکت های Awaitable

ما تا اینجا از دو Awaitable استفاده کرده ایم.. std::suspend_never و std::suspend_always .

در زیر بررسی می‌کنیم که چگونه Awaitable ها کار می کنند و چگونه می توانید اینترفیس های خود را برای ارتباط با کتابخانه های asynchronous بنویسید. درست شبیه به نوع پرامس ها، کامپایلر کدی را برای نوع awaitable تولید می‌کند.

اگر نوع پرامس متد await_transform(expr) را ارائه دهد، فراخوانی co_await expr تبدیل به عبارت co_await promise.await_transform(expr) می‌شود.

بنابراین ، نوع پرامس می تواند کنترل کند که کدام نوع Awaitable مجاز است، تا در بدنه کوروتین ظاهر شود و به طور بالقوه awaitable های مختلفی را بر اساس عبارت باز می گرداند (توجه داشته باشید که expr در اینجا نباید awaitable باشد.)

هر نوع Awaitable ملزم است تا ۳ متد را ارائه دهد.

چارچوب کد ایجاد شده در نتیجه فراخوانی  co_await
چارچوب کد ایجاد شده در نتیجه فراخوانی co_await


بیایید به یک مثال ساده (هرچند بی معنی) نگاهی بیندازیم.

در اینجا کوروتین قبل از ورود به متد await_suspend معلق می شود. هیچ دیتا رِیسی وجود ندارد زیرا ما (پس از نقطه تعلیق) یک ترد جدید در داخل این متد ایجاد می کنیم. همچنین با توجه به مطلبی که کمی قبل عنوان کردیم ("کنترل به تماس گیرنده بازگردانده می شود") ، می‌توانیم اجرای کوروتین را در ترد تازه ایجاد شده، از سر بگیریم...

سپس می توانیم از این کد در یک کوروتین استفاده کنیم:



چه زمانی از کوروتین استفاده کنیم

در پایان کمی درباره موارد استفاده نیز صحبت خواهیم کرد. به طور کلی در موارد زیر استفاده از کوروتین ها توصیه می‌شود اما ممکن است بنا به نیازهای شما، شرایط کمی تغییر کند


محیط های Single Thread یا تک رشته ای

اگر محدود به یک تک رشته هستید ، کوروتین ها راه حل مناسبی برای پردازش ناهمزمان هستند. به راحتی میتونید یک حلقه رویداد (Event Loop) به سبک جاوا اسکریپت پیاده سازی کنید و از آن بهره ببرید.

محیط های بسیار پیچیده

اگر مثال بالا را در یک سمت طیف پیچیدگی قرار دهیم (سمت کوچک آن) ، این مثال در نقطه مقابل آن قرار دارد. اگر محصول شما باید از رشته های فراوان و سبک استفاده کند، کوروتین ها در صرفه جویی حافظه به شما کمک می‌کنند.

استفاده از کتابخانه های Asynchronous با پشتیبانی از Awaitable ها

این مثال نکته بسیار آشکاری است. اگر کتابخانه ها awaitable های پیش ساخته شده و پشتیبانی از انواع کوروتین را در اختیارتان بگذارد، پس استفاده کردن از کوروتین ها رویکرد تمیز تری نسبت به سایر alternative ها است. زیرا تمام مراحل همگام سازی با کوروتین ها در پشت فراخوانی co_await اتفاق می افتد.

محیط هایی با ظرفیت کنترل شده

با پردازش همزمان ، می توانید ظرفیت سرویس خود را با کنترل تعداد رشته ها کنترل کنید. هنگام رسیدن به اضافه بار ، می توانید درخواست ها را هنگام فرارسیدن رد (reject) کنید. متاسفانه جلوگیری از اضافه بار با کوروتین کمی پیچیده می شود ، چون ممکن است در حین رسیدگی به یک درخواست ، سیستم دچار اضافه بار شود.

تایم اوت ها

یکی از قسمت هایی که در حین نوشتن این مطلب هنوز به طور کامل برای خودم هم جا نیفتاده است، مدیریت تایم اوت هاست. البته که راه هایی برای مدیریت تایم اوت ها وجود دارد و چیز جدیدی هم نیست. مثلا به قطعه کد زیر دقت کنید:

مدیریت تایم اوت ها با چنین رویکردی امکان پذیر است، اما مسلما راه معادلی برای انجام این کار با کوروتین هم وجود دارد، که من هنوز آن را پیدا نکردم (چنانچه نظری درباره پیاده سازی آن دارید، لطفا کامنت کنید.)



لینک ها و نکات فنی

همه نمونه کدهای موجود در این نوشته با استفاده از نسخه اصلی GCC (منتشر شده در سپتامبر ۲۰۲۱) قابل اجرا هستند. و یا می‌توانید این ابزار را با فلگ زیر کامپایل کنید:

‍‍g++ -fcoroutines -std=c++20

همچنین نمونه کدها در ریپازیتوری زیر موجود هستند:

https://github.com/AliBayat/CPP-20-Coroutines


سی پلاس پلاسکوروتینcoroutines
توسعه دهنده ارشد وب
شاید از این پست‌ها خوشتان بیاید