استاندارد ۲۰ زبان سی پلاس پلاس پشتیبانی اولیه از کوروتین ها (Coroutines) را برای ما به ارمغان آورد. این ویژگی در استاندارد ۲۰ عمدتا برای اجراکنندگان کتابخانه ها (library implementors) در دسترس است. احتمالا در نسخه ۲۳ پشتیبانی های بیشتری جهت پیاده سازی روش های متداول تر استفاده را نیز داشته باشیم.
اما Coroutine اصلا چیست؟
کوروتین (Coroutine) به رشته (Thread) های سبکی گفته میشود که با استفاده از آنها، میتوانیم کدهای غیر مسدودکننده (Non Blocking) بنویسیم و از ویژگی ناهمگامی (asynchronous) هم بهره ببریم.
اگر بخواهیم کمی دقیق تر بگیم:
یک Coroutine تابعی است که می تواند اجرا را به حالت تعلیق درآورد تا این اجرا بعداً از سر گرفته شود. کوروتین ها بدون استک (پشته) هستند و از طریق بازگشت دادن تماس به تماس گیرنده، اجرا را به حالت تعلیق در می آورند. این در حالی است که داده هایی که برای از سرگیری اجرا لازمند جدا از استک ذخیره می شوند. این عملکرد به کدهای متوالی اجازه می دهد که به صورت ناهمزمان اجرا شوند.
پس هر تابعی که شامل کلیدواژه های co_return
، co_yield
یا co_await
باشد، به شکلی یک کوروتین را پیاده سازی میکند.
co_await
برای تعلیق اجرا تا زمان از سرگیری استفاده میشود.co_yield
برای تعلیق اجرای یک مقدار استفاده میشود.co_return
برای تکمیل اجرای یک مقدار استفاده میشود.به صورت بنیادی کوروتین ها در استاندارد ۲۰ سی پلاس پلاس، لایه ای مبتنی بر توابع آبجکت ها هستند. پس با استفاده از آنها، کامپایلر عملا یک چارچوب کد در اطراف کوروتین شما ایجاد میکند; که این کد به ۲ نکته زیر وابستگی دارد:
تا زمانی که نوعی از انواع استاندارد را در C ++ 23 داشته باشیم ، خودمان باید این کدها را بنویسیم.
در این نوشته سعی شده تا جای ممکن از مترادف ها و معانی کلمات تخصصی استفاده شود، اما برای برخی از کلمات تخصصی بهترین شکل ترجمه، استفاده از خود کلمات است...
لذا در این نوشته برخی از کلمات بدون ترجمه هستند.
کوروتین ها نمیتوانند از آرگومان های متغیر، عبارات بازگشتی ساده، یا انواع Placeholder ها (خودکار یا مفهومی) استفاده کنند.
توابع Constexpr، سازنده (constructor) ، تخریب کننده (destructor) و تابع اصلی (main) نمی توانند کوروتین باشند.
هر کوروتین در اجرا با موارد زیر در ارتباط است:
و سلسله مراتبی که با اجرای یک کوروتین رخ میدهند، به شرح زیر است:
promise.get_return_object
فراخوانی شده و نتیجه آن در یک متغیر محلی ذخیره میشود.promise.initial_suspend
فراخوانی میشود و نتیجه آن به co_await
پاس داده میشود.بیایید نگاهی به یک کوروتین بسیار ساده بیندازیم، کوروتینی که کاری انجام نمیدهد:
در نگاه اول به نظر میرسد: که تنها برای فراخوانی یک 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 معلق در قالب داده ای خالص موجود است. پس با پاس دادن آن بین تِرد (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 استفاده کرده ایم.. 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 ملزم است تا ۳ متد را ارائه دهد.
بیایید به یک مثال ساده (هرچند بی معنی) نگاهی بیندازیم.
در اینجا کوروتین قبل از ورود به متد await_suspend
معلق می شود. هیچ دیتا رِیسی وجود ندارد زیرا ما (پس از نقطه تعلیق) یک ترد جدید در داخل این متد ایجاد می کنیم. همچنین با توجه به مطلبی که کمی قبل عنوان کردیم ("کنترل به تماس گیرنده بازگردانده می شود") ، میتوانیم اجرای کوروتین را در ترد تازه ایجاد شده، از سر بگیریم...
سپس می توانیم از این کد در یک کوروتین استفاده کنیم:
در پایان کمی درباره موارد استفاده نیز صحبت خواهیم کرد. به طور کلی در موارد زیر استفاده از کوروتین ها توصیه میشود اما ممکن است بنا به نیازهای شما، شرایط کمی تغییر کند
اگر محدود به یک تک رشته هستید ، کوروتین ها راه حل مناسبی برای پردازش ناهمزمان هستند. به راحتی میتونید یک حلقه رویداد (Event Loop) به سبک جاوا اسکریپت پیاده سازی کنید و از آن بهره ببرید.
اگر مثال بالا را در یک سمت طیف پیچیدگی قرار دهیم (سمت کوچک آن) ، این مثال در نقطه مقابل آن قرار دارد. اگر محصول شما باید از رشته های فراوان و سبک استفاده کند، کوروتین ها در صرفه جویی حافظه به شما کمک میکنند.
این مثال نکته بسیار آشکاری است. اگر کتابخانه ها awaitable های پیش ساخته شده و پشتیبانی از انواع کوروتین را در اختیارتان بگذارد، پس استفاده کردن از کوروتین ها رویکرد تمیز تری نسبت به سایر alternative ها است. زیرا تمام مراحل همگام سازی با کوروتین ها در پشت فراخوانی co_await
اتفاق می افتد.
با پردازش همزمان ، می توانید ظرفیت سرویس خود را با کنترل تعداد رشته ها کنترل کنید. هنگام رسیدن به اضافه بار ، می توانید درخواست ها را هنگام فرارسیدن رد (reject) کنید. متاسفانه جلوگیری از اضافه بار با کوروتین کمی پیچیده می شود ، چون ممکن است در حین رسیدگی به یک درخواست ، سیستم دچار اضافه بار شود.
یکی از قسمت هایی که در حین نوشتن این مطلب هنوز به طور کامل برای خودم هم جا نیفتاده است، مدیریت تایم اوت هاست. البته که راه هایی برای مدیریت تایم اوت ها وجود دارد و چیز جدیدی هم نیست. مثلا به قطعه کد زیر دقت کنید:
مدیریت تایم اوت ها با چنین رویکردی امکان پذیر است، اما مسلما راه معادلی برای انجام این کار با کوروتین هم وجود دارد، که من هنوز آن را پیدا نکردم (چنانچه نظری درباره پیاده سازی آن دارید، لطفا کامنت کنید.)
همه نمونه کدهای موجود در این نوشته با استفاده از نسخه اصلی GCC (منتشر شده در سپتامبر ۲۰۲۱) قابل اجرا هستند. و یا میتوانید این ابزار را با فلگ زیر کامپایل کنید:
g++ -fcoroutines -std=c++20
همچنین نمونه کدها در ریپازیتوری زیر موجود هستند:
https://github.com/AliBayat/CPP-20-Coroutines