死鎖示例
如果你開發一個簡單的Windows Form程序,點擊Button去使用async
異步獲取一個數據,然后顯示在Label上,類似這樣的代碼
private void button1_Click(object sender, EventArgs e) { var task = GetContentAsync(); var content = task.Result; this.label1.Text = content; } public async Task<string> GetContentAsync() { var http = new HttpClient(); var result = await http.GetStringAsync("http://www.imzjy.com"); var first50 = result.Substring(0, 50); return first50; }
當你點擊Button的時候會發現程序直接卡死了。
死鎖原因分析
C#中的async/await
隱藏了很多的細節,一個簡單的await
其實讓函數發生了一次重入,重入對於多線程代碼來說其實很正常。但是C#將這些藏了起來。你看上去像一個函數,其實被分成了兩段,而且執行這兩段代碼的線程還可能不一樣。
上面代碼真正的執行過程是這樣的:
private void button1_Click(object sender, EventArgs e) { //1. calling GetContentAsync var task = GetContentAsync(); Debug.WriteLine($"Continuation:{Environment.CurrentManagedThreadId}"); //4. .Result(or GetAwait().GetResult()) which waiting for GetContentAsync to complete. //OOPS: DEADLOCK!!! //REASON: task.Result waiting the http.GetStringAsync complete and return; //REASON: GetContentAsync wait button1_Click release the synchronization context; var content = task.Result; this.label1.Text = content; } public async Task<string> GetContentAsync() { var http = new HttpClient(); //2. automatic capture synchronization context :: auto capture caused the issue. //3. due to await applied, yield thread to caller(button1_Click) var result = await http.GetStringAsync("http://www.imzjy.com"); //WHY AUTO CAPTURE? capture the synchronization context makes following accessing UI control became possible. //textBox1.Text = first50; var first50 = result.Substring(0, 50); return first50; }
對於GetContentAsync
函數來說,在await
之前其實是同步的代碼,當await
之后,線程直接返回給button1_Click
。await
時候發生了兩件事:
- 在返回之前偷偷做了個動作,那就是將當前線程的同步上下文(SychronizationContext)給捕獲了。
- 返回了一個未完成的任務,這里面抽象為Task
然后在第4步,當button1_Click
中去獲取上面這個Task返回值的時候出現了死鎖,button1_Click
和GetContentAsync
相互等待:
var content = task.Result;
button1_Click等待任務await http.GetStringAsync("http://www.imzjy.com");
完成- 而
await http.GetStringAsync("http://www.imzjy.com");
等待當前線程(UI線程)的同步上下文SychronizationContext
由於上面兩個方法相互等待,所以產生了死鎖。
為什么自動捕獲當前線程同步上下文
GetContentAsync
自動捕獲的是當前UI線程的同步上下文,通過偷偷的
捕獲當前UI線程的同步上下文可以讓你在GetContentAsync
方法中await
之后可以更新UI控件。如果你在GetContentAsync
中不需要更新UI控件,那么我們就不必捕獲同步上下文,那么也就不存在這個問題。
解決方案 1
修改GetContentAsync,讓http.GetStringAsync("http://www.imzjy.com”);
自動捕獲上下文時候捕獲不到。破壞了上面的死鎖條件2。
private void button1_Click(object sender, EventArgs e) { var task = GetContentAsync(); var content = task.Result; this.label1.Text = content; } public async Task<string> GetContentAsync() { var syncContext = WindowsFormsSynchronizationContext.Current; //save SynchronizationContext WindowsFormsSynchronizationContext.SetSynchronizationContext(null); //set SynchronizationContext to null var http = new HttpClient(); var result = await http.GetStringAsync("http://www.imzjy.com"); WindowsFormsSynchronizationContext.SetSynchronizationContext(syncContext); //restore the SynchronizationContext var first50 = result.Substring(0, 50); return first50; }
優點:
- 調用方代碼不需要改變
缺點:
- 調用者線程(UI線程)會在
var content = task.Result;
阻塞,直到GetContentAsync
返回,導致界面在此期間無響應。 - 如果異步方法類似
http.GetStringAsync("http://www.imzjy.com")
需要更新界面(使用UI線程)會出現問題 - 改的代碼比較多3行。
- WindowsFormsSynchronizationContext.SetSynchronizationContext(null);可能有副作用。
解決方案 2
修改調用方式,將調用放到Thread pool中,這樣await http.GetStringAsync("http://www.imzjy.com");
的auto capture就不會獲取到當前UI線程的SynchronizationContext
,破壞了上面的死鎖條件2。
private void button1_Click(object sender, EventArgs e) { //put the GetContentAsync into thread pool, so that //http.GetStringAsync("http://www.imzjy.com"); //will capture the SynchronizationContext from thread pool's excection environemnt var task = Task<string>.Run(GetContentAsync); var content = task.Result; this.label1.Text = content; } public async Task<string> GetContentAsync() { var http = new HttpClient(); var result = await http.GetStringAsync("http://www.imzjy.com"); var first50 = result.Substring(0, 50); return first50; }
優點:
async
方法不需要改變。
缺點:
- 調用者線程(UI線程)會在
var content = task.Result;
阻塞,直到GetContentAsync
返回,導致界面在此期間無響應。 - 如果異步方法類似
http.GetStringAsync("http://www.imzjy.com")
需要更新界面(使用UI線程)會出現問題
解決方案 3
通過ConfigureAwait來改變自動捕獲SynchronizationContext行為,破壞了上面的死鎖條件2。
private void button1_Click(object sender, EventArgs e)
{
var task = GetContentAsync();
var content = task.Result;
this.label1.Text = content;
}
public async Task<string> GetContentAsync()
{
var http = new HttpClient();
//tell await not to capture SynchronizationContext
var result = await http.GetStringAsync("http://www.imzjy.com”)
.ConfigureAwait(continueOnCapturedContext: false);
var first50 = result.Substring(0, 50);
return first50;
}
優點:
- 調用方(caller)不需要改變
- 避免了此處無用的自動捕獲線程上下文。
缺點:
- 調用者線程(UI線程)會在
var content = task.Result;
阻塞,直到GetContentAsync
返回,導致界面在此期間無響應。 - 如果異步方法類似
http.GetStringAsync("http://www.imzjy.com")
需要更新界面(使用UI線程)會出現問題
解決方案 4
把當前的事件處理函數也改成async的,這樣破壞了死鎖條件的1。button1_Click
不在死等,所以也釋放了上下文。
private async void button1_Click(object sender, EventArgs e) { var task = GetContentAsync(); var content = await task; this.label1.Text = content; } public async Task<string> GetContentAsync() { var http = new HttpClient(); var result = await http.GetStringAsync("http://www.imzjy.com"); var first50 = result.Substring(0, 50); textBox1.Text = first50; return first50; }
優點:
async
方法不需要改變。- 避免了UI無響應的問題。
GetContentAsync
在await
之后可以更新UI界面。
缺點:
button1_Click
改為了異步,對原來的方法有侵入性,甚至會改變整個調用鏈的行為,我最討厭這點了。
適用
上面的死鎖通常會發生在下面兩個地方
- Windows Forms的UI線程中調用了異步的方法。
- ASP.NET的User Request Context執行環境,比如Controller中的方法。 代碼細節
經驗
異步方法實現者
- 分開提供同步和異步方法
- 只是自己做一些事,不需要bind到調用線程上的需要盡量
.ConfigAwait(continueOnCapturedContext:false)
對於異步方法使用者
- 看看是否提供了同步方法
- 考慮是否有機會將自己的代碼轉為異步代碼
- 實在不行放到threadpool中去執行
async/await中的異常處理
如果加上異常處理,那么async/await
會變得更加復雜,因為異步方法在異步執行,所以可以放到不同的線程中,那么如果出現了異常會怎么樣?簡單來說:
- 異步代碼中的異常如果存在
Task
或Task<T>
被attach到了Task對象上 - 但是
async void
例外,由於沒有Task對象可以attach,所以attach到了SynchronizationContext
中活躍的線程中了。 - 異步方法調用(調用鏈)中的異常,會被Aggregate,然后生成一個
AggregateException
。你可以使用aggExp.Flatten()
方法來方便查看這個調用鏈中所有異常--如果有多個的話。
完整代碼示例
首發:https://www.imzjy.com/blog/dotnet-async-locks-and-solutions