فصل ۱۴: async و await، توابع ناهمگام و اجرای موازی در C#
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
خوشبختانه، توابع ناهمگام C# همهٔ این کارها را برای ما انجام میدهند. با کلیدواژههای async و await فقط کافی است چنین بنویسیم:
async Task DisplayPrimeCountsAsync()
{
for (int i = 0; i < 10; i++)
Console.WriteLine (await GetPrimesCountAsync (i*1000000 + 2, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
Console.WriteLine ("Done!");
}
در نتیجه، async و await برای پیادهسازی ناهمگامی بدون پیچیدگی بیش از حد ضروریاند. اکنون ببینیم این کلیدواژهها چگونه کار میکنند.
توابع ناهمگام در C# (Asynchronous Functions in C#)
کلیدواژههای async و await اجازه میدهند کد ناهمگامی بنویسید که همان ساختار و سادگی کد همگام را دارد، در حالی که «لولهکشی» برنامهنویسی ناهمگام را حذف میکند.
Await کردن
کلیدواژهٔ await Attach کردن Continuationها را ساده میکند. در یک سناریوی پایه، Compiler این کد را:
var result = await expression;
statement(s);
به چیزی با کارکرد مشابه زیر بسط میدهد:
var awaiter = expression.GetAwaiter();
awaiter.OnCompleted (() =>
{
var result = awaiter.GetResult();
برای نمایش موضوع، دوباره به Method ناهمگامی برگردیم که پیشتر نوشتیم و Prime Numberها را محاسبه و شمارش میکند:
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)));
}
با کلیدواژهٔ await میتوانیم آن را چنین فراخوانی کنیم:
int result = await GetPrimesCountAsync (2, 1000000);
Console.WriteLine (result);
برای Compile شدن باید Modifier با نام async را به Method دربرگیرنده اضافه کنیم:
async void DisplayPrimesCount()
{
int result = await GetPrimesCountAsync (2, 1000000);
Console.WriteLine (result);
}
Modifier با نام async به Compiler میگوید اگر در آن Method ابهامی وجود داشت، await را Keyword در نظر بگیرد نه Identifier. این کار تضمین میکند کدی که پیش از C# 5 نوشته شده و شاید از await بهعنوان Identifier استفاده کرده است همچنان بدون خطا Compile شود. Modifier با نام async فقط روی Methodها و Lambda Expressionهایی قابل اعمال است که void یا ــ همانطور که بعداً میبینید ــ Task یا Task<TResult> برمیگردانند.
Methodهایی که Modifier با نام async دارند «توابع ناهمگام» نامیده میشوند، چون خودشان معمولاً ناهمگاماند. برای دیدن علت، بررسی کنیم Execution چگونه در یک تابع ناهمگام پیش میرود.
با رسیدن به یک Expression از نوع await، Execution بهطور معمول به Caller برمیگردد؛ تقریباً مانند yield return در Iterator. اما پیش از بازگشت، Runtime یک Continuation به Task مورد Await متصل میکند تا وقتی Task Complete شد، Execution دوباره به Method بپرد و از همان جایی که متوقف شده بود ادامه دهد. اگر Task Fault شود Exception آن دوباره پرتاب میشود؛ در غیر این صورت Return Value آن به Expression مربوط به await اختصاص مییابد.
میتوانیم همهٔ آنچه گفتیم را با نگاهکردن به بسط منطقی Method ناهمگام قبلی خلاصه کنیم:
void DisplayPrimesCount()
{
var awaiter = GetPrimesCountAsync (2, 1000000).GetAwaiter();
awaiter.OnCompleted (() =>
{
int result = awaiter.GetResult();
Console.WriteLine (result);
});
}
Expressionای که روی آن await میکنید معمولاً یک Task است؛ با این حال هر Objectی که Method با نام GetAwaiter داشته باشد و Awaiterی برگرداند که INotifyCompletion.OnCompleted را پیادهسازی کند، Method با نام GetResult با Type مناسب داشته باشد و Property بولی IsCompleted را ارائه دهد، Compiler را راضی میکند.
توجه کنید Expression مربوط به await در مثال ما به Type از نوع int ارزیابی میشود، زیرا Expression مورد Await یک Task<int> بود و GetAwaiter().GetResult() آن یک int برمیگرداند.
Await کردن یک Task غیرجنریک نیز مجاز است و یک Expression از نوع void ایجاد میکند:
await Task.Delay (5000);
Console.WriteLine ("Five seconds passed!");
Capture کردن State محلی
قدرت واقعی Expressionهای await این است که تقریباً هر جای کد میتوانند ظاهر شوند. بهطور مشخص، یک Expression از نوع await میتواند در یک تابع ناهمگام بهجای هر Expressionی قرار گیرد، بهجز داخل Statement از نوع lock یا Context از نوع unsafe.
در مثال بعد، داخل Loop از await استفاده میکنیم:
async void DisplayPrimeCounts()
{
for (int i = 0; i < 10; i++)
Console.WriteLine (await GetPrimesCountAsync (i*1000000+2, 1000000));
}
در نخستین اجرای GetPrimesCountAsync، بهسبب Expression از نوع await، Execution به Caller برمیگردد. وقتی Method Complete یا Fault شد، Execution از همان جای قبلی Resume میشود و Valueهای Local Variableها و Loop Counterها حفظ شدهاند.
بدون await، سادهترین معادل شاید همان مثالی باشد که در «Why Language Support Is Important» صفحهٔ 659 نوشتیم. Compiler راهبرد عمومیتری انتخاب میکند و چنین Methodهایی را به State Machine بازسازی میکند؛ تقریباً همانطور که با Iteratorها انجام میدهد.
Compiler برای Resume کردن Execution پس از Expression از نوع await به Continuationها ــ از طریق Awaiter Pattern ــ متکی است. در نتیجه، اگر کد روی UI Thread یک Rich Client Application اجرا شود، Synchronization Context تضمین میکند Execution روی همان Thread Resume شود.
در غیر این صورت، Execution روی هر Threadی که Task در آن Finish شده است Resume میشود. تغییر Thread ترتیب Execution را تغییر نمیدهد و معمولاً اهمیت چندانی ندارد، مگر اینکه بهنوعی به Thread Affinity متکی باشید؛ مثلاً با Thread-local Storage که در «Thread-Local Storage» صفحهٔ 923 آمده است. میتوانید آن را مانند گردش در یک شهر و گرفتن Taxi برای رفتن از مقصدی به مقصد دیگر تصور کنید: با Synchronization Context همیشه همان Taxi را میگیرید؛ بدون آن معمولاً هر بار Taxi متفاوتی خواهید داشت. در هر دو حالت، مسیر همان است.
Await در UI
میتوانیم توابع ناهمگام را در Contextی عملیتر نشان دهیم: UI سادهای مینویسیم که هنگام فراخوانی یک Method از نوع Compute-bound پاسخگو باقی بماند. ابتدا با راهحل همگام شروع کنیم:
class TestUI : Window
{
Button _button = new Button { Content = "Go" };
TextBlock _results = new TextBlock();
public TestUI()
{
var panel = new StackPanel();
panel.Children.Add (_button);
panel.Children.Add (_results);
Content = panel;
_button.Click += (sender, args) => Go();
}
void Go()
{
for (int i = 1; i < 5; i++)
_results.Text += GetPrimesCount (i * 1000000, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +
Environment.NewLine;
}
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));
}
}
با فشردن دکمهٔ «Go»، Application در مدتی که اجرای کد Compute-bound طول میکشد Unresponsive میشود. برای ناهمگامکردن آن دو مرحله داریم؛ مرحلهٔ اول رفتن به نسخهٔ ناهمگام 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)));
}
مرحلهٔ دوم تغییر Go برای فراخوانی GetPrimesCountAsync است:
async void Go()
{
_button.IsEnabled = false;
for (int i = 1; i < 5; i++)
_results.Text += await GetPrimesCountAsync (i * 1000000, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +
Environment.NewLine;
_button.IsEnabled = true;
}
این مثال سادگی برنامهنویسی با توابع ناهمگام را نشان میدهد: همانطور که همگام برنامه مینویسید، برنامهنویسی میکنید، اما بهجای توابع Blocking، توابع ناهمگام را فراخوانی و Await میکنید. فقط کد داخل GetPrimesCountAsync روی Worker Thread اجرا میشود؛ کد Go زمان را از UI Thread «اجاره» میکند. میتوان گفت Go نسبت به Message Loop بهصورت شبههمزمان اجرا میشود، زیرا اجرای آن با Eventهای دیگری که UI Thread پردازش میکند درهم میآمیزد.
در این شبههمزمانی، تنها نقطهای که Preemption میتواند رخ دهد هنگام یک await است. این Thread Safety را ساده میکند: در مثال ما تنها مشکل محتمل Reentrancy است ــ کلیک دوبارهٔ دکمه هنگام اجرای Operation ــ که با Disable کردن دکمه جلوی آن را میگیریم. همزمانی واقعی پایینتر در Call Stack و داخل کدی رخ میدهد که Task.Run فراخوانی کرده است. برای بهرهبردن از این Model، کدی که واقعاً Concurrent است باید از دسترسی به Shared State یا UI Controlها پرهیز کند.
برای مثال دیگر، فرض کنید بهجای محاسبهٔ Prime Numberها میخواهیم چند Web Page را دانلود و مجموع Length آنها را محاسبه کنیم. .NET تعداد زیادی Method ناهمگامِ Task-returning ارائه میدهد؛ یکی از آنها در Class با نام WebClient در System.Net است. Method با نام DownloadDataTaskAsync یک URI را بهصورت ناهمگام در Byte Array دانلود میکند و Task<byte[]> برمیگرداند؛ پس با Await کردن آن یک byte[] میگیریم. حال Go را بازنویسی کنیم:
async void Go()
{
_button.IsEnabled = false;
string[] urls = "www.albahari.com www.oreilly.com www.linqpad.net".Split();
int totalLength = 0;
try
{
foreach (string url in urls)
{
var uri = new Uri ("http://" + url);
byte[] data = await new WebClient().DownloadDataTaskAsync (uri);
_results.Text += "Length of " + url + " is " + data.Length +
Environment.NewLine;
totalLength += data.Length;
}
_results.Text += "Total length: " + totalLength;
}
catch (WebException ex)
{
_results.Text += "Error: " + ex.Message;
}
finally { _button.IsEnabled = true; }
}
باز هم این کد همان چیزی را بازتاب میدهد که بهشکل همگام مینوشتیم، حتی استفاده از Blockهای catch و finally. هرچند Execution پس از نخستین await به Caller برمیگردد، Block با نام finally تا زمانی که Method از نظر منطقی Complete نشده باشد اجرا نمیشود؛ یعنی تا همهٔ کد آن اجرا نشده، یا یک return زودهنگام یا Exception مدیریتنشده رخ نداده باشد.
دانستن دقیق چیزی که در زیرِ سطح رخ میدهد مفید است. ابتدا باید دوباره Pseudo-code مربوط به Message Loop روی UI Thread را ببینیم:
Set synchronization context for this thread to WPF sync context
while (!thisApplication.Ended)
{
wait for something to appear in message queue
Got something: what kind of message is it?
Keyboard/mouse message -> fire an event handler
User BeginInvoke/Invoke message -> execute delegate
}
Event Handlerهایی که به UI Elementها Attach میکنیم از طریق همین Message Loop اجرا میشوند. وقتی Method با نام Go اجرا میشود، Execution تا Expression از نوع await پیش میرود و سپس به Message Loop برمیگردد و UI را آزاد میکند تا به Eventهای بعدی پاسخ دهد. با این حال، بسط Compiler برای await تضمین میکند که پیش از بازگشت یک Continuation تنظیم شود تا پس از Complete شدن Task، Execution از همان جای قبلی Resume شود.
چون روی UI Thread Await کردهایم، Continuation به Synchronization Context Post میشود و آن Context از طریق Message Loop اجرا میشود؛ بنابراین کل Method با نام Go بهصورت شبههمزمان روی UI Thread اجرا میماند. همزمانی واقعی از نوع I/O-bound داخل Implementation مربوط به DownloadDataTaskAsync رخ میدهد.
مقایسه با Coarse-grained Concurrency
پیش از C# 5، برنامهنویسی ناهمگام دشوار بود؛ نه فقط به دلیل نبود پشتیبانی زبانی، بلکه چون .NET Framework قابلیت ناهمگام را بهجای Methodهای Task-returning از طریق Patternهای دستوپاگیری به نام EAP و APM ارائه میکرد؛ «Obsolete Patterns» در صفحهٔ 689 را ببینید.
Workaround محبوب، Coarse-grained Concurrency بود؛ حتی Typeای به نام BackgroundWorker برای کمک به آن وجود داشت. با برگشتن به مثال همگام اولیهٔ GetPrimesCount، میتوانیم با تغییر Event Handler دکمه، ناهمگامی Coarse-grained را چنین نشان دهیم:
...
_button.Click += (sender, args) =>
{
_button.IsEnabled = false;
Task.Run (() => Go());
};
ما Task.Run را بهجای BackgroundWorker انتخاب کردهایم، چون دومی در سادهکردن مثال خاص ما کمکی نمیکرد. در هر دو حالت نتیجه این است که کل Call Graph همگام ما ــ Go بهاضافهٔ GetPrimesCount ــ روی Worker Thread اجرا میشود. و چون Go عناصر UI را Update میکند، باید کد را با Dispatcher.BeginInvoke پر کنیم:
void Go()
{
for (int i = 1; i < 5; i++)
{
int result = GetPrimesCount (i * 1000000, 1000000);
Dispatcher.BeginInvoke (new Action (() =>
_results.Text += result + " primes between " + (i*1000000) +
" and " + ((i+1)*1000000-1) + Environment.NewLine));
}
Dispatcher.BeginInvoke (new Action (() => _button.IsEnabled = true));
}
برخلاف نسخهٔ ناهمگام، خود Loop روی Worker Thread اجرا میشود. این شاید بیضرر به نظر برسد، اما حتی در همین حالت ساده، استفاده از Multithreading یک Race Condition وارد کرده است. آیا میتوانید آن را پیدا کنید؟ اگر نه برنامه را اجرا کنید؛ تقریباً حتماً آشکار میشود.
پیادهسازی Cancellation و Progress Reporting احتمال بیشتری برای Thread-safety Error ایجاد میکند و هر کد اضافی در Method نیز چنین است. مثلاً فرض کنید Upper Limit حلقه Hardcode نشده و از Method Call زیر میآید:
for (int i = 1; i < GetUpperBound(); i++)
اکنون فرض کنید GetUpperBound() مقدار را از Configuration Fileای میخواند که بهصورت Lazy Load و در نخستین Call از Disk بارگذاری میشود. همهٔ این کد حالا روی Worker Thread اجرا میشود؛ کدی که احتمالاً Thread-safe نیست. این خطرِ شروعکردن Worker Threadها در سطوح بالای Call Graph است.
نوشتن توابع ناهمگام
در هر تابع ناهمگام میتوانید Return Type از نوع void را با Task جایگزین کنید تا خود Method بهشکلی مفید ناهمگام و Awaitable شود. هیچ تغییر دیگری لازم نیست:
async Task PrintAnswerToLife() // We can return Task instead of void
{
await Task.Delay (5000);
int answer = 21 * 2;
Console.WriteLine (answer);
}
توجه کنید در Body متد صریحاً Task برنمیگردانیم. Compiler Task را میسازد و هنگام Complete شدن Method ــ یا بروز Exception مدیریتنشده ــ آن را Signal میدهد. این ساخت Call Chainهای ناهمگام را آسان میکند:
async Task Go()
{
await PrintAnswerToLife();
Console.WriteLine ("Done");
}
و چون Go را با Return Type از نوع Task اعلام کردهایم، خود Go نیز Awaitable است.
Compiler توابع ناهمگامی را که Task برمیگردانند به کدی بسط میدهد که با TaskCompletionSource یک Task میسازد و سپس آن را Signal یا Fault میکند.
اگر ظرافتها را کنار بگذاریم، میتوانیم PrintAnswerToLife را به معادل Functional زیر بسط دهیم:
Task PrintAnswerToLife()
{
var tcs = new TaskCompletionSource<object>();
var awaiter = Task.Delay (5000).GetAwaiter();
awaiter.OnCompleted (() =>
{
try
{
awaiter.GetResult(); // Re-throw any exceptions
int answer = 21 * 2;
Console.WriteLine (answer);
tcs.SetResult (null);
}
catch (Exception ex) { tcs.SetException (ex); }
});
return tcs.Task;
}
بنابراین هرگاه یک Method ناهمگام Task-returning تمام شود، بهواسطهٔ Continuation، Execution به چیزی برمیگردد که آن را Await کرده است.
برگرداندن Task<TResult>
اگر Method Body مقدار TResult برگرداند، میتوانید Task<TResult> برگردانید:
async Task<int> GetAnswerToLife()
{
await Task.Delay (5000);
int answer = 21 * 2;
return answer; // Method has return type Task<int> we return int
}
در داخل، این باعث میشود TaskCompletionSource بهجای null با یک Value Signal شود. میتوانیم GetAnswerToLife را با فراخوانی آن از PrintAnswerToLife ــ که خودش از Go فراخوانی میشود ــ نشان دهیم:
async Task Go()
{
await PrintAnswerToLife();
Console.WriteLine ("Done");
}
async Task PrintAnswerToLife()
{
int answer = await GetAnswerToLife();
Console.WriteLine (answer);
}
async Task<int> GetAnswerToLife()
{
await Task.Delay (5000);
int answer = 21 * 2;
return answer;
}
در عمل، PrintAnswerToLife اولیه را به دو Method Refactor کردهایم، با همان آسانیای که در برنامهنویسی همگام انجام میدادیم. شباهت به برنامهنویسی همگام عمدی است؛ معادل همگام Call Graph ما در زیر آمده و فراخوانی Go() پس از پنج ثانیه Blocking همان نتیجه را میدهد:
void Go()
{
PrintAnswerToLife();
Console.WriteLine ("Done");
}
void PrintAnswerToLife()
{
int answer = GetAnswerToLife();
Console.WriteLine (answer);
}
int GetAnswerToLife()
{
Thread.Sleep (5000);
int answer = 21 * 2;
return answer;
}
توانایی Compiler در ساخت Task برای توابع ناهمگام یعنی در بیشتر موارد فقط در حالت نسبتاً نادرِ Methodهای Bottom-level که Concurrency از نوع I/O-bound را آغاز میکنند لازم است خودتان صریحاً TaskCompletionSource بسازید. برای Methodهایی که Concurrency از نوع Compute-bound را آغاز میکنند، Task را با Task.Run میسازید.
اجرای Call Graph ناهمگام
برای دیدن دقیق نحوهٔ اجرا، مفید است کد را چنین بازآرایی کنیم:
async Task Go()
{
var task = PrintAnswerToLife();
await task; Console.WriteLine ("Done");
}
async Task PrintAnswerToLife()
{
var task = GetAnswerToLife();
int answer = await task; Console.WriteLine (answer);
}
async Task<int> GetAnswerToLife()
{
var task = Task.Delay (5000);
await task; int answer = 21 * 2; return answer;
}
Go، PrintAnswerToLife را فراخوانی میکند؛ آن نیز GetAnswerToLife را فراخوانی میکند؛ و این یکی Delay را Call میکند و سپس Await میکند. await باعث میشود Execution به PrintAnswerToLife برگردد؛ آن هم Await میکند و به Go برمیگردد؛ Go نیز Await کرده و به Caller برمیگردد. همهٔ اینها بهصورت همگام و روی Threadی که Go را فراخوانی کرده رخ میدهد؛ این Phase کوتاهِ همگام Execution است.
پنج ثانیه بعد Continuation مربوط به Delay Fire میشود و Execution روی یک Pooled Thread به GetAnswerToLife برمیگردد. اگر از UI Thread شروع کرده باشیم، اکنون Execution به همان Thread Bounce میکند. Statementهای باقیماندهٔ GetAnswerToLife اجرا میشوند و سپس Task<int> Method با Result برابر 42 Complete میشود و Continuation بعدی را اجرا میکند.
این Continuation در PrintAnswerToLife اجرا میشود و Statementهای باقیماندهٔ آن Method را اجرا میکند. این Process ادامه پیدا میکند تا Task مربوط به Go بهعنوان Complete Signal شود.
Execution Flow با Call Graph همگامی که پیشتر نشان دادیم منطبق است، چون Pattern ما این است که هر Method ناهمگام را بلافاصله پس از Call کردن Await میکنیم. این یک Flow ترتیبی میسازد که درون Call Graph هیچ Parallelism یا Execution همپوشانیشدهای ندارد. هر Expression از نوع await یک «Gap» در Execution ایجاد میکند و پس از آن Program از جایی که متوقف شده بود Resume میشود.
Parallelism
فراخوانی یک Method ناهمگام بدون Await کردن آن اجازه میدهد کد بعدی بهصورت Parallel اجرا شود. شاید در مثالهای قبلی متوجه شده باشید Event Handler دکمه Go را چنین فراخوانی میکرد:
_button.Click += (sender, args) => Go();
با اینکه Go یک Method ناهمگام است، آن را Await نکردیم و همین چیزی است که Concurrency لازم برای پاسخگو ماندن UI را فراهم میکند.
میتوانیم همین اصل را برای اجرای دو Operation ناهمگام بهصورت Parallel به کار ببریم:
var task1 = PrintAnswerToLife();
var task2 = PrintAnswerToLife();
await task1; await task2;
با Await کردن هر دو Operation در ادامه، در همان نقطه Parallelism را «تمام» میکنیم. بعداً توضیح میدهیم Task Combinator با نام WhenAll چگونه به این Pattern کمک میکند.
Concurrency ایجادشده به این روش چه Operationها روی UI Thread آغاز شوند چه نشوند رخ میدهد، اما نحوهٔ وقوع متفاوت است. در هر دو حالت همان Concurrency «واقعی» در Operationهای Bottom-level آغازکنندهٔ آن رخ میدهد، مانند Task.Delay یا کدی که به Task.Run سپرده شده است. Methodهای بالاتر در Call Stack فقط وقتی در معرض Concurrency واقعیاند که Operation بدون Synchronization Context آغاز شده باشد؛ در غیر این صورت همان شبههمزمانی و Thread Safety سادهشدهای را دارند که پیشتر گفتیم، جایی که تنها نقطهٔ Preemption یک Statement از نوع await است.
بهعنوان نمونه، این اجازه میدهد Field مشترکی به نام _x تعریف و آن را بدون Locking در GetAnswerToLife Increment کنیم:
async Task<int> GetAnswerToLife()
{
_x++;
await Task.Delay (5000);
return 21 * 2;
}
البته نمیتوانیم فرض کنیم _x پیش و پس از await همان Value را داشته باشد.
Lambda Expressionهای ناهمگام
همانطور که Methodهای Named معمولی میتوانند ناهمگام باشند:
async Task NamedMethod()
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
}
Methodهای بدون نام ــ Lambda Expressionها و Anonymous Methodها ــ نیز اگر کلیدواژهٔ async پیش از آنها بیاید میتوانند ناهمگام باشند:
Func<Task> unnamed = async () =>
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
};
میتوانیم هر دو را به یک شکل Call و Await کنیم:
await NamedMethod();
await unnamed();
هنگام Attach کردن Event Handlerها نیز میتوانیم از Lambda Expression ناهمگام استفاده کنیم:
myButton.Click += async (sender, args) =>
{
await Task.Delay (1000);
myButton.Content = "Done";
};
این از کد زیر که همان اثر را دارد کوتاهتر است:
myButton.Click += ButtonHandler;
...
async void ButtonHandler (object sender, EventArgs args)
{
await Task.Delay (1000);
myButton.Content = "Done";
};
Lambda Expressionهای ناهمگام همچنین میتوانند Task<TResult> برگردانند:
Func<Task<int>> unnamed = async () =>
{
await Task.Delay (1000);
return 123;
};
int answer = await unnamed();
Streamهای ناهمگام (Asynchronous Streams)
با yield return میتوانید Iterator بنویسید؛ با await میتوانید تابع ناهمگام بنویسید. Asynchronous Streamها ــ از C# 8 ــ این دو مفهوم را ترکیب میکنند و اجازه میدهند Iteratorای بنویسید که Await کند و Elementها را بهصورت ناهمگام Yield کند. این قابلیت بر یک جفت Interface بنا شده که همتای ناهمگام Interfaceهای Enumeration هستند؛ ادامهٔ تعریف آنها در مقالهٔ بعد میآید.