راهنمای کامل 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 را نمایش می‌دهدبعدا درباره 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 معلق در قالب داده ای خالص موجود است. پس با پاس دادن آن بین تِرد (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بیایید به یک مثال ساده (هرچند بی معنی) نگاهی بیندازیم.در اینجا کوروتین قبل از ورود به متد 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