فصل ۱۴: Task، TaskCompletionSource و اصول برنامهنویسی ناهمگام
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
Taskها
Thread ابزاری سطح پایین برای ایجاد Concurrency است و به همین دلیل محدودیتهایی دارد؛ بهویژه موارد زیر:
- هرچند ارسال داده به Threadی که Start میکنید آسان است، راه سادهای برای گرفتن یک «Return Value» از Threadی که
Join میکنید وجود ندارد. باید نوعی Shared Field راهاندازی کنید. اگر Operation نیز Exception پرتاب کند، Catch و Propagate کردن آن Exception به همان اندازه دردسر دارد.
- نمیتوانید به Thread بگویید پس از پایان، کار دیگری را Start کند؛ در عوض باید آن را
Join کنید و در این فرایند Thread خودتان را Block کنید.
این محدودیتها Fine-grained Concurrency را دشوار میکنند؛ یعنی ساختن Operationهای Concurrent بزرگتر با ترکیب Operationهای کوچکتر را سخت میکنند، چیزی که برای Asynchronous Programming بخشهای بعدی ضروری است. در نتیجه وابستگی به Synchronization دستی ــ Locking، Signaling و مانند آن ــ و مشکلات همراه آن بیشتر میشود.
استفادهٔ مستقیم از Threadها پیامدهای Performance نیز دارد که در «The Thread Pool» صفحهٔ 646 بررسی کردیم. اگر لازم باشد صدها یا هزاران Operation همزمانِ I/O-bound اجرا کنید، رویکرد Thread-based فقط بابت سربار Thread صدها یا هزاران Megabyte حافظه مصرف میکند.
Class با نام Task به حل همهٔ این مشکلات کمک میکند. در مقایسه با Thread، Task یک Abstraction سطح بالاتر است: نمایندهٔ یک Operation Concurrent است که ممکن است پشت آن Thread وجود داشته باشد یا نداشته باشد. Taskها Compositional هستند؛ با Continuation میتوانید آنها را به هم Chain کنید. میتوانند برای کمکردن Startup Latency از Thread Pool استفاده کنند و با TaskCompletionSource میتوانند هنگام انتظار برای Operationهای I/O-bound از رویکرد Callback استفاده کنند و Thread را کلاً کنار بگذارند.
Typeهای Task در Framework 4.0 بهعنوان بخشی از Parallel Programming Library معرفی شدند. از آن زمان، با استفاده از Awaiterها، برای سناریوهای عمومیتر Concurrency نیز توسعه یافتهاند و Typeهای زیربنایی توابع ناهمگام C# هستند.
Start کردن یک Task
سادهترین راه برای Start کردن Taskی که پشت آن Thread قرار دارد استفاده از متد استاتیک Task.Run است؛ Class با نام Task در Namespace با نام System.Threading.Tasks قرار دارد. کافی است یک Delegate از نوع Action بدهید:
Task.Run (() => Console.WriteLine ("Foo"));
Task.Run (() => Console.WriteLine ("Foo"));
Console.ReadLine();
فراخوانی Task.Run به این شکل مشابه Start کردن Thread زیر است؛ بهجز پیامدهای Thread Pooling که کمی بعد توضیح میدهیم:
new Thread (() => Console.WriteLine ("Foo")).Start();
Task.Run یک Object از نوع Task برمیگرداند که میتوانیم با آن Progress را Monitor کنیم، تقریباً مانند Object از نوع Thread. توجه کنید پس از Task.Run، Start را فراخوانی نکردیم، چون این Method Taskهای «Hot» ایجاد میکند. در عوض میتوانید با Constructor در Task Task «Cold» بسازید، هرچند در عمل بهندرت چنین کاری انجام میشود.
با Property با نام Status میتوانید Execution Status یک Task را Track کنید.
Wait
فراخوانی Wait روی Task تا پایان آن Block میشود و معادل فراخوانی Join روی Thread است:
Task task = Task.Run (() =>
{
Thread.Sleep (2000);
Console.WriteLine ("Foo");
});
Console.WriteLine (task.IsCompleted); // False
task.Wait(); // Blocks until task is complete
Wait بهصورت اختیاری اجازه میدهد Timeout و Cancellation Token مشخص کنید تا انتظار زودتر پایان یابد؛ «Cancellation» در صفحهٔ 681 را ببینید.
Taskهای Long-running
بهطور پیشفرض CLR، Taskها را روی Pooled Threadها اجرا میکند؛ این برای Work کوتاهمدت و Compute-bound ایدئال است. برای Operationهای طولانیتر و Blocking ــ مانند مثال قبل ــ میتوانید استفاده از Pooled Thread را چنین متوقف کنید:
Task task = Task.Factory.StartNew (() => ...,
TaskCreationOptions.LongRunning);
- اگر Taskها I/O-bound باشند،
TaskCompletionSource و توابع ناهمگام اجازه میدهند Concurrency را با Callbackها یا Continuationها، بهجای Threadها، پیادهسازی کنید. - اگر Taskها Compute-bound باشند، Producer/Consumer Queue میتواند Concurrency را Throttle کند تا Threadها و Processهای دیگر Starve نشوند؛ «Writing a Producer/Consumer Queue» در صفحهٔ 970 را ببینید.
برگرداندن Value
Task یک Subclass جنریک به نام Task<TResult> دارد که اجازه میدهد Task یک Return Value تولید کند. برای گرفتن Task<TResult>، بهجای Delegate از نوع Action یک Func<TResult> ــ یا Lambda سازگار ــ به Task.Run بدهید:
Task<int> task = Task.Run (() => { Console.WriteLine ("Foo"); return 3; });
// ...
بعداً با Query کردن Property با نام Result میتوانید نتیجه را بگیرید. اگر Task هنوز پایان نیافته باشد، دسترسی به این Property، Thread فعلی را تا پایان Task Block میکند:
int result = task.Result; // Blocks if not already finished
Console.WriteLine (result); // 3
در مثال بعد Taskی میسازیم که با LINQ تعداد Prime Numberها را در نخستین سه میلیون عدد صحیح ــ بهاضافهٔ 2 ــ میشمارد:
Task<int> primeNumberTask = Task.Run (() =>
Enumerable.Range (2, 3000000).Count (n =>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));
Console.WriteLine ("Task running...");
Console.WriteLine ("The answer is " + primeNumberTask.Result);
ابتدا Task running... نوشته میشود و چند ثانیه بعد پاسخ 216816 چاپ میشود.
Exceptionها
برخلاف Threadها، Taskها Exceptionها را بهراحتی Propagate میکنند. بنابراین اگر کد داخل Task یک Exception مدیریتنشده پرتاب کند ــ یعنی Task Fault شود ــ آن Exception خودکار به کسی که Wait() را فراخوانی کند یا به Property با نام Result در Task<TResult> دسترسی بگیرد دوباره پرتاب میشود.
// Start a Task that throws a NullReferenceException:
Task task = Task.Run (() => { throw null; });
try
{
task.Wait();
}
catch (AggregateException aex)
{
if (aex.InnerException is NullReferenceException)
Console.WriteLine ("Null!");
else
throw;
}
CLR برای سازگاری با سناریوهای Parallel Programming، Exception را در یک AggregateException Wrap میکند؛ در فصل ۲۲ این موضوع را توضیح میدهیم.
بدون دوبارهپرتاب کردن Exception میتوانید با Propertyهای IsFaulted و IsCanceled بررسی کنید Task Fault شده است یا نه. اگر هر دو Property مقدار false بدهند، Errorی رخ نداده است. اگر IsCanceled برابر true باشد، برای آن Task یک OperationCanceledException پرتاب شده است؛ «Cancellation» در صفحهٔ 941 را ببینید. اگر IsFaulted برابر true باشد، نوع دیگری از Exception پرتاب شده و Property با نام Exception Error را مشخص میکند.
Exceptionها و Taskهای Autonomous
برای Taskهای مستقل از نوع «Set-and-forget» ــ Taskهایی که با Wait() یا Result یا Continuation مشابهی با آنها Rendezvous نمیکنید ــ بهتر است کد Task را صریحاً Exception-handle کنید تا Failure خاموش رخ ندهد؛ همانطور که با Thread انجام میدهید.
میتوانید در سطح Global از طریق Event استاتیک TaskScheduler.UnobservedTaskException به Exceptionهای مشاهدهنشده Subscribe کنید؛ Handle کردن این Event و Log کردن Error میتواند تصمیم خوبی باشد.
دربارهٔ اینکه چه چیزی Unobserved محسوب میشود چند ظرافت جالب وجود دارد:
- Taskهایی که با Timeout منتظرشان میمانید، اگر Fault پس از فاصلهٔ Timeout رخ دهد، یک Unobserved Exception ایجاد میکنند.
- صرفِ بررسی Property با نام
Exception پس از Fault شدن Task باعث میشود Exception «Observed» محسوب شود.
Continuationها
Continuation به Task میگوید: «وقتی تمام شدی، با انجام کار دیگری ادامه بده.» Continuation معمولاً بهصورت Callback پیادهسازی میشود که یک بار، هنگام Complete شدن Operation، اجرا میشود. دو راه برای Attach کردن Continuation به Task وجود دارد. راه اول اهمیت ویژهای دارد، چون همانطور که بهزودی میبینید در توابع ناهمگام C# استفاده میشود. میتوانیم آن را با Task شمارش Prime Number که کمی قبل در «Returning Values» صفحهٔ 650 نوشتیم نشان دهیم:
Task<int> primeNumberTask = Task.Run (() =>
Enumerable.Range (2, 3000000).Count (n =>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));
var awaiter = primeNumberTask.GetAwaiter();
awaiter.OnCompleted (() =>
{
int result = awaiter.GetResult();
Console.WriteLine (result); // Writes result
});
فراخوانی GetAwaiter روی Task یک Object از نوع Awaiter برمیگرداند که متد OnCompleted آن به Antecedent Task ــ یعنی primeNumberTask ــ میگوید وقتی تمام یا Fault شد Delegateای را اجرا کند. Attach کردن Continuation به Taskی که از قبل Complete شده نیز معتبر است؛ در این حالت Continuation بلافاصله برای اجرا Schedule میشود.
اگر Antecedent Task Fault شود، هنگامی که Continuation کد awaiter.GetResult() را فراخوانی میکند Exception دوباره پرتاب میشود. بهجای GetResult میتوانستیم مستقیم به Property با نام Result در Antecedent دسترسی بگیریم. مزیت GetResult این است که اگر Antecedent Fault شود، Exception بدون Wrap شدن در AggregateException مستقیماً پرتاب میشود و Catch Blockها سادهتر و تمیزتر میشوند.
برای Taskهای Nongeneric، GetResult() Return Value از نوع void دارد؛ در این حالت تنها کار مفیدش دوبارهپرتاب کردن Exceptionهاست.
اگر Synchronization Context وجود داشته باشد، OnCompleted آن را خودکار Capture میکند و Continuation را به همان Context Post میکند. این در Rich Client Applicationها بسیار مفید است، چون Continuation را به UI Thread برمیگرداند. اما هنگام نوشتن Library معمولاً مطلوب نیست، چون Bounce نسبتاً پرهزینه به UI Thread بهتر است فقط یک بار هنگام خروج از Library رخ دهد نه بین Method Callها. بنابراین میتوانید با ConfigureAwait این رفتار را متوقف کنید:
var awaiter = primeNumberTask.ConfigureAwait (false).GetAwaiter();
اگر Synchronization Context وجود نداشته باشد، یا از ConfigureAwait(false) استفاده کنید، Continuation بهطور کلی روی Pooled Thread اجرا خواهد شد.
راه دوم برای Attach کردن Continuation فراخوانی متد ContinueWith در Task است:
primeNumberTask.ContinueWith (antecedent =>
{
int result = antecedent.Result;
Console.WriteLine (result); // Writes 123
});
ContinueWith خودش یک Task برمیگرداند و اگر بخواهید Continuationهای بیشتری Attach کنید مفید است. با این حال، در صورت Fault شدن Task باید مستقیم با AggregateException سروکار داشته باشید و در UI Applicationها برای Marshal کردن Continuation کد اضافی بنویسید؛ «Task Schedulers» در صفحهٔ 962 را ببینید. در Contextهای غیر UI نیز اگر میخواهید Continuation روی همان Thread اجرا شود باید TaskContinuationOptions.ExecuteSynchronously را مشخص کنید؛ وگرنه به Thread Pool Bounce میشود. ContinueWith بهخصوص در سناریوهای Parallel Programming مفید است و در فصل ۲۲ با جزئیات بررسی میشود.
TaskCompletionSource
دیدیم Task.Run چگونه Taskی ایجاد میکند که Delegate را روی Pooled Thread ــ یا Non-pooled Thread ــ اجرا میکند. راه دیگر ساختن Task استفاده از TaskCompletionSource است.
TaskCompletionSource اجازه میدهد از هر Operationای که در آینده Complete میشود Task بسازید. این کار با دادن یک Task «Slave» به شما انجام میشود که خودتان بهصورت دستی Drive میکنید؛ یعنی مشخص میکنید Operation چه زمانی تمام یا Fault شده است. این برای Work از نوع I/O-bound ایدئال است: همهٔ مزایای Taskها ــ Propagate کردن Return Value، Exception و Continuation ــ را بدون Block کردن Thread در تمام مدت Operation میگیرید.
برای استفاده کافی است Class را Instantiate کنید. Property با نام Task را ارائه میدهد که Taskی برمیگرداند که مانند هر Task دیگری میتوانید روی آن Wait کنید و Continuation Attach کنید. اما Task کاملاً از طریق Object با نام TaskCompletionSource و Methodهای زیر کنترل میشود:
public class TaskCompletionSource<TResult>
{
public void SetResult (TResult result);
public void SetException (Exception exception);
public void SetCanceled();
public bool TrySetResult (TResult result);
public bool TrySetException (Exception exception);
public bool TrySetCanceled();
public bool TrySetCanceled (CancellationToken cancellationToken);
...
}
فراخوانی هرکدام از این Methodها به Task Signal میدهد و آن را در State از نوع Completed، Faulted یا Canceled قرار میدهد؛ مورد آخر را در «Cancellation» صفحهٔ 681 بررسی میکنیم. باید دقیقاً یک بار یکی از این Methodها را فراخوانی کنید: اگر دوباره فراخوانی شوند، SetResult، SetException یا SetCanceled Exception پرتاب میکنند، در حالی که Methodهای Try* مقدار false برمیگردانند.
مثال زیر پس از پنج ثانیه انتظار، عدد 42 را چاپ میکند:
var tcs = new TaskCompletionSource<int>();
new Thread (() => { Thread.Sleep (5000); tcs.SetResult (42); })
{ IsBackground = true }
.Start();
Task<int> task = tcs.Task; // Our "slave" task.
Console.WriteLine (task.Result); // 42
با TaskCompletionSource میتوانیم Method با نام Run خودمان را بنویسیم:
Task<TResult> Run<TResult> (Func<TResult> function)
{
var tcs = new TaskCompletionSource<TResult>();
new Thread (() =>
{
try { tcs.SetResult (function()); }
catch (Exception ex) { tcs.SetException (ex); }
}).Start();
return tcs.Task;
}
...
Task<int> task = Run (() => { Thread.Sleep (5000); return 42; });
فراخوانی این Method معادل فراخوانی Task.Factory.StartNew با Option از نوع TaskCreationOptions.LongRunning برای درخواست یک Non-pooled Thread است.
قدرت واقعی TaskCompletionSource در ساخت Taskهایی است که Thread را اشغال نمیکنند. مثلاً Taskی را در نظر بگیرید که پنج ثانیه منتظر میماند و سپس عدد 42 را برمیگرداند. میتوانیم بدون Thread و با استفاده از Class با نام Timer این کار را انجام دهیم؛ Timer با کمک CLR و در نهایت OS پس از x Millisecond Eventی را Fire میکند. در فصل ۲۱ دوباره Timerها را بررسی میکنیم:
Task<int> GetAnswerToLife()
{
var tcs = new TaskCompletionSource<int>();
// Create a timer that fires once in 5000 ms:
var timer = new System.Timers.Timer (5000) { AutoReset = false };
timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult (42); };
timer.Start();
return tcs.Task;
}
بنابراین Method ما Taskی برمیگرداند که پنج ثانیه بعد با Result برابر 42 Complete میشود. با Attach کردن Continuation به Task میتوانیم Result را بدون Block کردن هیچ Threadی بنویسیم:
var awaiter = GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted (() => Console.WriteLine (awaiter.GetResult()));
میتوانیم این Method را کاربردیتر کنیم و با Parameter کردن مدت Delay و حذف Return Value به یک Method عمومی Delay تبدیل کنیم. یعنی باید بهجای Task<int>، Task برگرداند. اما نسخهٔ Nongeneric از TaskCompletionSource وجود ندارد، پس نمیتوانیم مستقیم یک Nongeneric Task بسازیم. Workaround ساده است: چون Task<TResult> از Task مشتق میشود، یک TaskCompletionSource<anything> میسازیم و سپس Task<anything> آن را Implicit به Task Convert میکنیم:
var tcs = new TaskCompletionSource<object>();
Task task = tcs.Task;
حالا Method عمومی Delay را مینویسیم:
Task Delay (int milliseconds)
{
var tcs = new TaskCompletionSource<object>();
var timer = new System.Timers.Timer (milliseconds) { AutoReset = false };
timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult (null); };
timer.Start();
return tcs.Task;
}
برای نوشتن 42 بعد از پنج ثانیه میتوانیم چنین استفاده کنیم:
Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
استفاده از TaskCompletionSource بدون Thread یعنی Thread فقط زمانی درگیر میشود که Continuation پنج ثانیه بعد شروع شود. میتوانیم با Start کردن 10,000 مورد از این Operationها بهطور همزمان، بدون Error یا مصرف بیشازحد Resource، این موضوع را نشان دهیم:
for (int i = 0; i < 10000; i++)
Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
Task.Delay
Method با نام Delay که همین حالا نوشتیم آنقدر مفید است که بهصورت متد استاتیک روی Class با نام Task وجود دارد:
Task.Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
یا:
Task.Delay (5000).ContinueWith (ant => Console.WriteLine (42));
Task.Delay معادل ناهمگام Thread.Sleep است.
اصول ناهمگامی
در توضیح TaskCompletionSource ناخواسته Methodهای Asynchronous نوشتیم. در این بخش دقیقاً تعریف میکنیم Operation ناهمگام چیست و توضیح میدهیم چگونه به Asynchronous Programming منتهی میشود.
Operationهای Synchronous در برابر Asynchronous
یک Operation Synchronous کارش را پیش از Return کردن به Caller انجام میدهد.
یک Operation Asynchronous میتواند بیشتر یا تمام کارش را پس از Return کردن به Caller انجام دهد.
بیشتر Methodهایی که مینویسید و فراخوانی میکنید Synchronous هستند؛ برای نمونه List<T>.Add، Console.WriteLine یا Thread.Sleep. Methodهای Asynchronous کمتر رایجاند و Concurrency را آغاز میکنند، چون Work در Parallel با Caller ادامه مییابد. Methodهای Asynchronous معمولاً سریع ــ یا بلافاصله ــ به Caller برمیگردند؛ بنابراین Nonblocking Method نیز نامیده میشوند.
بیشتر Methodهای ناهمگامی که تا اینجا دیدهایم را میتوان Methodهای General-purpose دانست:
Thread.StartTask.Run- Methodهایی که Continuation را به Task Attach میکنند.
افزون بر این، برخی Methodهایی که در «Synchronization Contexts» صفحهٔ 645 بررسی کردیم ــ Dispatcher.BeginInvoke، Control.BeginInvoke و SynchronizationContext.Post ــ Asynchronous هستند، همانطور که Methodهایی که در «TaskCompletionSource» صفحهٔ 653 نوشتیم، از جمله Delay، Asynchronous هستند.
Asynchronous Programming چیست؟
اصل Asynchronous Programming این است که Functionهای Long-running ــ یا بالقوه Long-running ــ را بهصورت Asynchronous بنویسید. این در برابر رویکرد سنتی قرار میگیرد که Functionهای Long-running را Synchronous مینویسید و سپس برای ایجاد Concurrency در زمان نیاز، آن Functionها را از یک Thread یا Task جدید فراخوانی میکنید.
تفاوت رویکرد Asynchronous این است که Concurrency درون Function طولانی آغاز میشود، نه از بیرون Function. این کار دو مزیت دارد:
- Concurrency از نوع I/O-bound را میتوان بدون اشغال Thread پیادهسازی کرد؛ همانطور که در «TaskCompletionSource» صفحهٔ 653 نشان دادیم. این Scalability و Efficiency را بهتر میکند.
- در Rich Client Applicationها کد کمتری روی Worker Thread اجرا میشود و Thread Safety سادهتر میشود.
این موضوع به دو کاربرد متمایز برای Asynchronous Programming میانجامد. کاربرد اول نوشتن Applicationهایی ــ معمولاً Server-side ــ است که با حجم زیادی از I/O همزمان بهطور کارآمد کار میکنند. چالش اینجا Thread Safety نیست، چون Shared State معمولاً کم است؛ چالش Thread Efficiency است، بهخصوص اینکه برای هر Network Request یک Thread مصرف نشود. بنابراین در این Context فقط Operationهای I/O-bound از Asynchrony سود میبرند.
کاربرد دوم سادهکردن Thread Safety در Rich Client Applicationهاست. این موضوع با بزرگشدن برنامه اهمیت بیشتری پیدا میکند، چون برای مدیریت Complexity معمولاً Methodهای بزرگ را به Methodهای کوچکتر Refactor میکنیم و زنجیرهای از Methodها شکل میگیرد که یکدیگر را فراخوانی میکنند؛ Call Graph.
در Call Graph سنتی Synchronous، اگر هر Operation در Graph طولانی باشد، برای حفظ Responsive UI باید کل Call Graph را روی Worker Thread اجرا کنیم. در نتیجه یک Operation Concurrent واحد داریم که Methodهای زیادی را در بر میگیرد ــ Coarse-grained Concurrency ــ و Thread Safety همهٔ Methodهای Graph باید در نظر گرفته شود.
در Call Graph ناهمگام لازم نیست Thread را تا زمانی که واقعاً نیاز است Start کنیم؛ معمولاً در سطوح پایین Graph، و برای Operationهای I/O-bound شاید اصلاً Thread لازم نباشد. همهٔ Methodهای دیگر میتوانند کاملاً روی UI Thread اجرا شوند و Thread Safety بسیار سادهتر میشود. نتیجه Fine-grained Concurrency است: دنبالهای از Operationهای Concurrent کوچک که در فاصلهٔ آنها Execution به UI Thread Bounce میشود.
در این فصل بیشتر بر سناریوی Rich Client تمرکز میکنیم که پیچیدهتر از آن دو است. در فصل ۱۶ دو Example ارائه میکنیم که سناریوی I/O-bound را نشان میدهند؛ «Concurrency with TCP» در صفحهٔ 762 و «Writing an HTTP Server» در صفحهٔ 755 را ببینید.
Asynchronous Programming و Continuationها
Taskها برای Asynchronous Programming ایدئالاند، چون از Continuationها پشتیبانی میکنند و Continuation برای Asynchrony ضروری است؛ Method با نام Delay را که در «TaskCompletionSource» صفحهٔ 653 نوشتیم در نظر بگیرید. برای نوشتن Delay از TaskCompletionSource استفاده کردیم که روش استاندارد برای پیادهسازی Methodهای ناهمگامِ I/O-bound در پایین Call Graph است.
برای Methodهای Compute-bound از Task.Run برای آغاز Thread-bound Concurrency استفاده میکنیم. صرفاً با Return کردن Task به Caller، یک Method Asynchronous میسازیم.
آنچه Asynchronous Programming را متمایز میکند این است که میکوشیم این کار را پایینتر در Call Graph انجام دهیم تا در Rich Client Applicationها، Methodهای سطح بالا روی UI Thread بمانند و بدون مشکلات Thread Safety به Controlها و Shared State دسترسی داشته باشند. برای توضیح، Method زیر را در نظر بگیرید که با استفاده از همهٔ Coreهای موجود Prime Numberها را محاسبه و میشمارد؛ ParallelEnumerable را در فصل ۲۲ بررسی میکنیم:
int GetPrimesCount (int start, int count)
{
return
ParallelEnumerable.Range (start, count).Count (n =>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0));
}
جزئیات عملکرد آن مهم نیست؛ مهم این است که اجرای آن میتواند طول بکشد. میتوانیم با Method دیگری آن را نشان دهیم:
void DisplayPrimeCounts()
{
for (int i = 0; i < 10; i++)
Console.WriteLine (GetPrimesCount (i*1000000 + 2, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
Console.WriteLine ("Done!");
}
خروجی:
78498 primes between 0 and 999999
70435 primes between 1000000 and 1999999
67883 primes between 2000000 and 2999999
66330 primes between 3000000 and 3999999
65367 primes between 4000000 and 4999999
64336 primes between 5000000 and 5999999
63799 primes between 6000000 and 6999999
63129 primes between 7000000 and 7999999
62712 primes between 8000000 and 8999999
62090 primes between 9000000 and 9999999
اکنون یک Call Graph داریم که DisplayPrimeCounts، GetPrimesCount را فراخوانی میکند. اولی برای سادگی از Console.WriteLine استفاده میکند، هرچند در واقعیت احتمال بیشتری دارد در Rich Client Application، UI Controlها را Update کند؛ بعداً نشان میدهیم.
میتوانیم برای این Call Graph، Coarse-grained Concurrency را چنین آغاز کنیم:
Task.Run (() => DisplayPrimeCounts());
در رویکرد Fine-grained Asynchronous، در عوض کار را با نوشتن نسخهٔ ناهمگام GetPrimesCount شروع میکنیم:
Task<int> GetPrimesCountAsync (int start, int count)
{
return Task.Run (() =>
ParallelEnumerable.Range (start, count).Count (n =>
Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0)));
}
چرا پشتیبانی زبان مهم است
حالا باید DisplayPrimeCounts را تغییر دهیم تا GetPrimesCountAsync را فراخوانی کند. اینجاست که Keywordهای await و async در C# وارد میشوند، چون انجام این کار بدون آنها سختتر از چیزی است که به نظر میرسد. اگر Loop را فقط چنین تغییر دهیم:
for (int i = 0; i < 10; i++)
{
var awaiter = GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted (() =>
Console.WriteLine (awaiter.GetResult() + " primes between... "));
}
Console.WriteLine ("Done");
Loop بهسرعت 10 Iteration را طی میکند، چون Methodها Nonblocking هستند؛ همهٔ 10 Operation بهصورت Parallel اجرا میشوند و عبارت Done هم زودتر از موعد چاپ میشود.
برای اجرای Sequential باید Iteration بعدی Loop را از خود Continuation Trigger کنیم. این یعنی حذف Loop از نوع for و استفاده از Recursive Call در Continuation:
void DisplayPrimeCounts()
{
DisplayPrimeCountsFrom (0);
}
void DisplayPrimeCountsFrom (int i)
{
var awaiter = GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted (() =>
{
Console.WriteLine (awaiter.GetResult() + " primes between...");
if (++i < 10) DisplayPrimeCountsFrom (i);
else Console.WriteLine ("Done");
});
}
اگر بخواهیم خود DisplayPrimesCount نیز Asynchronous باشد و Taskی برگرداند که هنگام Complete شدن Signal شود، وضعیت بدتر هم میشود. برای این کار باید TaskCompletionSource بسازیم:
Task DisplayPrimeCountsAsync()
{
var machine = new PrimesStateMachine();
machine.DisplayPrimeCountsFrom (0);
return machine.Task;
}
class PrimesStateMachine
{
TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>();
public Task Task { get { return _tcs.Task; } }
public void DisplayPrimeCountsFrom (int i)
{
var awaiter = GetPrimesCountAsync (i*1000000+2, 1000000).GetAwaiter();
awaiter.OnCompleted (() =>
{
Console.WriteLine (awaiter.GetResult());
if (++i < 10) DisplayPrimeCountsFrom (i);
else { Console.WriteLine ("Done"); _tcs.SetResult (null); }
});
ادامهٔ این State Machine در صفحهٔ بعد میآید و نشان میدهد چرا async و await برای حذف Plumbing و Complexity ناهمگامی ضروریاند.