14.5.2 編寫異步函數
private static readonly Stopwatch Watch = new Stopwatch();
static void Main(string[] args)
{
Go();
Console.Read();
}
private static async Task Go()
{
await PrintAnswerToLife();
Console.WriteLine("Done");
}
private static async Task PrintAnswerToLife() // We can return Task instead of void
{
await Task.Delay(5000);
int answer = 21 * 2;
Console.WriteLine(answer);
}
編譯器會擴展異步函數,它會將任務返回給使用TaskCompletionSource
的代碼,用于創建任務,然后再發送信號或異常終止。
除了這些細微區別,可以將PrintAnswerToLife
擴展為下面的等價功能:
private static Task PrintAnswerToLife()
{
var tcs = new TaskCompletionSource<object>();
var awaiter = Task.Delay(5000).GetAwaiter();
awaiter.OnCompleted(() =>
{
try
{
awaiter.GetResult();
int answer = 21 * 2;
Console.WriteLine(answer + " 耗時:" + Watch.ElapsedMilliseconds + "ms");
tcs.SetResult(null);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
return tcs.Task;
}
因此,當一個返回任務的異步方法結束時,執行過程會返回等待它的程序(通過一個延續)。
1.返回 Task<TResult>
async Task<int> GetAnswerToLife()
{
await Task.Delay (5000);
int answer = 21 * 2;
return answer; //返回類型是Task<int> 所以返回int
}
在內部,這段代碼向TaskCompletionSource發送一個值,而非null。
void Main()
{
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;
}
編譯能夠為異步函數創建任務,意味我們只需在 I/O 綁定代碼底層方法中顯式創建一個'TaskCompletionSource'實例。(CPU 綁定代碼可以使用 Task.Run
創建任務)
2.異步調用圖的執行
為了確切理解執行過程,最好將代碼重新排列:
static async Task Go()
{
var task = PrintAnswerToLife();
await task;
Console.WriteLine("Done");
}
static async Task PrintAnswerToLife()
{
var task = GetAnswerToLife();
int answer = await task;
Console.WriteLine(answer);
}
static async Task<int> GetAnswerToLife()
{
var task = Task.Delay(5000);
await task;
int answer = 21 * 2;
return answer;
}
await 會使執行過程返回它所等待的PrintAnswerToLife,然后再返回Go,它同樣會等待并返回調用者。所有這些方法調用都在調用Go的線程上以同步方式執行;這是執行過程的主要同步階段。
整個執行流程在每一個異步調用后都會等待。這樣就可以在調用圖中形成一個無并發或重疊的串行流。每一個await表達式都會執行中創建一個“缺口”,之后程序都可以在原處恢復執行。
3.并行性
調用一個異步方法,但是等待它,就可以使代碼并行執行。前面例子,有一個按鈕添加一個像下面這樣的事件處理器Go:
_buttion.Click += (sender, args) => Go();
盡管Go是一個異步方法,但是我們并沒有等待它,事實上它正是利用并發性來實現快速響應UI:
我們可以使用相同的法則以并行方式執行兩個異步操作:
var task1 = PrintAnswerToLife();
var task2 = PrintAnswerToLife();
await task1;
await task2;
以這種方式創建的并發性可以支持UI線程或非UI線程上執行的操作,但是它們實現方式有所區別。這兩種情況都可以在底層操作上(如Task.Delay
或Task.Run
生成的代碼)實現真正的并發性。
在調用堆中,只有操作不通過同步上下文創建,在這之上的方法才可以實現真正的并發性;否則,它們就是前面介紹的偽并發性和簡化的線程安全性,其中我們唯一能夠優先使用的是await
語句。
例如,它允許我們定義一個共享域_x,然后不需要使用鎖就可以在增加它的值:
private static async Task PrintAnswerToLife()
{
_x++;
await Task.Delay(5000);
return 21 * 2;
}
(但是,這里不能假定_x在await前后均保持相同的值。)
14.5.3 異步Lambda表達式
就像普通的命名(named)方法可以采用異步方式執行一樣:
async Task NamedMethod()
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
}
只要添加async關鍵字,未命名(unnamed)方法也可以采用異步:
async void Main()
{
Func<Task> unnamed = async () =>
{
await Task.Delay (1000);
Console.WriteLine ("Foo1");
};
// We can call the two in the same way:
await NamedMethod();
await unnamed();
}
異步lambda表達式可用于附加事件處理器:
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表達式也可以返回Task<Result>
:
Func<Task<int>> unnamed = async () =>
{
await Task.Delay (1000);
return 123;
};
int answer = await unnamed();
14.5.4 WinRT異步方法
WinRT中,
Task
等價IAsyncAction
,
Task<TResult>
等價IAsyncOperation<TResult>
。
兩個類都通過System.Runtime.WindowsRuntime.dll
程序集的AsTask
擴展方法轉換為Task
或Task<TResult>
。這個程序集也定義了一個GetAwaiter
方法,它可以操作IAsyncAction
和IAsyncOperation<TResult>
,他們可以直接執行等待操作。
Task<StorageFile> file =
KnowFolders.DocumentsLibrary.CreateFileAsync("test.txt").AsTask();
或者:
StorageFile file =
await KnowFolders.DocumentsLibrary.CreateFileAsync("test.txt");
14.5.5 異步與同步上下文
1.異常提交
2.OpertionStarted 和 OperationCompleted
14.5.6 優化
1.同步完成
異步方法可能會在等待之前返回,假設有下面這樣方法,它會緩存下載的網頁:
static Dictionary<string,string> _cache = new Dictionary<string,string>();
async Task<string> GetWebPageAsync (string uri)
{
string html;
if (_cache.TryGetValue (uri, out html)) return html;
return _cache [uri] = await new WebClient().DownloadStringTaskAsync (uri);
}
假設某個URI已經存在于緩存之中,那么執行過程會在等待發生之前返回調用者,同時這個方法會返回一個已發送信號的任務,這稱為同步完成。
如果等待一個同步完成任務,那么執行過程不會返回調用者并通過一個延續彈回——相反,它會馬上進入下一條語句。編譯器會通過檢查等待著的IsCompleted
屬性來實現這種優化;換言之,無論何時執行等待:
Console.WriteLine(await GetWebPageAsync ("http://oreilly.com"));
在同步完成時,編譯器會生成中止延續的代碼:
var awaiter = GetWebPageAsync().GetAwaiter();
if (awaiter.IsCompleted)
Console.WriteLine(awaiter.GetResult());
else
awaiter.OnCompleted(()=>Console.WriteLine(awaiter.GetResult()));
編寫從不等待的異步方法是允許的,但是編譯器會發出警告:
async Task<string> Foo() {return "abc";}
在重寫虛方法/抽象方法時,如果不需要實現異步處理,那么很適合使用這種方法。
實現相同結果的另一種方法是使用Task.FromResult
,它會返回一個已發送信號的任務。
Task<string> Too()
{
return Task.FromResult("abc");
}
如果從UI線程調用,GetWebPageAsync方法本身就具有線程安全性,在成功執行后多次調用這個方法(初始化多個并發下載),而且不用鎖來保證緩存。
但是,多次處理同個URI,會生成多個冗余下載,最終更新同一個緩存記錄(最后個覆蓋前面)。如果沒有錯,那更高效的方式是讓同一個URI的后續調用(異步)等待正在處理的請求。
還有一個簡單方法(不需要鎖或信號結構):
創建一個“未來”緩存(
Task<string>
),代替字符串緩存:
static Dictionary<string,Task<string>> _cache =
new Dictionary<string,Task<string>>();
Task<string> GetWebPageAsync (string uri)
{
Task<string> downloadTask;
if (_cache.TryGetValue (uri, out downloadTask)) return downloadTask;
return _cache [uri] = new WebClient().DownloadStringTaskAsync (uri);
}
這里沒有使用await,直接返回獲得的任務。
如果重復調用GetWebPageAsync處理同一個URI,可以保證能獲得同一個Task<string>
對象。(這樣做另一個好處,降低GC負載)
2.避免過度回彈
ConfigureAwait的作用:使當前
async
方法的await
后續操作不需要恢復到主線程(不需要保存線程上下文)。
對于循環中多次調用的方法,通過調用ConfigureAwait,可以避免重復回彈UI消息循環帶來的開銷。
void Main()
{
A();
}
async void A()
{
await B();
}
async Task B()
{
for (int i = 0; i < 1000; i++)
await C().ConfigureAwait (false);
}
async Task C() { /*...*/ }
B方法和C方法撤銷UI使用的簡單線程安全模式,代碼運行在UI線程上,而只能在await語句中優先占用。然而,A方法不受影響,它在啟動之后就一直停留在UI線程。
14.6 異步模式
14.6.1 取消
通常要能夠在并發操作啟動后,取消這個操作(用戶請求)。實現這個操作的簡單方式是使用取消令牌,編寫一個封裝類:
class CancellationToken
{
public bool IsCancellationRequested { get; private set; }
public void Cancel() { IsCancellationRequested = true; }
public void ThrowIfCancellationRequested()
{
if (IsCancellationRequested) throw new OperationCanceledException();
}
}
當調用者想取消操作時,它會調用傳遞給Foo的取消令牌上的Cancel
。因此出現OperationCanceledException
異常。
例:
async void Main()
{
var token = new CancellationToken();
Task.Delay (5000).ContinueWith (ant => token.Cancel()); // Tell it to cancel in two seconds.
await Foo (token);
}
// This is a simplified version of the CancellationToken type in System.Threading:
class CancellationToken
{
public bool IsCancellationRequested { get; private set; }
public void Cancel() { IsCancellationRequested = true; }
public void ThrowIfCancellationRequested()
{
if (IsCancellationRequested) throw new OperationCanceledException();
}
}
async Task Foo (CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine (i);
await Task.Delay (1000);
cancellationToken.ThrowIfCancellationRequested();
}
}
CLR提供一個CancellationToken
類型,然而它沒有Cancel()
方法;
但是這個方法提供另一個類型CancellationTokenSource
。這種分離具有一定安全性:只能訪問CancellationToken
對象的方法可以檢查取消操作,但不能初始化取消操作。
CancellationTokenSource有一個Token
屬性,可以返回一個CancellationToken
。
var cancelSource = new CancellationTokenSource();
Task.Delay(5000).ContinueWith(ant => cancelSource.Cancel());
await Foo (cancelSource.Token);
在CLR中,大多數異步方法提供了取消令牌,包括Delay。
public static Task Delay(int millisecondsDelay, CancellationToken cancellationToken);
我們不需要再調用
ThrowIfCancellationRequested
,因為Task.Delay已經包含這個操作。
同步方法也支持取消操作(如Task.Wait
方法)。這種情況,取消指令必須以異步方式執行(例如,在另一個任務中執行)。
例如:
var cancelSource = new CancellationTokenSource(5000);
Task.Delay(5000).ContinueWith(ant => cancelSource.Cancel());
...
Framework 4.5開始,創建CancellationTokenSource
可以指定一個時間間隔,表示一定時間段后初始化取消操作。
無論同步或者異步,最好指定一個超時時間:
var cancelSource = new CancellationTokenSource (5000);
try
{
await Foo (cancelSource.Token);
}
catch (OperationCanceledException ex)
{
Console.WriteLine ("Cancelled");
}
CancellationToken
結構提供一個Register
方法,可以用于注冊一個回調代理,然后在取消操作發生時觸發,它會返回一個對象,用于撤銷注冊。
IsCanceled
返回true
,IsFaulted
返回false
。出現OperationCanceledException
異常,任務進入“已取消”狀態。
14.6.2 進度報告
有時,異步操作需要在運行時報告進度。有一種簡單的解決方法是給異步傳入一個 Action
代理,然后進度發生變化時就會觸發這個方法:
async void Main()
{
Action<int> progress = i => Console.WriteLine (i + " %");
await Foo (progress);
}
Task Foo (Action<int> onProgressPercentChanged)
{
return Task.Run (() =>
{
for (int i = 0; i < 1000; i++)
{
if (i % 10 == 0) onProgressPercentChanged (i / 10);
// 執行CPU綁定代碼.
}
});
}
這段代碼運行在控制臺應用程序上,但是它不適合運行在富客戶端場景,因為它可以從工作者線程報告進度,這可能會給使用者線程帶來線程安全問題。
IProgress<T>
和Progress<T>
它們的作用是“包裝”一個代理,這樣UI應用程序就可以通過同步上下文安全地報告進度。
這個接口只定義一個方法:
public interface IProgress<in T>
{
// 參數:
// value:
// 進度更新之后的值。
void Report(T value);
}
Iprogress<T>
用法很簡單:
Task Foo (IProgress<int> onProgressPercentChanged)
{
return Task.Run (() =>
{
for (int i = 0; i < 1000; i++)
{
if (i % 10 == 0) onProgressPercentChanged.Report (i / 10);
// 執行CPU綁定代碼.
}
});
}
Progress<T>
類有一個構造方法,它可以接受Action<T>
類型包裝的代理,
var progress = new Progress<int>(i => Console.WriteLine (i + " %"));
await Foo (progress);
(Progress<T>
還有一個ProgressChanged事件,我們可以訂閱這個事件,同時不要給構造函數傳入一個操作代理)
在實例化Progress<int>
時,這個類會波桌同步上下文(如果有)。然后Foo
調用Report
時,它會通過上下文調用代理對象。
將替換為包含一系列屬性的自定義類型,就可以在異步方法中實現更復雜的進度報告。
由
IProgress<T>
生成的值一般是“廢棄值”(例如,完成比或已下載字節),而由IObserver<T>
的MoveNext
生成的值通常由結果組成,這個正式調用它的初衷。
14.6.3 基于任務的異步模式(TAP)
一個TAP方法必須:
- 返回一個“熱”(正在運行)
Task
或Task<TReuslt>
- 擁有“Async”后綴
- 如果支持取消或進度報告,重載可接收取消令牌或
IProgress<T>
- 快速返回調用者
- 在I/O 綁定代碼中不占用線程。
14.6.4 任務組合器
CLR包含兩個任務組合器:Task.WhenAny
和Task.WhenAll
。
我們假定以下方法:
async Task<int> Delay1() { await Task.Delay (1000); return 1; }
async Task<int> Delay2() { await Task.Delay (2000); return 2; }
async Task<int> Delay3() { await Task.Delay (3000); return 3; }
1.WhenAny
當任務組中任意一個任務完成,它就完成。下面任務會1秒內完成:
async void Main()
{
Task<int> winningTask = await Task.WhenAny (Delay3(), Delay1(), Delay2());
Console.WriteLine ("Done");
Console.WriteLine (winningTask.Result); // 1
}
因為Task.WhenAny
本身會返回一個任務,所以我們要等待它,然后它會返回先完成的任務。這個例子完全不會阻塞——包括訪問Result
屬性的最后一行語句(因為winningTask已經完成)。但是,最好還是要等待任務(winningTask):
Console.WriteLine (await winningTask); // 1
因為這時任何異常都會重新拋出,而不需要包裝一個AggregateException
異常中。事實上,我們可以進一步操作中同時執行兩個await
:
int answer = await await Task.WhenAny (Delay1(), Delay2(), Delay3());
如果后面沒有一個未完成任務出現錯誤,那么除非后面等待了這個任務,否則該異常將不會被捕捉到。
WhenAny適合用于應用操作超時時間或取消操作:
async void Main()
{
Task<string> task = SomeAsyncFunc(); //返回task
Task winner = await (Task.WhenAny (task, Task.Delay(5000))); //返回Task.Delay(5000)
if (winner != task) throw new TimeoutException();
string result = await task; // 解開結果/重新拋出異常
}
async Task<string> SomeAsyncFunc()
{
await Task.Delay (10000);
return "foo";
}
注意這個例子不同類型的任務去調用
WhenAny
,所以完成的任務報告為一個普通Task
(而非Task<string>
)
2.WhenAll
當傳入的所有任務完成時,它才完成。下面的任務會在3秒之后完成(同時演示了分叉/聯合模式)
await Task.WhenAll(Delay1(), Delay2(), Delay3());
不使用WnenAll
,而依次等待task1,task2和task3,也可以得到相似的結果:
Task task1 = Delay1(),task2 = Delay2(),task3 = Delay3();
await task1;await task2;await task3;
這種方式,除了三次等待效率低于一次等待外,區別:如果task1
出錯,不執行task2/task3。而且異常無法處理。
相反,Task.WhenAll
只有在所有任務完成后才會完成——即使中間出現錯誤。如果出現多個錯誤,它們的異常會組合到任務的AggregateException
之中。
然而,等待組合的任務只能捕捉到第一個異常,所以如果要查看所有異常,則必須這樣做:
Task task1 = Task.Run (() => { throw null; } );
Task task2 = Task.Run (() => { throw null; } );
Task all = Task.WhenAll (task1, task2);
try { await all; }
catch
{
Console.WriteLine (all.Exception.InnerExceptions.Count); // 2
}
結果輸出為:2
使用類型為Task<TResult>
的任務調用WhenAll,會返回一個Task<TResult[]>
,這是所有任務的結果組合。如果執行等待操作時,那么這個結果會變成TResult[]
:
Task<int> task1 = Task.Run (() => 1);
Task<int> task2 = Task.Run (() => 2);
int[] results = await Task.WhenAll (task1, task2); // { 1, 2 }
下面一個例子,并行下載多個URI,然后計算它們的總下載大小:
async void Main()
{
int totalSize = await GetTotalSize ("http://www.qq.com http://www.weibo.com http://www.163.com".Split());
totalSize.Dump();
}
async Task<int> GetTotalSize (string[] uris)
{
IEnumerable<Task<byte[]>> downloadTasks = uris.Select (uri =>
new WebClient().DownloadDataTaskAsync (uri));
byte[][] contents = await Task.WhenAll (downloadTasks);
return contents.Sum (c => c.Length);
}
字段代碼效率不行,我們只能在每一個任務都完成之后才能處理字節數組。如果在下載之后馬上將字節數組壓縮為實際長度,那么效率會提高。這正式異步lambda發揮作用地方,因為我們在LINQ的Select查詢操作符插入一個await表達式:
async Task<int> GetTotalSize (string[] uris)
{
IEnumerable<Task<int>> downloadTasks = uris.Select (async uri =>
(await new WebClient().DownloadDataTaskAsync (uri)).Length); //await .... Length
int[] contentLengths = await Task.WhenAll (downloadTasks);
return contentLengths.Sum();
}
3.自定義組合器
編寫自定義的任務組合很實用。最簡單的組合器可以接受一個任務,下面例子允許在特定超時時間里等待任意任務:
async void Main()
{
string result = await SomeAsyncFunc().WithTimeout (TimeSpan.FromSeconds (2));
result.Dump();
}
async Task<string> SomeAsyncFunc()
{
await Task.Delay (10000);
return "foo";
}
//Task<TResult> 擴展方法
public static class Extensions
{
public async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task, TimeSpan timeout)
{
Task winner = await (Task.WhenAny (task, Task.Delay (timeout)));
if (winner != task) throw new TimeoutException();
return await task; // 解開結果/重新拋出異常
}
}
下面代碼通過一個CancellationToken
“拋棄”一個任務:
public static class Extensions
{
public static Task<TResult> WithCancellation<TResult> (this Task<TResult> task, CancellationToken cancelToken)
{
var tcs = new TaskCompletionSource<TResult>();
var reg = cancelToken.Register (() => tcs.TrySetCanceled ());
task.ContinueWith (ant =>
{
reg.Dispose();
if (ant.IsCanceled)
tcs.TrySetCanceled();
else if (ant.IsFaulted)
tcs.TrySetException (ant.Exception.InnerException);
else
tcs.TrySetResult (ant.Result);
});
return tcs.Task;
}
}
任務組合器有時候可能很復雜,需要22章介紹的各種信號結構。
下面的組合器作用與WhenAll類似,唯一不同的是如果任意任務出現錯誤,那么最終任務也會馬上出錯:
async void Main()
{
Task<int> task2 = Task.Delay (5000).ContinueWith (ant => {return 53;});
Task<int> task1 = Task.Run (() => {throw null; return 42; } ); //--->未將對象引用為實例
int[] results = await WhenAllOrError (task1, task2);
}
async Task<TResult[]> WhenAllOrError<TResult> (params Task<TResult>[] tasks)
{
var killJoy = new TaskCompletionSource<TResult[]>();
foreach (var task in tasks)
task.ContinueWith (ant =>
{
if (ant.IsCanceled)
killJoy.TrySetCanceled(); //嘗試將底層Task <TResult>轉換為已取消狀態。
else if (ant.IsFaulted)
killJoy.TrySetException (ant.Exception.InnerException);
});
return await await Task.WhenAny (killJoy.Task, Task.WhenAll (tasks));
}
這里先創建一個TaskCompletionSource,它的唯一作用的終止出錯的任務(此例)。因此,這里不會調用它的SetResult
方法,只會調用它的TrySetCanceled
和TrySetException
方法。
這個例子更適合ContinueWith
,而不是GetAwaiter().OnCompleted,因為我們不需要訪問任務的結果,也不需要在此彈回UI線程。