C#非同步程式設計由淺入深(一)

白煙染黑墨發表於2021-03-28

一、什麼算非同步?

  廣義來講,兩個工作流能同時進行就算非同步,例如,CPU與外設之間的工作流就是非同步的。在面向服務的系統中,各個子系統之間通訊一般都是非同步的,例如,訂單系統與支付系統之間的通訊是非同步的,又如,在現實生活中,你去館子吃飯,工作流是這樣的,點菜->下單->做你的事->上菜->吃飯,這個也是非同步的,具體來講你和廚師之間是非同步的,非同步是如此重要,因外它代表者高效率(兩者或兩者以上的工作可以同時進行),但複雜,同步的世界簡單,但效率極極低。

二、在程式設計中的非同步

  在程式設計中,除了同步和非同步這兩個名詞,還多了一個阻塞和非阻塞,其中,阻塞和非阻塞是針對執行緒的概念,那麼同步和非同步是針對誰呢?其實很多情況下同步和非同步並沒有具體針對某一事物,所以導致了針對同步阻塞、同步非阻塞、非同步阻塞、非同步非阻塞這幾個概念的模糊不清。並且也確實沒有清晰的邊界,請看以下例子:

    public static void DoWorkA()
    {
        Thread thread = new Thread(() => 
        {
            Console.WriteLine("WorkA Done!");
        });
        thread.Start();
    }

    public static void DoWordB()
    {
        Thread thread = new Thread(() =>
        {
            Console.WriteLine("WorkB Done!");
        });
        thread.Start();
    }
    static void Main(string[] args)
    {
        DoWorkA();
        DoWordB();
    }

  假設執行該程式碼的CPU是單核單執行緒,那麼請問?DoWorkA()、DoWorkB()這兩個函式是非同步的嗎?因為CPU是單核,所以根本不能同時執行兩個函式,那麼從這個層次來講,他們之間其實是同步的,但是,現實的情況是我們一般都認為他們之間是非同步的,因為我們是從程式碼的執行順序角度考慮的,而不是從CPU本身的工作流程考慮的。所以要分上下文考慮。再請看下面這個例子:

    static void Main(string[] args)
    {
        DoWorkA();
        QueryDataBaseSync();//同步查詢資料庫
        DoWorkB();
    }

  從程式碼的執行順序角度考慮,這三個函式執行就是同步的,但是,從CPU的角度來講,資料庫查詢工作(另一臺機器)和CPU計算工作是非同步的,在下文中,沒有做特別申明,則都是從程式碼的執行順序角度來討論同步和非同步。
  再解釋一下阻塞和非阻塞以及相關的知識:

  阻塞特指執行緒由執行狀態轉換到掛起狀態,但CPU並不會阻塞,作業系統會切換另一個處於就緒狀態的執行緒,並轉換成執行狀態。導致執行緒被阻塞的原因有很多,如:發生系統呼叫(應用程式呼叫系統API,如果呼叫成功,會發生從應用態->核心態->應用態的轉換開銷),但此時外部條件並沒有滿足,如從Socket核心緩衝區讀資料,此時緩衝區還沒有資料,則會導致作業系統掛起該執行緒,切換到另一個處於就緒態的執行緒然後給CPU執行,這是主動呼叫導致的,還有被動導致的,對於現在的分時作業系統,在一個執行緒時間片到了之後,會發生時鐘中斷訊號,然後由作業系統預先寫好的中斷函式處理,再按一定策略(如執行緒優先順序)切換至另一個執行緒執行,導致執行緒被動地從執行態轉換成掛起狀態。
  非阻塞一般指函式呼叫不會導致執行該函式的執行緒從執行態轉換成掛起狀態。

三、原始的非同步程式設計模式之回撥函式

  在此之前,我們先稍微瞭解下圖形介面的工作原理,GUI程式大概可以用以下虛擬碼表示:

While(GetMessage() != 'exit') //從執行緒訊息佇列中獲取一個訊息,執行緒訊息佇列由系統維護,例如滑鼠移動事件,這個事件由作業系統捕捉,並投遞到執行緒的訊息佇列中。
{
    msg = TranslateMessage();//轉換訊息格式
    DispatherMessage(msg);//分發訊息到相應的處理函式
}

  其中DispatherMessage根據不同的訊息型別,呼叫不同的訊息處理函式,例如滑鼠移動訊息(MouseMove),此時訊息處理函式可以根據MouseMove訊息中的值,做相應的處理,例如呼叫繪圖相關函式畫出滑鼠此刻的形狀。
  一般來講,我們稱這個迴圈為訊息迴圈(事件迴圈、EventLoop),程式設計模型稱為訊息驅動模型(事件驅動),在UI程式中,執行這部分程式碼的執行緒一般只有一個執行緒,稱為UI執行緒,為什麼是單執行緒,讀者可以去思考。
  以上為背景知識。現在,我們思考,假如在UI執行緒中執行一個會導致UI執行緒被阻塞的操作,或者在UI執行緒執行一個純CPU計算的工作,會發生什麼樣的結果?如果執行一個導致UI執行緒被阻塞的操作,那麼這個訊息迴圈就會被迫停止,導致相關的繪圖訊息不能被相應的訊息處理函式處理,表現就是UI介面“假死”,直到UI執行緒被喚起。如果是純CPU計算的工作,那麼也會導致其他訊息不能被及時處理,也會導致介面“假死”現象。如何處理這種情況?寫非同步程式碼。
  我們先用控制檯程式模擬這個UI程式,後面以此為基礎。

    public static string GetMessage()
    {
        return Console.ReadLine();
    }

    public static  string TranslateMessage(string msg)
    {
        return msg;
    }

    public static  void DispatherMessage(string msg)
    {
        switch (msg)
        {
            case "MOUSE_MOVE":
                {
                    OnMOUSE_MOVE(msg);
                    break;
                }
            default:
                break;
        }
    }

    public static void OnMOUSE_MOVE(string msg)
    {
        Console.WriteLine("開始繪製滑鼠形狀");
    }


    static void Main(string[] args)
    {
        while(true)
        {
            string msg = GetMessage();
            if (msg == "quit") return;
            string m = TranslateMessage(msg);
            DispatherMessage(m);
        }
    }
1、回撥函式

  上面那個例子,一但外部有訊息到來,根據不同的訊息型別,呼叫不同的處理函式,如滑鼠移動時產生MOUSE_DOWN訊息,相應的訊息處理函式就開始重新繪製滑鼠的形狀,這樣一但你滑鼠移動,就你會發現螢幕上的滑鼠跟著移動了。
  現在假設我們增加一個訊息處理函式,如OnMOUSE_DOWN,這個函式內部進行了一個阻塞的操作,如發起一個HTTP請求,在HTTP請求回覆到來前,該UI程式會“假死”,我們編寫非同步程式碼來解決這個問題。

    public static int Http()
    {
        Thread.Sleep(1000);//模擬網路IO延時
        return 1;
    }
    public static void HttpAsync(Action<int> action,Action error)
    {
        //這裡我們用另一個執行緒來實現非同步IO,由於Http方法內部是通過Sleep來模擬網路IO延時的,這裡也只能通過另一個執行緒來實現非同步IO
        //但記住,多執行緒是實現非同步IO的一個手段而已,它不是必須的,後面會講到如何通過一個執行緒來實現非同步IO。
        Thread thread = new Thread(() => 
        {
            try
            {
                int res = Http();
                action(res);
            }
            catch
            {
                error();
            }
    
        });

        thread.Start();
    }
    public static void OnMouse_DOWN(string msg)
    {
        HttpAsync(res => 
        {
            Console.WriteLine("請求成功!");
            //使用該結果做一些工作
        }, () => 
        {
            Console.WriteLine("請求發生錯誤!");
        });
    }

  此時介面不再“假死”了,我們看下程式碼可讀性,感覺還行,但是,如果再在回撥函式裡面再發起類似的非同步請求呢?(有人可能有疑問,為什麼還需要發起非同步請求,我發同步請求不行嗎?這都是在另一個執行緒裡了。是的,在這個例子裡是沒問題的,但真實情況是,執行回撥函式的程式碼,一般都會在UI執行緒,因為取得結果後需要更新相關UI元件上的介面,例如文字,而更新介面的操作都是放在UI執行緒裡的,如何把回撥函式放到UI執行緒上執行,這裡不做討論,在.NET中,這跟同步上下文(Synchronization context)有關,後面會講到),那麼程式碼會變成這樣

    public static void OnMouse_DOWN(string msg)
    {
        HttpAsync(res => 
        {
            Console.WriteLine("請求成功!");
            //使用該結果做一些工作

            HttpAsync(r1 => 
            {
                //使用該結果做一些工作

                HttpAsync(r2 => 
                {
                    //使用該結果做一些工作
                }, () => 
                {

                });
            }, () => 
            {

            });
        }, () => 
        {
            Console.WriteLine("請求發生錯誤!");
        });
    }

  寫過JS的同學可能很清楚,這叫做“回撥地獄”,如何解決這個問題?JS中有Promise,而C#中有Task,我們先用Task來寫這一段程式碼,然後自己實現一個與Task功能差不多的簡單的類庫。

    public static Task<int> HttpAsync()
    {
        return Task.Run(() => 
        {
            return Http();
        });
    }


    public static void OnMouse_DOWN(string msg)
    {
        HttpAsync()
            .ContinueWith(t => 
            {
                if(t.Status == TaskStatus.Faulted)
                {

                }else if(t.Status == TaskStatus.RanToCompletion)
                {
                    //做一些工作
                }
            })
            .ContinueWith(t => 
            {
                if (t.Status == TaskStatus.Faulted)
                {

                }
                else if (t.Status == TaskStatus.RanToCompletion)
                {
                    //做一些工作
                }
            })
            .ContinueWith(t => 
            {
                if (t.Status == TaskStatus.Faulted)
                {

                }
                else if (t.Status == TaskStatus.RanToCompletion)
                {
                    //做一些工作
                }
            });
    }

  是不是感覺清爽了許多?這是編寫非同步程式碼的第一個躍進。下篇將會介紹,如何自己實現一個簡單的Task。後面還會提到C#中async/await的本質作用,async/await是怎麼跟Task聯絡起來的,怎麼把自己寫的Task庫與async/await連結起來,以及一個執行緒如何實現非同步IO。
  覺得有收穫的不妨點個贊,有支援才有動力寫出更好的文章。

相關文章