برنامه نویسی غیرهمزمان (Asynchoronous) با استفاده از Async و Await

شنبه 17 مهر 1395

این مقاله درباره کلمات کلیدی Async Await در C# صحبت می کند، اشتباهات متداول در هنگام استفاده از آن ها را شرح می دهد و توصیه هایی برای آسان تر کردن پیاده سازی این موارد، ارائه می کند.

 برنامه نویسی غیرهمزمان (Asynchoronous) با استفاده از Async و Await

کلمات کلیدی async  و  await از نسخه 5 زبان C# در آن تعبیه شده اند. این ویژگی ها در پاییز 2013 به عنوان بخشی از Visual Studio 2013 به بازار عرضه شدند و می توانند تا میزان زیادی، برنامه نویسی غیز همزمان را ساده تر کنند.

پایه ی C# Async   و  Await

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

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

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

 هر برنامه با رابط کاربری گرافیکی (GUI) یک حلقه مدیریتی اصلی دارد که به صورت پیوسته، محتویات این صف را چک می کند. اگر پیامی در صف وجود داشته باشد که هنوز پردازش نشده باشد، آن را از صف برمیدارد و پردازش می کند. در زبان های سطح بالاتر مانند C# ، این کار معمولا به وسیله ی به کارگیری event handler مناسب انجام می شود. این کد در event handler به صورت همزمان اجرا می شود. تا زمانی که این کار به صورت کامل انجام نشده است، سایر پیام ها مورد پردازش قرار نخواهند گرفت. اگر این کار، مدت زمان زیادی طول بکشد، برنامه خطای عدم پاسخگویی را به سمت رابط کاربری ارسال می کند.

برنامه نویسی غیرهمزمان با استفاده از کلمات کلیدی async    و  await  یک راه آسان برای جلوگیری از این مشکل را به شما ارائه می کند. به عنوان مثال، event handler زیر به صورت همزمان یک منبع HTTP را دانلود می کند.

private void OnRequestDownload(object sender, RoutedEventArgs e)
{
    var request = HttpWebRequest.Create(_requestedUri);
    var response = request.GetResponse();
    // process the response
}

در زیر نسخه ی غیرهمزمان آن نیز آورده شده است:

private async void OnRequestDownload(object sender, RoutedEventArgs e)
{
    var request = HttpWebRequest.Create(_requestedUri);
    var response = await request.GetResponseAsync();
    // process the response
}

 تنها سه تغییر اصلی در کد ایجاد شده است :

method signature ما از حالت void به async void تغییر کرده است که این مورد مشخص می کند که متد به شیوه  ی asynchronous است که به ما اجازه می دهد در بدنه این متد از کلمه کلیدی await استفاده کنیم.

به جای فراخوانی متد همزمان GetResponse ما در حال فرخوانی متد ناهمزمان GetResponseAsync هستیم. بر اساس یک قاعده ، معمولا متدهای ناهمزمان ، پسوند Async دارند.

ما کلمه کلیدی await را قبل از فراخوانی متد ناهمزمان اضافه کرده ایم.

این کار، رفتار مربوط به event handler را تغییر می دهد. فقط بخشی از متد در فراخوانی GetResponseAsync به صورت همزمان اجرا می شود. در این نقطه، اجرای event handler متوقف می شود و برنامه به پردازش پیام های موجود در صف باز می گردد.

همچنین فرآیند دانلود در پس زمینه کار ادامه پیدا می کند. زمانی که این فرآیند تکمیل شود، یک پیام جدید به صف می فرستد. زمانی که حلقه ی پیام، آن را پردازش کرد، اجرای متد event handler از بعد از فراخوانی GetResponseAsync ادامه پیدا می کند. در ابتدا، پاسخی از نوع Task<WebResponse> به WebResponse متصل می شود که در متغیر response تعریف شده است. سپس ، باقی روند اجرا به همان صورت معمول انجام می گیرد. کامپایلر ، همه بخش های کد مورد نیاز برای این کار را تولید می کند.

اگر چه برنامه های وب بر روی سرور به UI thread مخصوص به خودشان نیاز ندارند، همچنان می توانند از مزایای برنامه نویسی ناهمزمان بهره بگیرند. هر درخواست، یک پردازش جداگانه و مخصوص به خودش دارد. تا زمانی که thread با یک درخواست ، اشغال شده باشد، نمی تواند درخواست دیگری را پردازش کند. تا زمانی که thread  های محدودی برای این کار در thread pool در دسترس باشند، به همان میزان، تعداد درخواست هایی که به صورت موازی می توانند پردازش شوند، نیز کاهش پیدا می کند. هر thread   ای که منتظر تکمیل شدن یک فرآیند ورودی-خروجی باشد، یک منبع را اسراف می کند. اما اگر عملیات ورودی-خروجی به صورت ناهمزمان انجام بگیرد، thread تا زمانی که عملیات تکمیل نشود، مورد نیاز نخواهد بود و به صورت آزاد به thread pool باز می گردد و می تواند در پردازش درخواست های بعدی مورد استفاده قرار بگیرد. اگر چه این روش ممکن است تاخیر مربوط به یک درخواست را افزایش بدهد، اما بازدهی کلی سیستم را بالا می برد.

استفاده از کلمات کلیدی async  و  await  فقط به برنامه نویسی ناهمزمان محدود نشده است. با استفاده از Task Parallel Library (TPL) شما می توانید عملیات فشرده CPU را به یک بخش جداگانه ببرید. این کار با فراخوانی Task.Run انجام می گیرد. شما می توانید وظیفه بازگرداننده شده را به همان روش مشابه با استفاده از یک متد ناهمزمان ، await کنید تا از مسدود شدن UI thread ها جلوگیری کند. برخلاف عملیات حقیقی ناهمزمان ، عملی که آن را به حالت await در آورده ایم باز هم از thread استفاده می کند. این استراتژی برای برنامه های وب، کاربردی نیست، زیرا thread خاصی در این گونه برنامه ها در دسترس نیست.

برنامه نویسی ناهمزمان C# Async Await – اشتباهات متداول

کلمات کلیدی async  و  awaitیک مزیت عالی برای توسعه دهندگان C# دارند و آن ، آسان سازی برنامه نویسی ناهمزمان است. در اغلب موارد، فرد می تواند از این موارد، بدون دانستن جزئیات استفاده کند. حداقل ، تا زمانی که کامپایلر یک اعتبار سنج خوب و کافی است، کد یک رفتار معقول از خود نشان خواهد داد. اگر چه در بعضی موارد یک کد ناهمزمان که نادرست نوشته شده است، به صورت موفق در این روش کامپایل می شود ، ولی خب همچنان مشکلات احتمالی و باگ های زیادی را رفع می کند.

بیایید نگاهی به متداول ترین اشتباهات بیندازیم.

Avoid با استفاده از Async Void

شیوه نوشتن متد ناهمزمانی که ما در کد دیدیم، به صورت async void است.

به جای این کار شما می توانید از async Task or async Task<T> در مواردی که امکان پذیر است، استفاده کنید. در این حالت، T نوع بازگشتی متد شما می باشد.

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

DoSomeStuff(); // synchronous method
await DoSomeLengthyStuffAsync(); // long-running asynchronous method
DoSomeMoreStuff(); // another synchronous method

این کار به کامپایلر اجازه می دهد تا  متد فراخوانی شونده را هنگام رسیدن به کلمه کلیدی await از سایر متدها جدا کند. اولین بخش با فراخوانی متد ناهمزمان خاتمه پیدا می کند و بخش دوم از نتایج بخش اول استفاده می کند و سپس به کار خودش ادامه می دهد.

برای استفاده از کلمه کلیدی await در یک متد، نوع خروجی این متد باید از نوع Task باشد. این کار به کامپایلر اجازه می دهد تا اجرای متد را ادامه بدهد. به عبارت دیگر ، این کار تا زمانی ادامه پیدا خواهد کرد که متد ما به شیوه ی async نوشته شده باشد .  اگر شیوه نوشتاری متد به صورت async void باشد ما می توانیم آن را بدون استفاده از کلمه کلیدی await فراخوانی کنیم.

DoSomeStuff(); // synchronous method
DoSomeLengthyStuffAsync(); // long-running asynchronous method
DoSomeMoreStuff(); // another synchronous method

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

برای این که از این مشکلات جلوگیری کنیم، در متد خودتان همواره از async Task استفاده کنید. استفاده از async void را فقط به event handler ها محدود کنید که اجازه ندارند هیچ نوعی را برگردانند و همچنین مطمئن بشوید که این موارد، هرگز توسط خودتان فراخوانی نشوند.

احتیاط در مورد بن بست ها

به عبارتی می توانیم اینطور بگوییم که متدهای asynchronous بقیه متدها را هم تحت تاثیر خودشان قرار می دهند. برای این که شما بتوانید یک method را با await به شیوه ناهمزمان فراخوانی کنید، باید متد فراخوانی شونده را نیز به صورت غیرهمزمان دربیاورید، حتی اگر این متد از ابتدا، async نبوده باشد.

ولی زمانی که همه متدها نمی توانند به شیوه ناهمزمان تعریف شوند، ممکن است با مشکلاتی روبرو شویم. به عنوان مثال ، constructor ها (سازنده ها) نمی توانند ناهمزمان باشند، بنابراین شما نمی توانید از کلمه کلیدی await در بدنه آن ها استفاده کنید. اگر چه شما می توانید از async void  نیز استفاده کنید، اما این کار شما را از اجرای کامل برنامه باز می دارد.

به عنوان جایگزین، شما می توانید از synchronously waiting (انتظار های همزمان ) برای متد ناهمزمان استفاده کنیم تا به این ترتیب متد بتواند به صورت کامل اجرا شود. به عنوان مثال در تکه کد زیر ، کد همزمانی که نوشته ایم، به صورت موقت ، استفاده از صف پیام ها را برای شما متوقف خواهد کرد و این کار ممکن است موجب ایجاد بن بست در فایل شما شود.

private async void MyEventHandler(object sender, RoutedEventArgs e)
{
    var instance = new InnocentLookingClass();
    // further code
}

هر متدی که به شیوه ی ناهمزمان در سازنده ی InnocentLookingClass فراخوانی شود، برای ایجاد یک بن بست کافی خواهد بود.

public class InnocentLookingClass()
{
    public InnocentLookingClass()
    {
        DoSomeLengthyStuffAsync().Wait();
        // do some more stuff
    }
 
    private async Task DoSomeLengthyStuffAsync()
    {
        await SomeOtherLengthyStuffAsync();
    }
 
    // other class members
}

روند اجرای تکه کد بالا به این صورت خواهد بود که : MyEventHandler به صورت همزمان ، سازنده ی InnocentLookingClass را فراخوانی می کند، که آن نیز DoSomeLengthyStuffAsync را به کار می گیرد که در ادامه به کارگیری ناهمزمان SomeOtherLengthyStuffAsync را نیز در پی خواهد داشت. اجرای متدهای بعدی شروع خواهند شد و در همین زمان نیز thread ، بخش at Wait را تا اجرای کامل DoSomeLengthyStuffAsync بلاک می کند(تا قادر به انجام هیچ گونه عملی نباشد) و هر گونه کنترلی بر روی صف پیام ها را می بندد.

در نهایت SomeOtherLengthyStuffAsync کامل شده و یک پیام به صف پیام ها می فرستد که این پیام اعلام می کند که اجرای DoSomeLengthyStuffAsync می تواند ادامه پیدا کند.متاسفانه thread اصلی منتظر اجرای کامل این بخش باقی می ماند و به پردازش پیام ها نمی پردازد و بنابراین روند اجرای برنامه کامل نخواهد شد.

اجازه ادامه اجرا بر روی یک Thread دیگر

بن بستی که در مثال بالا آن را مطرح کردیم، در صورتی که DoSomeLengthyStuffAsync نیاز به ادامه اجرا در thread اصلی را لازم نداشت، هرگز اتفاق نمی افتاد. اگر اینطور که عمل کنیم که در صورت خالی نبودن یک thread  ، ادامه اجرا  بر روی یکthread  دیگر، انجام بگیرد، برنامه روند درست و منطقی خواهد داشت.

ویژگی های از پیش برنامه ریزی شده برای C# 7

طراحان تصمیم گرفتند که در نسخه اولیه این زبان در 2013 این ویژگی async   و  await را به صورت گسترده ای، مورد استفاده قرار بدهند.

به عنوان مثال، در C# 6.0 (که در Visual Studio 2015 ارائه شد) ، پشتیبانی برای استفاده از await را در درون بلاک های catch فراهم کرد. این کار، باعث سهولت در استفاده از این متدها در بخش مدیریت خطاها نیز شد.

پشتیبانی از Async Main

در بخشی که در مورد بن بست ها صحبت کردیم، بررسی کردیم که چگونه به کار گیری آن ها می تواند باعث بروز مشکلاتی شود. اما این روش برای فریم ورک های مبتنی بر event (مانند Windows Forms  و  WPF) می تواند به خوبی کار کندو زیرا event handler ها می توانند به صورت امن از async void استفاده کنند. همچنین برای برنامه های ASP.NET MVC نیز پشتیبانی از asynchronous action method ها وجود دارد.

متد Main به عنوان یک بخش ورودی برای برنامه های کنسول نباید به صورت ناهمزمان فعالیت کند. برای فراخوانی متدهای ناهمزمان در یک برنامه کنسول ، شما نیاز دارید از متد top-level asynchronous wrapper استفاده کنید و آن را به صورت همزمان در درون Main استفاده کنید:

static void Main()
{	
    MainAsync().Wait();
}

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

طراحان زبان ها ویژگی" پشتیبانی از نقطه ورود داده ها به صورت همزمان " را در طراحی هایشان در نظر می گیرند. (در برنامه های کنسول، این نقاط، همان کامپایلر ها محسوب می شوند) این کار می تواند تمامی حالت های زیر را برای نوشتن و استفاده، امکان پذیر کند.

// current valid entry point signatures
void Main()
int Main()
void Main(string[])
int Main(string[])
// proposed additional valid entry point signatures in C# 7
async Task Main()
async Task<int> Main()
async Task Main(string[])
async Task<int> Main(string[])

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

بهبود کارایی

علاوه بر مواردی که در بالا در مورد آن ها صحبت کردیم و جزو مزایای استفاده از کلمات کلیدی async  و  await محسوب می شوند، یکی دیگر از مزایا، میزان اختصاص و استفاده از حافظه (memory allocation) است.

هر متد ناهمزمان ، باعث ایجاد سه شی در درون heap می شود:

الف) state machine همراه با متغیرهای محلی مربوط به متد

 ب) delegate که باید در ادامه کار مورد فراخوانی قرار بگیرد

ج) Task بازگشتی

این دسته بندی ها تا میزان زیادی باعث بهبود حافظه می شوند. دو اختصاص حافظه ای که در ابتدا نام بردیم (یعنی مورد الف و ب ) تنها زمانی اتفاق می افتند که  مورد نیاز باشند. به عنوان مثال زمانی که یک متد ناهمزمان دیگر در حالت await باشد. این حالت زمانی اتفاق می افتد که متد زیر در حالت true فراخوانی شود:

private async Task DoSomeWorkAsync(bool doRealWork)
{
    if (doRealWork)
    {
        await DoSomeRealWorkAsync();
    }
}

این یک مثال ساختگی است، اما در متدهای درون دنیای واقعی تفاوت هایی نیز وجود دارد.

در مورد اختصاص حافظه به returned task (Task بازگشتی ) نیز موارد بهینه سازی لحاظ شده است. شی های task متداول (برای مقادیر 0و1و true و false  و غیره) عمل cache انجام می گیرد تا به این ترتیب، از اختصاص حافظه به موارد استفاده بعدی از این متغیر ها جلوگیری شود. (یعنی یک بار یک خانه از حافظه را به 0 (به عنوان مثال) اختصاص می دهد و هر زمان دیگری در برنامه از متغیر 0 استفاده شده بود، دیگر حافظه ای به آن اختصاص داده نمی شود و از همان قبلی استفاده می شود. ).

در C# 7 موارد بیشتری برای کاربران به ارمغان آورده شد که یکی از این موارد، امکان برگرداندن نوع خروجی در قالب ValueTask<T> به جای Task<T> است. همانطور که از نام آن نیز پیداست، برخلاف Task<T> ، ValueTask<T> خودش یک ساختار محسوب می شود . به عنوان مثال یک نوع value به جای heap در یک پشته تعریف می شود. این کار باعث بهینه سازی حافظه می شود.

به جز مزایایی که در زمینه ی بهبود حافظه دارد، مزایایی نظیر بهبود راندمان سیستم نیز برای برنامه نویس فراهم می شود.

آموزش سی شارپ

برنامه نویسان

نویسنده 3355 مقاله در برنامه نویسان
  • C#.net
  • 5k بازدید
  • 7 تشکر

کاربرانی که از نویسنده این مقاله تشکر کرده اند

در صورتی که در رابطه با این مقاله سوالی دارید، در تاپیک های انجمن مطرح کنید