C#異步的世界(下)

前言

今天說異步的主要是指C#5的async\await異步。在此為瞭方便的表述,我們稱async\await之前的異步為“舊異步”,async\await為“新異步”。

新異步的使用

隻能說新異步的使用太簡單(如果僅僅隻是說使用)

方法加上async修飾符,然後使用await關鍵字執行異步方法,即可。對就是如此簡單。像使用同步方法邏輯一樣使用異步。

public async Task<int> Test()
 {
     var num1 = await GetNumber(1);
     var num2 = await GetNumber(num1);
     var task =  GetNumber(num2);
     //或者
     var num3 = await task;
     return num1 + num2 + num3;
 }

新異步的優勢

在此之前已經有瞭多種異步模式,為什麼還要引入和學習新的async\await異步呢?當然它肯定是有其獨特的優勢。

我們分兩個方面來分析:WinForm、WPF等單線程UI程序和Web後臺服務程序。

對於WinForm、WPF等單線程UI程序

代碼1(舊異步)

private void button1_Click(object sender, EventArgs e)
{
    var request = WebRequest.Create("https://github.com/");
    request.BeginGetResponse(new AsyncCallback(t =>
    {
        //(1)處理請求結果的邏輯必須寫這裡
        label1.Invoke((Action)(() => { label1.Text = "[舊異步]執行完畢!"; }));//(2)這裡跨線程訪問UI需要做處理      
    }), null);
}

代碼2(同步)

private void button3_Click(object sender, EventArgs e)
{
    HttpClient http = new HttpClient();
    var htmlStr = http.GetStringAsync("https://github.com/").Result;
    //(1)處理請求結果的邏輯可以寫這裡
    label1.Text = "[同步]執行完畢!";//(2)不在需要做跨線程UI處理瞭
}

代碼3(新異步)

private async void button2_Click(object sender, EventArgs e)
 {
     HttpClient http = new HttpClient();
     var htmlStr = await http.GetStringAsync("https://github.com/");
     //(1)處理請求結果的邏輯可以寫這裡
     label1.Text = "[新異步]執行完畢!";//(2)不在需要做跨線程UI處理瞭
 }

新異步的優勢:

  • 沒有瞭煩人的回調處理
  • 不會像同步代碼一樣阻塞UI界面(造成假死)
  • 不在像舊異步處理後訪問UI不在需要做跨線程處理
  • 像使用同步代碼一樣使用異步(超清晰的邏輯)

是的,說得再多還不如看看實際效果圖來得實際:(新舊異步UI線程沒有阻塞,同步阻塞瞭UI線程)

【思考】:舊的異步模式是開啟瞭一個新的線程去執行,不會阻塞UI線程。這點很好理解。可是,新的異步看上去和同步區別不大,為什麼也不會阻塞界面呢?

【原因】:新異步,在執行await表達式前都是使用UI線程,await表達式後會啟用新的線程去執行異步,直到異步執行完成並返回結果,然後再回到UI線程(據說使用瞭SynchronizationContext)。所以,await是沒有阻塞UI線程的,也就不會造成界面的假死。

【註意】:我們在演示同步代碼的時候使用瞭Result。然,在UI單線程程序中使用Result來使異步代碼當同步代碼使用是一件很危險的事(起碼對於不太瞭解新異步的同學來說是這樣)。至於具體原因稍候再分析(哎呀,別跑啊)。

對於Web後臺服務程序

也許對於後臺程序的影響沒有單線程程序那麼直觀,但其價值也是非常大的。且很多人對新異步存在誤解。

【誤解】:新異步可以提升Web程序的性能。

【正解】:異步不會提升單次請求結果的時間,但是可以提高Web程序的吞吐量。

1、為什麼不會提升單次請求結果的時間?

其實我們從上面示例代碼(雖然是UI程序的代碼)也可以看出。

2、為什麼可以提高Web程序的吞吐量?

那什麼是吞吐量呢,也就是本來隻能十個人同時訪問的網站現在可以二十個人同時訪問瞭。也就是常說的並發量。

還是用上面的代碼來解釋。[代碼2] 阻塞瞭UI線程等待請求結果,所以UI線程被占用,而[代碼3]使用瞭新的線程請求,所以UI線程沒有被占用,而可以繼續響應UI界面。

那問題來瞭,我們的Web程序天生就是多線程的,且web線程都是跑的線程池線程(使用線程池線程是為瞭避免不斷創建、銷毀線程所造成的資源成本浪費),而線程池線程可使用線程數量是一定的,盡管可以設置,但它還是會在一定范圍內。如此一來,我們web線程是珍貴的(物以稀為貴),不能濫用。用完瞭,那麼其他用戶請求的時候就無法處理直接503瞭。

那什麼算是濫用呢?比如:文件讀取、URL請求、數據庫訪問等IO請求。如果用web線程來做這個耗時的IO操作那麼就會阻塞web線程,而web線程阻塞得多瞭web線程池線程就不夠用瞭。也就達到瞭web程序最大訪問數。

此時我們的新異步橫空出世,解放瞭那些原本處理IO請求而阻塞的web線程(想偷懶?沒門,幹活瞭。)。通過異步方式使用相對廉價的線程(非web線程池線程)來處理IO操作,這樣web線程池線程就可以解放出來處理更多的請求瞭。

不信?下面我們來測試下:

【測試步驟】:

1、新建一個web api項目

2、新建一個數據訪問類,分別提供同步、異步方法(在方法邏輯執行前後讀取時間、線程id、web線程池線程使用數)

public class GetDataHelper
{
    /// <summary>
    /// 同步方法獲取數據
    /// </summary>
    /// <returns></returns>
    public string GetData()
    {
        var beginInfo = GetBeginThreadInfo();
        using (HttpClient http = new HttpClient())
        {
            http.GetStringAsync("https://github.com/").Wait();//註意:這裡是同步阻塞
        }
        return beginInfo + GetEndThreadInfo();
    }

    /// <summary>
    /// 異步方法獲取數據
    /// </summary>
    /// <returns></returns>
    public async Task<string> GetDataAsync()
    {
        var beginInfo = GetBeginThreadInfo();
        using (HttpClient http = new HttpClient())
        {
            await http.GetStringAsync("https://github.com/");//註意:這裡是異步等待
        }
        return beginInfo + GetEndThreadInfo();
    }

    public string GetBeginThreadInfo()
    {
        int t1, t2, t3;
        ThreadPool.GetAvailableThreads(out t1, out t3);
        ThreadPool.GetMaxThreads(out t2, out t3);
        return string.Format("開始:{0:mm:ss,ffff} 線程Id:{1} Web線程數:{2}",
                                DateTime.Now,
                                Thread.CurrentThread.ManagedThreadId,                                  
                                t2 - t1);
    }

    public string GetEndThreadInfo()
    {
        int t1, t2, t3;
        ThreadPool.GetAvailableThreads(out t1, out t3);
        ThreadPool.GetMaxThreads(out t2, out t3);
        return string.Format(" 結束:{0:mm:ss,ffff} 線程Id:{1} Web線程數:{2}",
                                DateTime.Now,
                                Thread.CurrentThread.ManagedThreadId,
                                t2 - t1);
    }
}

3、新建一個web api控制器

[HttpGet]
public async Task<string> Get(string str)
{
    GetDataHelper sqlHelper = new GetDataHelper();
    switch (str)
    {
        case "異步處理"://
            return await sqlHelper.GetDataAsync();
        case "同步處理"://
            return sqlHelper.GetData();
    }
    return "參數不正確";           
}

4、發佈web api程序,部署到本地iis(同步鏈接:http://localhost:803/api/Home?str=同步處理 異步鏈接:http://localhost:803/api/Home?str=異步處理)

5、接著上面的winform程序裡面測試請求:(同時發起10個請求)

private void button6_Click(object sender, EventArgs e)
{
    textBox1.Text = "";
    label1.Text = "";
    Task.Run(() =>
    {
        TestResultUrl("http://localhost:803/api/Home?str=同步處理");
    });
}

private void button5_Click(object sender, EventArgs e)
{
    textBox1.Text = "";
    label1.Text = "";
    Task.Run(() =>
    {
        TestResultUrl("http://localhost:803/api/Home?str=異步處理");
    });
}

public void TestResultUrl(string url)
{
    int resultEnd = 0;
    HttpClient http = new HttpClient();

    int number = 10;
    for (int i = 0; i < number; i++)
    {
        new Thread(async () =>
        {
            var resultStr = await http.GetStringAsync(url);
            label1.Invoke((Action)(() =>
            {
                textBox1.AppendText(resultStr.Replace(" ", "\r\t") + "\r\n");
                if (++resultEnd >= number)
                {
                    label1.Text = "全部執行完畢";
                }
            }));

        }).Start();
    }
}

6、重啟iis,並用瀏覽器訪問一次要請求的鏈接地址(預熱)

7、啟動winform程序,點擊“訪問同步實現的Web”:

8、重復6,然後重新啟動winform程序點擊“訪問異步實現的Web”

看到這些數據有什麼感想?

數據和我們前面的【正解】完全吻合。仔細觀察,每個單次請求用時基本上相差不大。 但是步驟7″同步實現”最高投入web線程數是10,而步驟8“異步實現”最高投入web線程數是3。

也就是說“異步實現”使用更少的web線程完成瞭同樣的請求數量,如此一來我們就有更多剩餘的web線程去處理更多用戶發起的請求。

接著我們還發現同步實現請求前後的線程ID是一致的,而異步實現前後線程ID不一定一致。再次證明執行await異步前釋放瞭主線程。

【結論】:

  • 使用新異步可以提升Web服務程序的吞吐量
  • 對於客戶端來說,web服務的異步並不會提高客戶端的單次訪問速度。
  • 執行新異步前會釋放web線程,而等待異步執行完成後又回到瞭web線程上。從而提高web線程的利用率。

【圖解】:

Result的死鎖陷阱

我們在分析UI單線程程序的時候說過,要慎用異步的Result屬性。下面我們來分析:

private void button4_Click(object sender, EventArgs e)
{
    label1.Text = GetUlrString("https://github.com/").Result;
}

public async Task<string> GetUlrString(string url)
{
    using (HttpClient http = new HttpClient())
    {
        return await http.GetStringAsync(url);
    }
}

代碼GetUlrString(“https://github.com/”).Result的Result屬性會阻塞(占用)UI線程,而執行到GetUlrString方法的 await異步的時候又要釋放UI線程。此時矛盾就來瞭,由於線程資源的搶占導致死鎖。

且Result屬性和.Wait()方法一樣會阻塞線程。此等問題在Web服務程序裡面一樣存在。(區別:UI單次線程程序和web服務程序都會釋放主線程,不同的是Web服務線程不一定會回到原來的主線程,而UI程序一定會回到原來的UI線程)

我們前面說過,.net為什麼會這麼智能的自動釋放主線程然後等待異步執行完畢後又回到主線程是因為SynchronizationContext的功勞。

但這裡有個例外,那就是控制臺程序裡面是沒有SynchronizationContext的。所以這段代碼放在控制臺裡面運行是沒有問題的。

static void Main(string[] args)
{
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    GetUlrString("https://github.com/").Wait();
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    Console.ReadKey();
}

public async static Task<string> GetUlrString(string url)
{
    using (HttpClient http = new HttpClient())
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        return await http.GetStringAsync(url);
    }
}

打印出來的都是同一個線程ID

使用AsyncHelper在同步代碼裡面調用異步

但可是,可但是,我們必須在同步方法裡面執行異步怎辦?辦法肯定是有的

我們首先定義一個AsyncHelper靜態類:

static class AsyncHelper
{
    private static readonly TaskFactory _myTaskFactory = new TaskFactory(CancellationToken.None,
        TaskCreationOptions.None, TaskContinuationOptions.None, TaskScheduler.Default);

    public static TResult RunSync<TResult>(Func<Task<TResult>> func)
    {
        return _myTaskFactory.StartNew(func).Unwrap().GetAwaiter().GetResult();
    }

    public static void RunSync(Func<Task> func)
    {
        _myTaskFactory.StartNew(func).Unwrap().GetAwaiter().GetResult();
    }
}

然後調用異步:

private void button7_Click(object sender, EventArgs e)
{
    label1.Text = AsyncHelper.RunSync(() => GetUlrString("https://github.com/"));
}

這樣就不會死鎖瞭。

ConfigureAwait

除瞭AsyncHelper我們還可以使用Task的ConfigureAwait方法來避免死鎖

private void button7_Click(object sender, EventArgs e)
{
    label1.Text = GetUlrString("https://github.com/").Result;
}

public async Task<string> GetUlrString(string url)
{
    using (HttpClient http = new HttpClient())
    {
        return await http.GetStringAsync(url).ConfigureAwait(false);
    }
}

ConfigureAwait的作用:使當前async方法的await後續操作不需要恢復到主線程(不需要保存線程上下文)。

異常處理

關於新異步裡面拋出異常的正確姿勢。我們先來看下面一段代碼:

private async void button8_Click(object sender, EventArgs e)
{
    Task<string> task = GetUlrStringErr(null);
    Thread.Sleep(1000);//一段邏輯。。。。
    textBox1.Text = await task;
}

public async Task<string> GetUlrStringErr(string url)
{
    if (string.IsNullOrWhiteSpace(url))
    {
        throw new Exception("url不能為空");
    }
    using (HttpClient http = new HttpClient())
    {
        return await http.GetStringAsync(url);
    }
}

調試執行執行流程:

在執行完118行的時候竟然沒有把異常拋出來?這不是逆天瞭嗎。非得在等待await執行的時候才報錯,顯然119行的邏輯執行是沒有什麼意義的。讓我們把異常提前拋出:

提取一個方法來做驗證,這樣就能及時的拋出異常瞭。有朋友會說這樣的太坑爹瞭吧,一個驗證還非得另外寫個方法。接下來我們提供一個沒有這麼坑爹的方式:

在異步函數裡面用匿名異步函數進行包裝,同樣可以實現及時驗證。

感覺也不比前種方式好多少…可是能怎麼辦呢。

異步的實現

上面簡單分析瞭新異步能力和屬性。接下來讓我們繼續揭秘異步的本質,神秘的外套下面究竟是怎麼實現的。

首先我們編寫一個用來反編譯的示例:

class MyAsyncTest
{
    public async Task<string> GetUrlStringAsync(HttpClient http, string url, int time)
    {
        await Task.Delay(time);
        return await http.GetStringAsync(url);
    }
}

反編譯代碼:

為瞭方便閱讀,我們把編譯器自動命名的類型重命名。

GetUrlStringAsync方法變成瞭如此模樣:

public Task<string> GetUrlStringAsync(HttpClient http, string url, int time)
{
    GetUrlStringAsyncdStateMachine stateMachine = new GetUrlStringAsyncdStateMachine()
    {
        _this = this,
        http = http,
        url = url,
        time = time,
        _builder = AsyncTaskMethodBuilder<string>.Create(),
        _state = -1
    };
    stateMachine._builder.Start(ref stateMachine);
    return stateMachine._builder.Task;
}

方法簽名完全一致,隻是裡面的內容變成瞭一個狀態機GetUrlStringAsyncdStateMachine 的調用。此狀態機就是編譯器自動創建的。下面來看看神秘的狀態機是什麼鬼:

private sealed class GetUrlStringAsyncdStateMachine : IAsyncStateMachine
{
    public int _state;
    public MyAsyncTest _this;
    private string _str1;
    public AsyncTaskMethodBuilder<string> _builder;
    private TaskAwaiter taskAwaiter1;
    private TaskAwaiter<string> taskAwaiter2;    //異步方法的三個形參都到這裡來瞭
    public HttpClient http;
    public int time;
    public string url;

    private void MoveNext()
    {
        string str;
        int num = this._state;
        try
        {
            TaskAwaiter awaiter;
            MyAsyncTest.GetUrlStringAsyncdStateMachine d__;
            string str2;
            switch (num)
            {
                case 0:
                    break;

                case 1:
                    goto Label_00CD;

                default:                    //這裡是異步方法 await Task.Delay(time);的具體實現
                    awaiter = Task.Delay(this.time).GetAwaiter();
                    if (awaiter.IsCompleted)
                    {
                        goto Label_0077;
                    }
                    this._state = num = 0;
                    this.taskAwaiter1 = awaiter;
                    d__ = this;
                    this._builder.AwaitUnsafeOnCompleted<TaskAwaiter, MyAsyncTest.GetUrlStringAsyncdStateMachine>(ref awaiter, ref d__);
                    return;
            }
            awaiter = this.taskAwaiter1;
            this.taskAwaiter1 = new TaskAwaiter();
            this._state = num = -1;
        Label_0077:
            awaiter.GetResult();
            awaiter = new TaskAwaiter();            //這裡是異步方法await http.GetStringAsync(url);的具體實現
            TaskAwaiter<string> awaiter2 = this.http.GetStringAsync(this.url).GetAwaiter();
            if (awaiter2.IsCompleted)
            {
                goto Label_00EA;
            }
            this._state = num = 1;
            this.taskAwaiter2 = awaiter2;
            d__ = this;
            this._builder.AwaitUnsafeOnCompleted<TaskAwaiter<string>, MyAsyncTest.GetUrlStringAsyncdStateMachine>(ref awaiter2, ref d__);
            return;
        Label_00CD:
            awaiter2 = this.taskAwaiter2;
            this.taskAwaiter2 = new TaskAwaiter<string>();
            this._state = num = -1;
        Label_00EA:
            str2 = awaiter2.GetResult();
            awaiter2 = new TaskAwaiter<string>();
            this._str1 = str2;
            str = this._str1;
        }
        catch (Exception exception)
        {
            this._state = -2;
            this._builder.SetException(exception);
            return;
        }
        this._state = -2;
        this._builder.SetResult(str);
    }

    [DebuggerHidden]
    private void SetStateMachine(IAsyncStateMachine stateMachine)
    {
    }

}

明顯多個異步等待執行的時候就是在不斷調用狀態機中的MoveNext()方法。經驗來至我們之前分析過的IEumerable,不過今天的這個明顯復雜度要高於以前的那個。猜測是如此,我們還是來驗證下事實:

在起始方法GetUrlStringAsync第一次啟動狀態機stateMachine._builder.Start(ref stateMachine);

確實是調用瞭MoveNext。因為_state的初始值是-1,所以執行到瞭下面的位置:

繞瞭一圈又回到瞭MoveNext。由此,我們可以現象成多個異步調用就是在不斷執行MoveNext直到結束。

說瞭這麼久有什麼意思呢,似乎忘記瞭我們的目的是要通過之前編寫的測試代碼來分析異步的執行邏輯的。

再次貼出之前的測試代碼,以免忘記瞭。

反編譯後代碼執行邏輯圖:

當然這隻是可能性較大的執行流程,但也有awaiter.Iscompleted為true的情況。其他可能的留著大傢自己去琢磨吧。

以上就是C#異步的世界(下)的詳細內容,更多關於C#異步的資料請關註WalkonNet其它相關文章!

推薦閱讀: