دات نت: ابزاری برای کار با چند رشته و ناهمزمان. قسمت 1

در حال انتشار اصل مقاله حبر هستم که ترجمه آن در شرکت قرار داده شده است پست وبلاگ.

نیاز به انجام کاری به صورت ناهمزمان، بدون انتظار برای نتیجه اینجا و اکنون، یا تقسیم کار بزرگ بین چندین واحد انجام دهنده آن، قبل از ظهور رایانه ها وجود داشت. با ظهور آنها این نیاز بسیار ملموس شد. اکنون، در سال 2019، من در حال تایپ این مقاله بر روی یک لپ تاپ با پردازنده 8 هسته ای Intel Core هستم که بیش از صد فرآیند به صورت موازی روی آن اجرا می شود، و حتی موضوعات بیشتری. در همان نزدیکی، یک تلفن کمی فرسوده وجود دارد که چند سال پیش خریداری شده است، یک پردازنده 8 هسته ای در آن وجود دارد. منابع موضوعی مملو از مقالات و ویدیوهایی است که نویسندگان آنها گوشی های هوشمند پرچمدار امسال را که دارای پردازنده های 16 هسته ای هستند تحسین می کنند. MS Azure یک ماشین مجازی با پردازنده 20 هسته ای و رم 128 ترابایتی را با قیمت کمتر از 2 دلار در ساعت ارائه می دهد. متأسفانه استخراج حداکثر و مهار این قدرت بدون مدیریت تعامل نخ ها غیرممکن است.

اصطلاحات

روند - شی OS، فضای آدرس ایزوله، حاوی موضوعات است.
نخ - یک شی OS، کوچکترین واحد اجرا، بخشی از یک فرآیند، رشته ها حافظه و سایر منابع را بین خود در یک فرآیند به اشتراک می گذارند.
چند وظیفه ای - ویژگی OS، توانایی اجرای چندین فرآیند به طور همزمان
چند هسته ای - ویژگی پردازنده، توانایی استفاده از چندین هسته برای پردازش داده ها
چند پردازش - ویژگی یک کامپیوتر، توانایی کار همزمان با چندین پردازنده به صورت فیزیکی
چند رشته ای - خاصیت یک فرآیند، توانایی توزیع پردازش داده ها بین چندین رشته.
موازی سازی - انجام چندین عمل به صورت فیزیکی به صورت همزمان در واحد زمان
ناهمزمانی - اجرای یک عملیات بدون انتظار برای تکمیل این پردازش؛ نتیجه اجرا می تواند بعداً پردازش شود.

استعاره

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

هنگام تهیه صبحانه در صبح من (پردازنده) من به آشپزخانه می آیم (کامپیوتر). من 2 دست دارم (هسته). تعدادی دستگاه در آشپزخانه وجود دارد (IO): فر، کتری، توستر، یخچال. گاز را روشن می کنم و ماهیتابه را روی آن می گذارم و بدون اینکه صبر کنم تا داغ شود داخل آن روغن می ریزم (به صورت ناهمزمان، Non-Blocking-IO-Waitتخم مرغ ها را از یخچال خارج می کنم و در بشقاب می شکنم و با یک دست می زنم (موضوع شماره 1و دوم (موضوع شماره 2) نگه داشتن صفحه (Shared Resource). اکنون می خواهم کتری را روشن کنم، اما دستم به اندازه کافی نیست (گرسنگی نخ) در این مدت ماهیتابه داغ می شود (Processing the result) که من مقداری را که زده ام در آن می ریزم. دستم را به سمت کتری دراز می کنم و آن را روشن می کنم و احمقانه به جوشیدن آب در آن نگاه می کنم (مسدود کردن-IO-صبر کنید) اگرچه در این مدت می توانست بشقاب را که در آن املت را هم زده بود بشویید.

من فقط با 2 دست املت پختم و بیشتر ندارم، اما همزمان در لحظه هم زدن املت، 3 عمل همزمان انجام شد: هم زدن املت، نگه داشتن بشقاب، گرم کردن ماهیتابه. CPU سریعترین بخش کامپیوتر است، IO چیزی است که اغلب همه چیز کند می شود، بنابراین اغلب یک راه حل موثر این است که CPU را با چیزی در حین دریافت داده از IO اشغال کنید.

ادامه استعاره:

  • اگر در فرآیند تهیه املت، سعی کنم لباس عوض کنم، این نمونه ای از چند کار است. نکته مهم: کامپیوترها در این کار بسیار بهتر از مردم هستند.
  • یک آشپزخانه با چندین سرآشپز، به عنوان مثال در یک رستوران - یک کامپیوتر چند هسته ای.
  • بسیاری از رستوران ها در یک فودکورت در یک مرکز خرید - مرکز داده

ابزارهای دات نت

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

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

شروع یک تاپیک

کلاس Thread ابتدایی ترین کلاس در دات نت برای کار با thread ها است. سازنده یکی از دو نماینده را می پذیرد:

  • ThreadStart - بدون پارامتر
  • ParametrizedThreadStart - با یک پارامتر از نوع شی.

نماینده پس از فراخوانی متد Start در رشته جدید ایجاد شده اجرا می شود.اگر یک نماینده از نوع ParametrizedThreadStart به سازنده ارسال شده باشد، پس یک شی باید به متد Start ارسال شود. این مکانیسم برای انتقال هرگونه اطلاعات محلی به جریان مورد نیاز است. شایان ذکر است که ایجاد یک thread یک عملیات گران است و خود Thread یک شی سنگین است، حداقل به این دلیل که 1MB حافظه را در پشته اختصاص می دهد و نیاز به تعامل با API سیستم عامل دارد.

new Thread(...).Start(...);

کلاس ThreadPool مفهوم pool را نشان می دهد. در دات نت، Thread Pool یک قطعه مهندسی است و توسعه دهندگان مایکروسافت تلاش زیادی برای اطمینان از عملکرد بهینه آن در طیف گسترده ای از سناریوها انجام داده اند.

مفهوم کلی:

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

برای استفاده از thread از pool، یک متد QueueUserWorkItem وجود دارد که یک نماینده از نوع WaitCallback را می پذیرد که امضای مشابهی با ParametrizedThreadStart دارد و پارامتری که به آن ارسال می شود همان عملکرد را انجام می دهد.

ThreadPool.QueueUserWorkItem(...);

روش Thread Pool کمتر شناخته شده RegisterWaitForSingleObject برای سازماندهی عملیات غیر مسدود کننده IO استفاده می شود. نماینده ارسال شده به این متد زمانی فراخوانی می شود که WaitHandle ارسال شده به متد "Released" باشد.

ThreadPool.RegisterWaitForSingleObject(...)

دات نت دارای یک تایمر رشته است و تفاوت آن با تایمرهای WinForms/WPF در این است که کنترل کننده آن بر روی رشته ای که از استخر گرفته شده است فراخوانی می شود.

System.Threading.Timer

همچنین یک روش نسبتا عجیب و غریب برای ارسال یک نماینده برای اجرا به یک رشته از استخر وجود دارد - روش BeginInvoke.

DelegateInstance.BeginInvoke

من می خواهم به طور خلاصه در مورد عملکردی که بسیاری از روش های فوق را می توان به آن فراخوانی کرد - CreateThread از Kernel32.dll Win32 API. به لطف مکانیسم روش های خارجی، راهی برای فراخوانی این تابع وجود دارد. من فقط یک بار چنین تماسی را در یک نمونه وحشتناک از کد میراث دیده ام و انگیزه نویسنده ای که دقیقاً این کار را انجام داده است هنوز برای من یک راز باقی مانده است.

Kernel32.dll CreateThread

مشاهده و رفع اشکال موضوعات

موضوعات ایجاد شده توسط شما، تمام اجزای شخص ثالث و استخر دات نت را می توان در پنجره Threads ویژوال استودیو مشاهده کرد. این پنجره فقط زمانی اطلاعات رشته را نمایش می دهد که برنامه در حال اشکال زدایی و در حالت Break باشد. در اینجا می توانید به راحتی نام پشته ها و اولویت های هر رشته را مشاهده کنید و اشکال زدایی را به یک رشته خاص تغییر دهید. با استفاده از ویژگی Priority کلاس Thread، می توانید اولویت یک رشته را تنظیم کنید، که OC و CLR هنگام تقسیم زمان پردازشگر بین رشته ها، آن را به عنوان یک توصیه درک می کنند.

دات نت: ابزاری برای کار با چند رشته و ناهمزمان. قسمت 1

کتابخانه موازی کار

Task Parallel Library (TPL) در .NET 4.0 معرفی شد. اکنون استاندارد و ابزار اصلی کار با ناهمزمان است. هر کدی که از رویکرد قدیمی‌تری استفاده می‌کند، میراث محسوب می‌شود. واحد اصلی TPL کلاس Task از فضای نام System.Threading.Tasks است. وظیفه یک انتزاع بر روی یک موضوع است. با نسخه جدید زبان C#، روشی زیبا برای کار با Tasks به دست آوردیم - عملگرهای async/wait. این مفاهیم نوشتن کدهای ناهمزمان را به گونه‌ای ساده و همزمان امکان‌پذیر می‌سازد، این امر حتی برای افرادی که درک کمی از عملکردهای داخلی رشته‌ها ندارند، می‌تواند برنامه‌هایی را بنویسد که از آنها استفاده می‌کنند، برنامه‌هایی که هنگام انجام عملیات طولانی فریز نمی‌شوند. استفاده از async/wait موضوعی برای یک یا حتی چند مقاله است، اما سعی می‌کنم در چند جمله به اصل آن بپردازم:

  • async یک اصلاح کننده روشی است که Task یا Void را برمی گرداند
  • و await یک اپراتور غیر مسدود کننده Task انتظار است.

یک بار دیگر: عملگر await، در حالت کلی (استثناهایی وجود دارد)، رشته اجرای فعلی را بیشتر آزاد می کند، و زمانی که Task اجرای خود را به پایان رساند، و رشته (در واقع، گفتن زمینه درست تر است. ، اما در ادامه در مورد آن بیشتر خواهد شد) به اجرای روش ادامه خواهد داد. در داخل دات نت، این مکانیزم به همان روش بازگشت بازده پیاده سازی می شود، زمانی که متد نوشته شده به یک کلاس کامل تبدیل می شود که یک ماشین حالت است و بسته به این حالت ها می تواند در قطعات جداگانه اجرا شود. هر کسی که علاقه مند است می تواند هر کد ساده ای را با استفاده از asynс/await بنویسد، اسمبلی را با استفاده از JetBrains dotPeek با فعال کردن کد تولید کامپایلر کامپایل و مشاهده کند.

بیایید به گزینه هایی برای راه اندازی و استفاده از Task نگاه کنیم. در مثال کد زیر، یک کار جدید ایجاد می کنیم که هیچ فایده ای ندارد (Thread.Sleep(10000))، اما در زندگی واقعی این باید یک کار پیچیده با CPU فشرده باشد.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

یک Task با تعدادی گزینه ایجاد می شود:

  • LongRunning اشاره ای است به این که کار به سرعت تکمیل نخواهد شد، به این معنی که ممکن است ارزش آن را داشته باشد که نخی از استخر نگیرید، بلکه یک رشته جداگانه برای این Task ایجاد کنید تا به دیگران آسیب نرسانید.
  • AttachedToParent - وظایف را می توان در یک سلسله مراتب مرتب کرد. اگر از این گزینه استفاده می شد، ممکن است Task در حالتی باشد که خودش کامل شده باشد و منتظر اجرای فرزندانش باشد.
  • PreferFairness - به این معنی است که بهتر است وظایفی که برای اجرا ارسال شده اند زودتر از کارهایی که دیرتر ارسال می شوند، اجرا شوند. اما این فقط یک توصیه است و نتایج تضمین نشده است.

دومین پارامتر ارسال شده به متد CancellationToken است. برای مدیریت صحیح لغو یک عملیات پس از شروع آن، کد در حال اجرا باید با بررسی وضعیت CancellationToken پر شود. اگر هیچ بررسی وجود نداشته باشد، متد Cancel که روی شی CancellationTokenSource فراخوانی شده است، می‌تواند اجرای Task را فقط قبل از شروع آن متوقف کند.

آخرین پارامتر یک شی زمانبندی از نوع TaskScheduler است. این کلاس و فرزندان آن برای کنترل استراتژی‌های توزیع Tasks در رشته‌ها طراحی شده‌اند؛ به طور پیش‌فرض، Task بر روی یک رشته تصادفی از Pool اجرا می‌شود.

عملگر await بر روی Task ایجاد شده اعمال می شود، به این معنی که کد نوشته شده بعد از آن، اگر وجود داشته باشد، در همان زمینه (اغلب این به معنای روی همان رشته است) با کد قبل از انتظار اجرا می شود.

این روش به‌عنوان غیر همگام‌سازی void علامت‌گذاری شده است، به این معنی که می‌تواند از اپراتور انتظار استفاده کند، اما کد فراخوانی نمی‌تواند منتظر اجرا بماند. اگر چنین ویژگی ضروری است، متد باید Task را برگرداند. روش‌هایی که با علامت عدم همگام‌سازی علامت‌گذاری شده‌اند بسیار رایج هستند: به عنوان یک قاعده، اینها کنترل‌کننده‌های رویداد یا روش‌های دیگری هستند که روی اصل آتش کار می‌کنند و فراموش می‌کنند. اگر لازم است نه تنها فرصت صبر کنید تا پایان اجرا، بلکه نتیجه را نیز برگردانید، پس باید از Task استفاده کنید.

در Task که متد StartNew برگردانده است، و همچنین در هر مورد دیگری، می توانید متد ConfigureAwait را با پارامتر false فراخوانی کنید، سپس اجرای پس از انتظار نه در زمینه ضبط شده، بلکه در یک متن دلخواه ادامه می یابد. این باید همیشه زمانی انجام شود که زمینه اجرا برای کد پس از انتظار مهم نباشد. این نیز توصیه ای از MS هنگام نوشتن کدی است که به صورت بسته بندی شده در یک کتابخانه تحویل داده می شود.

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

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

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

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

یکی دیگر از معایب این روش، مدیریت خطای پیچیده است. واقعیت این است که خطاهای موجود در کد ناهمزمان هنگام استفاده از async/wait بسیار آسان است - رفتار آنها مانند همگام بودن کد است. در حالی که اگر جن گیری انتظار همزمان را روی یک Task اعمال کنیم، استثنای اصلی به یک AggregateException تبدیل می شود، یعنی. برای رسیدگی به این استثنا، باید نوع InnerException را بررسی کنید و یک زنجیره if را در داخل یک بلوک catch بنویسید یا از catch هنگام ساخت استفاده کنید، به جای زنجیره بلوک های catch که در دنیای C# آشناتر است.

نمونه های سوم و آخر نیز به همین دلیل بد علامت گذاری شده اند و همه مشکلات مشابهی دارند.

متدهای WhenAny و WhenAll برای انتظار برای گروهی از Tasks بسیار راحت هستند؛ آنها گروهی از Tasks را در یکی می پیچند، که یا زمانی که یک Task از گروه برای اولین بار راه اندازی می شود، یا زمانی که همه آنها اجرای خود را به پایان رسانده اند، فعال می شوند.

توقف رشته ها

به دلایل مختلف، ممکن است لازم باشد که جریان پس از شروع آن متوقف شود. برای انجام این کار چندین راه وجود دارد. کلاس Thread دو متد با نام مناسب دارد: سقط и وقفه. اولین مورد برای استفاده بسیار توصیه نمی شود، زیرا پس از فراخوانی آن در هر لحظه تصادفی، در طول پردازش هر دستورالعمل، یک استثنا پرتاب می شود ThreadAbortedException. شما انتظار ندارید چنین استثنایی هنگام افزایش هر متغیر عدد صحیح ایجاد شود، درست است؟ و هنگام استفاده از این روش، این یک وضعیت بسیار واقعی است. اگر می‌خواهید از ایجاد چنین استثنایی در بخش خاصی از کد توسط CLR جلوگیری کنید، می‌توانید آن را در تماس‌ها قرار دهید. Thread.BeginCriticalRegion, Thread.EndCriticalRegion. هر کدی که در یک بلوک نهایی نوشته شود در چنین تماس هایی قرار می گیرد. به همین دلیل، در اعماق کد فریمورک می‌توانید بلوک‌هایی را با یک امتحان خالی پیدا کنید، اما نهایتاً خالی نیست. مایکروسافت آنقدر از این روش منصرف شده است که آن را در هسته نت قرار نداده است.

روش وقفه قابل پیش بینی تر عمل می کند. می تواند موضوع را با یک استثنا قطع کند ThreadInterruptedException فقط در آن لحظاتی که نخ در حالت انتظار است. در حالی که منتظر WaitHandle، قفل کردن، یا پس از فراخوانی Thread.Sleep است، در حالت آویزان قرار می گیرد.

هر دو گزینه توضیح داده شده در بالا به دلیل غیرقابل پیش بینی بودن بد هستند. راه حل استفاده از ساختار است CancellationToken و کلاس CancellationTokenSource. نکته اینجاست: یک نمونه از کلاس CancellationTokenSource ایجاد می شود و تنها کسی که آن را دارد می تواند با فراخوانی متد عملیات را متوقف کند. لغو کردن. فقط CancellationToken به خود عملیات منتقل می شود. دارندگان CancellationToken نمی توانند خودشان عملیات را لغو کنند، اما فقط می توانند بررسی کنند که آیا عملیات لغو شده است یا خیر. یک خاصیت Boolean برای این وجود دارد لغو درخواست شده است و روش ThrowIfCancelRequested. دومی یک استثنا ایجاد می کند TaskCancelledException اگر متد Cancel در نمونه CancellationToken در حال طوطی گری فراخوانی شده باشد. و این روشی است که من استفاده از آن را توصیه می کنم. این یک بهبود نسبت به گزینه های قبلی با به دست آوردن کنترل کامل بر روی این است که در چه نقطه ای یک عملیات استثنایی می تواند لغو شود.

بی رحمانه ترین گزینه برای توقف یک رشته، فراخوانی تابع Win32 API TerminateThread است. رفتار CLR پس از فراخوانی این تابع ممکن است غیرقابل پیش بینی باشد. در MSDN موارد زیر در مورد این تابع نوشته شده است: "TerminateThread یک تابع خطرناک است که فقط باید در شدیدترین موارد استفاده شود. "

تبدیل API قدیمی به Task Based با استفاده از روش FromAsync

اگر به اندازه کافی خوش شانس هستید که روی پروژه ای کار کنید که پس از معرفی Tasks شروع شد و دیگر باعث وحشت بی سر و صدا برای اکثر توسعه دهندگان نشد، دیگر مجبور نخواهید بود با بسیاری از API های قدیمی، چه API های شخص ثالث و چه آنهایی که تیم شما هستند، سر و کار داشته باشید. در گذشته شکنجه کرده است خوشبختانه تیم دات نت فریم ورک از ما مراقبت کرد، هرچند شاید هدف مراقبت از خودمان بود. همانطور که ممکن است، دات نت دارای تعدادی ابزار برای تبدیل بدون دردسر کد نوشته شده در رویکردهای برنامه نویسی ناهمزمان قدیمی به روش جدید است. یکی از آنها متد FromAsync TaskFactory است. در مثال کد زیر، من روش‌های async قدیمی کلاس WebRequest را با استفاده از این روش در یک Task قرار می‌دهم.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

این فقط یک مثال است و بعید است که مجبور باشید این کار را با انواع داخلی انجام دهید، اما هر پروژه قدیمی به سادگی مملو از متدهای BeginDoSomething است که متدهای IAsyncResult و EndDoSomething را دریافت می‌کنند.

API قدیمی را با استفاده از کلاس TaskCompletionSource به Task Based تبدیل کنید

ابزار مهم دیگری که باید در نظر بگیرید کلاس است TaskCompletionSource. از نظر توابع، هدف و اصل عملکرد، ممکن است تا حدودی یادآور متد RegisterWaitForSingleObject از کلاس ThreadPool باشد که در بالا در مورد آن نوشتم. با استفاده از این کلاس، می توانید API های ناهمزمان قدیمی را به راحتی و به راحتی در Tasks قرار دهید.

شما خواهید گفت که من قبلاً در مورد متد FromAsync از کلاس TaskFactory که برای این اهداف در نظر گرفته شده است صحبت کرده ام. در اینجا باید کل تاریخچه توسعه مدل‌های ناهمزمان در .net را که مایکروسافت در 15 سال گذشته ارائه کرده است، به خاطر بسپاریم: قبل از الگوی ناهمزمان مبتنی بر وظیفه (TAP)، الگوی برنامه‌نویسی ناهمزمان (APP) وجود داشت. در مورد روش ها بود شروعDoSomething در حال بازگشت است IAsyncResult و روش ها پایانDoSomething که آن را می پذیرد و برای میراث این سال ها، روش FromAsync کاملاً عالی است، اما به مرور زمان با الگوی ناهمزمان مبتنی بر رویداد جایگزین شد.و AP) که فرض می کند یک رویداد با تکمیل عملیات ناهمزمان مطرح می شود.

TaskCompletionSource برای بسته بندی Tasks و APIهای قدیمی ساخته شده حول مدل رویداد عالی است. ماهیت کار آن به شرح زیر است: یک شی از این کلاس دارای یک ویژگی عمومی از نوع Task است که وضعیت آن را می توان از طریق متدهای SetResult، SetException و غیره از کلاس TaskCompletionSource کنترل کرد. در جاهایی که عملگر await برای این Task اعمال شده است، بسته به روشی که در TaskCompletionSource اعمال می شود، با یک استثنا اجرا می شود یا با شکست مواجه می شود. اگر هنوز روشن نیست، اجازه دهید به این مثال کد نگاه کنیم، جایی که برخی از API های قدیمی EAP با استفاده از TaskCompletionSource در یک Task پیچیده شده است: وقتی رویداد فعال می شود، Task در حالت Completed قرار می گیرد و روشی که عملگر انتظار را اعمال می کند. به این Task با دریافت شی، اجرای خود را از سر می گیرد نتیجه.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

نکات و ترفندهای TaskCompletionSource

بسته بندی API های قدیمی تنها کاری نیست که می توان با استفاده از TaskCompletionSource انجام داد. استفاده از این کلاس امکان جالبی را برای طراحی API های مختلف روی Tasks که رشته ها را اشغال نمی کنند باز می کند. و جریان، همانطور که به یاد داریم، یک منبع گران قیمت است و تعداد آنها محدود است (عمدتاً با مقدار RAM). این محدودیت را می توان به راحتی با توسعه، به عنوان مثال، یک برنامه وب بارگذاری شده با منطق تجاری پیچیده به دست آورد. بیایید در هنگام اجرای چنین ترفندی مانند Long-Polling، احتمالاتی را که در مورد آنها صحبت می کنم در نظر بگیریم.

به طور خلاصه، ماهیت ترفند این است: شما باید اطلاعاتی را از API در مورد برخی از رویدادها دریافت کنید، در حالی که API به دلایلی نمی تواند رویداد را گزارش کند، اما فقط می تواند وضعیت را برگرداند. نمونه‌ای از اینها همه APIهایی هستند که قبل از زمان WebSocket یا زمانی که استفاده از این فناوری به دلایلی غیرممکن بود، بر روی HTTP ساخته شده‌اند. مشتری می تواند از سرور HTTP بپرسد. سرور HTTP خودش نمی تواند ارتباط با مشتری را آغاز کند. یک راه حل ساده این است که با استفاده از یک تایمر از سرور نظرسنجی کنید، اما این یک بار اضافی روی سرور ایجاد می کند و یک تاخیر اضافی در میانگین TimerInterval / 2 ایجاد می کند. برای دور زدن این موضوع، ترفندی به نام Long Polling ابداع شد که شامل تاخیر در پاسخگویی می شود. سرور تا زمانی که Timeout منقضی شود یا یک رویداد رخ دهد. اگر رویداد رخ داده باشد، پردازش می شود، اگر نه، درخواست دوباره ارسال می شود.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

اما به محض اینکه تعداد مشتریان منتظر رویداد افزایش یابد، چنین راه حلی وحشتناک خواهد بود، زیرا ... هر مشتری از این قبیل یک رشته کامل را در انتظار یک رویداد اشغال می کند. بله، و زمانی که رویداد راه‌اندازی می‌شود، 1 میلی‌ثانیه تأخیر اضافی دریافت می‌کنیم، اغلب این امر مهم نیست، اما چرا نرم‌افزار را بدتر از آن چیزی که می‌توانست کرد؟ اگر Thread.Sleep(1) را حذف کنیم، بیهوده یک هسته پردازنده را 100% بیکار بارگذاری می کنیم و در یک چرخه بی فایده می چرخد. با استفاده از TaskCompletionSource می توانید به راحتی این کد را بازسازی کنید و تمام مشکلات شناسایی شده در بالا را حل کنید:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

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

وقتی درخواستی برای یک پیام دریافت می‌کنیم، یک TaskCompletionSource را در فرهنگ لغت ایجاد کرده و قرار می‌دهیم، و سپس منتظر می‌شویم که ابتدا چه اتفاقی بیفتد: بازه زمانی مشخص شده منقضی شود یا پیامی دریافت شود.

ValueTask: چرا و چگونه

عملگرهای async/wait مانند عملگر بازگشت بازده، یک ماشین حالت از متد ایجاد می‌کنند و این ایجاد یک شی جدید است که تقریباً همیشه مهم نیست، اما در موارد نادر می‌تواند مشکل ایجاد کند. این مورد ممکن است روشی باشد که واقعاً اغلب نامیده می شود، ما در مورد ده ها و صدها هزار تماس در ثانیه صحبت می کنیم. اگر چنین روشی به گونه ای نوشته شده باشد که در بیشتر موارد نتیجه ای را با دور زدن همه روش های انتظار برمی گرداند، دات نت ابزاری را برای بهینه سازی آن ارائه می دهد - ساختار ValueTask. برای روشن شدن موضوع، بیایید به مثالی از کاربرد آن نگاه کنیم: حافظه پنهانی وجود دارد که ما اغلب به آن می رویم. مقادیری در آن وجود دارد و سپس به سادگی آنها را برمی گردانیم؛ اگر نه، برای دریافت آنها به سراغ یک IO کند می رویم. من می خواهم دومی را به صورت ناهمزمان انجام دهم، به این معنی که کل روش ناهمزمان می شود. بنابراین، روش واضح برای نوشتن متد به شرح زیر است:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

به دلیل تمایل به بهینه سازی کمی، و ترس جزئی از آنچه Roslyn هنگام کامپایل کردن این کد ایجاد می کند، می توانید این مثال را به صورت زیر بازنویسی کنید:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

در واقع، راه حل بهینه در این مورد، بهینه سازی مسیر داغ است، یعنی به دست آوردن یک مقدار از فرهنگ لغت بدون هیچ گونه تخصیص غیر ضروری و بارگذاری بر روی GC، در حالی که در موارد نادری که هنوز باید برای داده به IO برویم. ، همه چیز به صورت مثبت / منهای روش قدیمی باقی می ماند:

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

بیایید نگاهی دقیق‌تر به این قطعه کد بیندازیم: اگر مقداری در حافظه پنهان وجود داشته باشد، یک ساختار ایجاد می‌کنیم، در غیر این صورت وظیفه واقعی در یک قطعه معنی‌دار پیچیده می‌شود. کد فراخوانی اهمیتی ندارد که این کد در کدام مسیر اجرا شده است: ValueTask، از نقطه نظر نحو C#، در این مورد مانند یک Task معمولی عمل می کند.

Task Schedulers: مدیریت استراتژی های راه اندازی کار

API بعدی که می خواهم در نظر بگیرم کلاس است وظیفه زمانبندی و مشتقات آن قبلاً در بالا ذکر کردم که TPL توانایی مدیریت استراتژی های توزیع Tasks در سراسر رشته ها را دارد. چنین استراتژی هایی در نوادگان کلاس TaskScheduler تعریف شده اند. تقریباً هر استراتژی که ممکن است نیاز داشته باشید را می توانید در کتابخانه پیدا کنید. ParallelExtensionsExtras، توسط مایکروسافت توسعه یافته است، اما بخشی از دات نت نیست، اما به عنوان یک بسته Nuget عرضه شده است. بیایید به طور خلاصه به برخی از آنها نگاه کنیم:

  • CurrentThreadTaskScheduler - Tasks را روی رشته فعلی اجرا می کند
  • LimitedConcurrencyLevelTaskScheduler - تعداد وظایف اجرا شده به طور همزمان توسط پارامتر N که در سازنده پذیرفته شده است را محدود می کند.
  • OrderedTaskScheduler - به عنوان LimitedConcurrencyLevelTaskScheduler(1) تعریف می شود، بنابراین وظایف به صورت متوالی اجرا می شوند.
  • WorkStealingTaskScheduler - اجرا می کند کار دزدی رویکرد توزیع وظیفه در اصل یک ThreadPool جداگانه است. این مشکل را حل می کند که در .NET ThreadPool یک کلاس ثابت است، یکی برای همه برنامه ها، به این معنی که بارگذاری بیش از حد یا استفاده نادرست آن در یک قسمت از برنامه می تواند منجر به عوارض جانبی در قسمت دیگر شود. علاوه بر این، درک علت چنین نقص هایی بسیار دشوار است. که ممکن است نیاز به استفاده از WorkStealingTaskScheduler های جداگانه در بخش هایی از برنامه باشد که استفاده از ThreadPool ممکن است تهاجمی و غیرقابل پیش بینی باشد.
  • QueuedTaskScheduler - به شما امکان می دهد وظایف را مطابق با قوانین صف اولویت انجام دهید
  • ThreadPerTaskScheduler - برای هر Task که روی آن اجرا می شود یک رشته مجزا ایجاد می کند. می تواند برای کارهایی مفید باشد که به طور غیرقابل پیش بینی برای تکمیل آنها زمان می برد.

جزئیات خوبی وجود دارد مقاله درباره Task Schedulers در وبلاگ مایکروسافت.

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

دات نت: ابزاری برای کار با چند رشته و ناهمزمان. قسمت 1

PLinq و کلاس Parallel

علاوه بر Tasks و هر آنچه در مورد آنها گفته شد، دو ابزار جالب دیگر در دات نت وجود دارد: PLinq (Linq2Parallel) و کلاس Parallel. اولین نوید اجرای موازی تمام عملیات Linq در چندین رشته را می دهد. تعداد رشته ها را می توان با استفاده از روش گسترش WithDegreeOfParallelism پیکربندی کرد. متأسفانه، اغلب PLinq در حالت پیش فرض خود اطلاعات کافی در مورد داخلی منبع داده شما برای ارائه افزایش سرعت قابل توجه ندارد، از سوی دیگر، هزینه تلاش بسیار پایین است: فقط باید قبل از استفاده از روش AsParallel تماس بگیرید. زنجیره ای از روش های Linq و اجرای تست های عملکرد. علاوه بر این، این امکان وجود دارد که با استفاده از مکانیسم پارتیشن، اطلاعات بیشتری در مورد ماهیت منبع داده خود به PLinq ارسال کنید. می توانید بیشتر بخوانید اینجا и اینجا.

کلاس Parallel static متدهایی را برای تکرار از طریق یک مجموعه Foreach به صورت موازی، اجرای یک حلقه For و اجرای چندین نماینده به صورت موازی Invoke ارائه می دهد. اجرای thread فعلی تا زمان تکمیل محاسبات متوقف خواهد شد. با پاس کردن ParallelOptions به عنوان آخرین آرگومان، می‌توان تعداد رشته‌ها را پیکربندی کرد. همچنین می توانید TaskScheduler و CancellationToken را با استفاده از گزینه ها مشخص کنید.

یافته ها

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

نتیجه گیری:

  • برای استفاده از منابع رایانه های شخصی مدرن باید ابزارهای کار با رشته ها، ناهمزمانی و موازی سازی را بدانید.
  • دات نت ابزارهای مختلفی برای این اهداف دارد
  • همه آنها به یکباره ظاهر نشدند، بنابراین اغلب می توانید نمونه های قدیمی را پیدا کنید، با این حال، راه هایی برای تبدیل API های قدیمی بدون تلاش زیاد وجود دارد.
  • کار با thread ها در دات نت توسط کلاس های Thread و ThreadPool نشان داده می شود.
  • متدهای Thread.Abort، Thread.Interrupt و Win32 API TerminateThread خطرناک هستند و برای استفاده توصیه نمی شوند. در عوض، بهتر است از مکانیسم CancellationToken استفاده کنید
  • جریان یک منبع ارزشمند است و عرضه آن محدود است. از موقعیت هایی که رشته ها در انتظار رویدادها هستند باید اجتناب شود. برای این کار استفاده از کلاس TaskCompletionSource راحت است
  • قدرتمندترین و پیشرفته ترین ابزار دات نت برای کار با موازی و ناهمزمانی Tasks هستند.
  • اپراتورهای c# async/wait مفهوم انتظار غیر مسدود را پیاده سازی می کنند
  • با استفاده از کلاس های مشتق شده از TaskScheduler می توانید توزیع Tasks را در سراسر رشته ها کنترل کنید.
  • ساختار ValueTask می تواند در بهینه سازی مسیرهای داغ و ترافیک حافظه مفید باشد
  • پنجره های Tasks و Threads ویژوال استودیو اطلاعات زیادی را برای اشکال زدایی کدهای چند رشته ای یا ناهمزمان ارائه می دهند.
  • PLinq ابزار جالبی است، اما ممکن است اطلاعات کافی در مورد منبع داده شما نداشته باشد، اما می توان با استفاده از مکانیزم پارتیشن بندی آن را برطرف کرد.
  • ادامه ...

منبع: www.habr.com

اضافه کردن نظر