首頁 > 軟體

C#非同步的世界(下)

2021-04-26 19:00:32

前言

今天說非同步的主要是指C#5的asyncawait非同步。在此為了方便的表述,我們稱asyncawait之前的非同步為「舊非同步」,asyncawait為「新非同步」。

新非同步的使用

只能說新非同步的使用太簡單(如果僅僅只是說使用)

方法加上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;
 }

新非同步的優勢

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

我們分兩個方面來分析: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(" ", "rt") + "rn");
                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#非同步的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com