小心C# 5.0 中的await and async模式造成的死鎖


平時在使用C# 5.0中的await and async關鍵字的時候總是沒注意,直到今天在調試一個ASP.NET項目時,發現在調用一個聲明為async的方法后,程序老是莫名其妙的被卡住,就算聲明為async的方法中的Task任務執行完畢后,外部方法的await調用還是阻塞着,后來查到了下面這篇文章,才恍然大悟原來await and async模式使用不當很容易造成程序死鎖,下面這篇文章通過一個Winform示例和一個Asp.net示例介紹了await and async模式是如何造成程序死鎖的,以及如何避免這種死鎖。

原文鏈接

 

await之前和之后的線程

首先在了解下面的內容前,我們必須要知道一件事,就是await關鍵字之前和之后的代碼有可能是在不同的線程上執行的:

//await之前的代碼可能在線程A上執行
string result = await GetStringAsync();
//待GetStringAsync()執行完畢后,await之后的代碼有可能是在線程A上執行,也有可能是在一個新的線程B上執行

至於await關鍵字之前和之后的代碼是否是在相同的線程上執行,這是由.NET根據當前狀態(默認情況下,是線程池當前的狀態)決定的。

關於這個問題,詳情可以查看這篇帖子

async/await different thread ID

Stephen Cleary的回答

還可以參考這個帖子:Does .NET resume an await continuation on a new different thread pool thread or reuse the thread from a previous resumption?

 

UI Example

Consider the example below. A button click will initiate a REST call and display the results in a text box (this sample is for Windows Forms, but the same principles apply to any UI application).

// My "library" method.
public static async Task<JObject> GetJsonAsync(Uri uri)
{
  using (var client = new HttpClient())
  {
    var jsonString = await client.GetStringAsync(uri);
    return JObject.Parse(jsonString);
  }
}

// My "top-level" method.
public void Button1_Click(...)
{
  var jsonTask = GetJsonAsync(...);
  textBox1.Text = jsonTask.Result;
}

The “GetJson” helper method takes care of making the actual REST call and parsing it as JSON. The button click handler waits for the helper method to complete and then displays its results.

This code will deadlock.

 

 

ASP.NET Example

This example is very similar; we have a library method that performs a REST call, only this time it’s used in an ASP.NET context (Web API in this case, but the same principles apply to any ASP.NET application):

// My "library" method.
public static async Task<JObject> GetJsonAsync(Uri uri)
{
  using (var client = new HttpClient())
  {
    var jsonString = await client.GetStringAsync(uri);
    return JObject.Parse(jsonString);
  }
}

// My "top-level" method.
public class MyController : ApiController
{
  public string Get()
  {
    var jsonTask = GetJsonAsync(...);
    return jsonTask.Result.ToString();
  }
}

This code will also deadlock. For the same reason.

 

 

 

What Causes the Deadlock

Here’s the situation: remember from my intro post that after you await a Task, when the method continues it will continue in a context.

現在情況就是:就如我以前的文章所說,在你await一個Task對象后,當方法要繼續執行await關鍵字之后的代碼時,線程需要在一個context中方能繼續執行。

In the first case, this context is a UI context (which applies to any UI except Console applications). In the second case, this context is an ASP.NET request context.

在第一個例子中,這個context是一個UI context(即.NET中諸如Winform、WPF等任何UI項目,除了控制台項目)。在第二個例子中,這個context是一個ASP.NET中的request context。

One other important point: an ASP.NET request context is not tied to a specific thread (like the UI context is), but it does only allow one thread in at a time. This interesting aspect is not officially documented anywhere AFAIK, but it is mentioned in my MSDN article about SynchronizationContext.

另外一個很重要的知識點:一個ASP.NET request context並不會和一個特有的線程綁定(前面說的UI context也是這樣),但是一個context在任意時刻都只允許一個線程進入。這個有趣的知識點並沒有在AFAIK上的任何文檔中被記錄,但是它在MSDN上我介紹SynchronizationContext的文章中被提到了

 

So this is what happens, starting with the top-level method (Button1_Click for UI / MyController.Get for ASP.NET):

所以這就是發生的事情,從top-level method開始(對於上面UI的例子是Button1_Click方法,對於ASP.NET的例子是MyController的Get方法)

  1. The top-level method calls GetJsonAsync (within the UI/ASP.NET context). top-level method調用GetJsonAsync方法(在UI/ASP.NET context中)
  2. GetJsonAsync starts the REST request by calling HttpClient.GetStringAsync (still within the context). GetJsonAsync方法通過調用HttpClient.GetStringAsync方法(這仍然在context中),開始發起REST請求
  3. GetStringAsync returns an uncompleted Task, indicating the REST request is not complete. GetStringAsync方法返回了一個未完成的Task對象,它表示REST請求還沒有完成
  4. GetJsonAsync awaits the Task returned by GetStringAsync. The context is captured and will be used to continue running the GetJsonAsync method later. GetJsonAsync returns an uncompleted Task, indicating that the GetJsonAsync method is not complete. 在GetJsonAsync方法中,代碼await了GetStringAsync方法返回的Task對象。context現在被捕獲(注意現在這個context是被top-level method的線程占有的,當GetStringAsync方法執行完畢返回后,繼續執行GetJsonAsync方法的線程,需要獲得該context來繼續執行await關鍵字之后的代碼,這也是造成本例中代碼會死鎖的原因),之后會用來繼續執行GetJsonAsync方法中await關鍵字之后的代碼。GetJsonAsync方法返回一個未完成的Task對象,表示現在GetJsonAsync方法還沒有執行完畢。
  5. The top-level method synchronously blocks on the Task returned by GetJsonAsync. This blocks the context thread. 在top-level method中代碼被GetJsonAsync方法返回的Task對象同步地阻塞了(因為代碼訪問了jsonTask.Result屬性,而訪問這個屬性現在會被阻塞,因為GetJsonAsync方法還未執行完),這會導致context線程(也就是執行top-level method的線程)被阻塞。
  6. … Eventually, the REST request will complete. This completes the Task that was returned by GetStringAsync. ...最終,REST請求完成了,所以這時GetStringAsync方法返回的Task對象也就完成了。
  7. The continuation for GetJsonAsync is now ready to run, and it waits for the context to be available so it can execute in the context. 現在GetJsonAsync方法中await關鍵字之后的代碼准備運行了, 這需要等待前面說的context空閑時,線程(可能是一個新的線程)才能進入該context來繼續運行await關鍵字之后的代碼。
  8. Deadlock. The top-level method is blocking the context thread, waiting for GetJsonAsync to complete, and GetJsonAsync is waiting for the context to be free so it can complete. 死鎖發生了。top-level method中現在占有context的線程正在被阻塞來等待GetJsonAsync方法執行完成,這時top-level method的線程會一直占有context,然而GetJsonAsync方法也在等待context被釋放才能執行完成。也就是說top-level method中的線程在等待GetJsonAsync方法執行完成,所以被阻塞,GetJsonAsync方法又在等待top-level method中的線程釋放context也被阻塞,兩個阻塞相互等待,相互死鎖。

For the UI example, the “context” is the UI context; for the ASP.NET example, the “context” is the ASP.NET request context. This type of deadlock can be caused for either “context”.

對於前面的UI例子,我們說的"context"就是UI context;對於前面的ASP.NET例子,我們說的"context"就是ASP.NET request context。這兩種"context"中都會發生上面所述的死鎖。

 

Preventing the Deadlock

There are three best practices (both covered in my intro post) that avoid this situation:

這里有三個最佳實踐來避免死鎖,其中前面兩個在我的文章中也介紹過:

  1. In your “library” async methods, use ConfigureAwait(false) wherever possible. 在你的"library"異步方法中,返回未完成Task時都調用ConfigureAwait(false)。
  2. Don’t block on Tasks; use async all the way down. 不要讓線程在任何Task對象上被阻塞,總是使用async關鍵字,不要混合阻塞式代碼和異步代碼。
  3. 如果想結束async & await模式的調用,啟動一個新的線程去await異步方法的返回結果。

第一種方法,設置ConfigureAwait(false)后,會導致await關鍵字之后的代碼在一個新的線程上運行,如果是在Winform程序中,await關鍵字之后的代碼設置了控件的屬性,會產生Winform程序的線程安全異常,所以方法一不適用於.NET中的UI項目(諸如Winform、WPF等項目),同理第三種方法也不適合.NET中的UI項目。

其實最好的方法應該是第二種,將await and async模式在調用方法中貫徹到底,由.NET自己來管理持有"context"的線程,就不會出現本文所述的死鎖情況,此外一直保持await and async模式還有個好處,所有await and async模式中的線程都是由.NET來自動創建和銷毀的,這樣可以保證線程池中的線程得到最大的重用,避免了由於人為阻塞線程(注意當用await關鍵字等待一個未完成的Task對象時,執行await代碼的線程實際上會立即返回,正如本文開始時所述,待await的Task對象完成后可能由新的線程來繼續執行await關鍵字之后的代碼,但是這個過程中並沒有線程被阻塞,所以所有的線程都可以去做它們該做的事情,並不會進行無謂的等待),導致線程池需要創建新的線程,從而產生額外的性能開銷。

那么我們來想想為什么第二種方法可以適用於.NET中的UI項目(諸如Winform、WPF等項目),.NET之所以讓本文所述的"context"只允許被一個線程占有,是為了保證await關鍵字后面的代碼是被正確的線程執行的,就拿Winform項目來舉例,設想下如果await關鍵字后面的代碼設置了控件的屬性,而該代碼是在一個非UI線程(非主線程)來執行的,就會產生線程安全異常(正如上面第一種方法所述)。現在有了"context"這個機制后,由於"context"被UI線程(主線程)一直持有,所以只有UI線程(主線程)才能執行await關鍵字后面的代碼,這樣就避免了.NET中UI項目的線程安全異常問題,使得只有UI線程(主線程)才能執行await關鍵字后面的代碼,但是由於本例中的UI線程(主線程)被阻塞了,所以才導致await關鍵字后面的代碼無法被執行進入了死鎖,所以我們在任何時刻都不應該去阻塞UI線程(主線程),這也就是await and async模式的作用,await關鍵字並不會去真正阻塞UI線程(主線程),它會讓UI線程(主線程)返回去做其它事情,待await關鍵字等待的Task對象執行完畢后,在合適的時候(這里所說的"合適的時候"是.NET自己判斷的,我們不用去管).NET會讓UI線程(主線程)回來繼續執行await關鍵字后面的代碼,這樣即便await關鍵字后面的代碼設置了控件的屬性,也是由UI線程(主線程)來設置的,所以就避免了UI項目的線程安全異常問題。

 

Consider the first best practice. The new “library” method looks like this:

public static async Task<JObject> GetJsonAsync(Uri uri)
{
  using (var client = new HttpClient())
  {
    var jsonString = await client.GetStringAsync(uri).ConfigureAwait(false);
    return JObject.Parse(jsonString);
  }
}

This changes the continuation behavior of GetJsonAsync so that it does not resume on the context. Instead, GetJsonAsync will resume on a thread pool thread. This enables GetJsonAsync to complete the Task it returned without having to re-enter the context. The top-level methods, meanwhile, do require the context, so they cannot use ConfigureAwait(false).

Using ConfigureAwait(false) to avoid deadlocks is a dangerous practice. You would have to use ConfigureAwait(false) for every await in the transitive closure of all methods called by the blocking code, including all third- and second-party code. Using ConfigureAwait(false) to avoid deadlock is at best just a hack).
As the title of this post points out, the better solution is “Don’t block on async code”.

Consider the second best practice. The new “top-level” methods look like this:

public async void Button1_Click(...)
{
  var json = await GetJsonAsync(...);
  textBox1.Text = json;
}

public class MyController : ApiController
{
  public async Task<string> Get()
  {
    var json = await GetJsonAsync(...);
    return json.ToString();
  }
}

This changes the blocking behavior of the top-level methods so that the context is never actually blocked; all “waits” are “asynchronous waits”.

Note: It is best to apply both best practices. Either one will prevent the deadlock, but both must be applied to achieve maximum performance and responsiveness.

The third best practice:如果想結束async & await模式的調用,啟動一個新的線程去await異步方法的返回結果:

// My "library" method.
public static async Task<JObject> GetJsonAsync(Uri uri)
{
    using (var client = new HttpClient())
    {
        var jsonString = await client.GetStringAsync(uri);
        return JObject.Parse(jsonString);
    }
}

// My "top-level" method.
public string Get()
{
    JObject jObject = null;

    Task.Run(async () =>
    {
        jObject = await GetJsonAsync(...);
        //await之后的代碼
    }).Wait();//此處啟動線程是為了防止Async & Await模式造成死鎖

    return jObject.ToString();
}

這樣因為GetJsonAsync方法是由Task.Run新啟動的線程來調用的,而Task.Run新啟動的線程是線程池線程,該線程沒有SynchronizationContext,所以在await GetJsonAsync(...)執行完畢之后,一個線程(有可能就是執行await關鍵字之前的線程,也有可能是一個新的線程,如本文開始時所述)不需要獲得"context"就可以繼續執行await之后的代碼,不會和top-level method的線程阻塞,造成死鎖。

 

最后再補充說一點,本文提到的await and async死鎖問題,在.NET控制台項目和ASP.NET Core項目(因為微軟在ASP.NET Core中移除了SynchronizationContext,詳情可以查看這里)中並不存在。因為經過實驗發現在.NET控制台項目和ASP.NET Core項目中,await關鍵字這一行后面的代碼不需要線程重新進入"context"就可以執行,也就是說在.NET控制台項目和ASP.NET Core項目中就算不調用Task.ConfigureAwait(false),await關鍵字這一行后面的代碼也會由一個線程池線程來成功執行,不會和主線程發生死鎖。但是在Winform和老的ASP.NET(指.NET Framework中的ASP.NET)中就會發生死鎖。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM