iT邦幫忙

1

C# Task 十分鐘輕鬆學同步非同步

簡介與內容概述

預備知識 (multi-thread)

在探討同步非同步之前首先要了解何為thread, 以下內容抄錄自維基百科。

執行緒(英語:thread)是作業系統能夠進行運算排程的最小單位。大部分情況下,它被包含在行程之中,是行程中的實際運作單位。一條執行緒指的是行程中一個單一順序的控制流,一個行程中可以並行多個執行緒,每條執行緒並列執行不同的任務。在Unix System V及SunOS中也被稱為輕量行程(lightweight processes),但輕量行程更多指核心執行緒(kernel thread),而把使用者執行緒(user thread)稱為執行緒。

看起來好像粉複雜, 但其實我們可以簡單把其理解為, 一條執行緒就是一系列做事的行程

所以當我們定義一支程式的執行緒有兩條, 想要完成的任務是讓使用者可以線上與人進行格鬥比賽, 則在概念上可以設計如下兩條thread

  1. 監聽遠端伺服器指令來得知敵方操作, 接著同步本地軟體內敵方腳色的行為 , 使本地玩家得知對方的行動。
  2. 監聽本地玩家的操作, 接著同步本地軟體內我方腳色的行為, 最後上傳本地腳色的操作到遠端伺服器, 使敵方玩家可以知道我方腳色的操作。

我們可以很明顯的發現, 若我們的程式是先執行第一件事再執行第二件事, 再回頭執行第一件事, 再做第二件事, 即是以如下寫法

void enemyHit(){
	//監聽遠端伺服器指令來得知敵方操作, 接著同步本地軟體內敵方腳色的行為 , 使本地玩家得知對方的行動
}
void weHit(){
	//監聽本地玩家的操作, 接著同步本地軟體內我方腳色的行為, 最後上傳本地腳色的操作到遠端伺服器, 使敵方玩家可以知道我方腳色的操作。
}
while(1){
	enemyHit();
	weHit()
}

就會發生整場格鬥都是你一拳我一拳, 我一拳你一拳的回合制戰鬥.

那該怎麼做才可以讓玩家來場酣暢淋漓的實時戰鬥呢? 問題的答案很簡單

只要 : 兩條流程同時做就可以啦 , 所以程式同時在接收敵方的操作, 也在上傳己方的操作,這時我們就可以說這支程式是個雙線程(thread)的程式。

注 : 以上內容不包含完整知識, 為了簡化概念捨棄了許多內容, 不過用來理解下方教學已經夠用了。

簡介

所謂同步非同步語法, 即為一種方式可以定義不同條流程分別要做甚麼事情, 並且設定兩條流程的溝通規則, 包含兩條流程誰要先完成誰要後完成, 還是同時做(實務上因為CPU一次只能做一件事, 所以同時做會是以快速的交替做來完成), 都可以在這邊定義。

本篇內容

以下會分成4階段,

  1. 第一段說明Task最傳統的用法, 如何創建一條新流程, 且主流程, 子流程彼此等待、溝通。
  2. 第二段說明如何用JS也在用的async/await 語法來取代第一段所完成的程式。這裡要注意的是第一段的做法可以被第二段的作法取代, 第二段的做法也可以被第一段的作法取代, 兩者都是為了定義不同條流程分別要做甚麼事情, 和設定兩條流程的溝通規則, 只差在實現的語法不同還有底層的實作原理不同
  3. 第三段為實戰演練, 會利用實際代碼給大家看, 這個技巧在生產上可以完成甚麼任務。
  4. 第四段為該概念的進階用法, 跟前兩段沒太大關連主要是教大家如何同時調用大量線程(上方所說的流程)

傳統語法 Awaiter

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            //創建4條子線程

            Task subThread1 = new Task(() =>
            {
                //這裡可以填入一系列要讓該線程做的事
                Thread.Sleep(1000);
                Console.WriteLine("I am subThread1!");
            });
            Task subThread2 = new Task(() =>
            {
                //這裡可以填入一系列要讓該線程做的事
                Thread.Sleep(1000);
                Console.WriteLine("I am subThread2!");
            });
            Task subThread3 = new Task(() =>
            {
                //這裡可以填入一系列要讓該線程做的事
                Thread.Sleep(1000);
                Console.WriteLine("I am subThread3!");
            });
            Task subThread4 = new Task(() =>
            {
                //這裡可以填入一系列要讓該線程做的事
                Thread.Sleep(1000);
                Console.WriteLine("I am subThread4!");
            });

            // 讓線程溝通

            // 讓2條線程開始跑
            subThread2.Start();
            subThread1.Start();
            // GetAwaiter() : 等待完成, OnCompleted() : 線程完成後要做的事
            subThread1.GetAwaiter().OnCompleted(()=> {
                // 線程完成後要做的事
                // 讓2條線程開始跑, 當第一條線程跑完
                subThread4.Start();
                subThread3.Start();
            });
            // GetAwaiter() : 等待完成, GetResult() : 取得結果
            // 實際寫法範例 result =  subThread2.GetAwaiter().GetResult(); (這裡只是因為沒有回傳才這樣寫)
            subThread2.GetAwaiter().GetResult();
            // 等待線程完成才繼續往下走
            subThread3.Wait();
            subThread4.Wait();
        }
    }
}

若是實際運行上述代碼於本地會發現每次結果相異, 以下解釋相關重點資訊。

// 此處定義了如何定義新線程(一條獨立的做事流程), 但其並沒有開始運行
Task subThread1 = new Task(() =>
{
    Thread.Sleep(1000);
    Console.WriteLine("I am subThread1!");
});

// 下達指令, 創建線程開始運行, 所以此刻程式包含該程式的主線程, 總共有三條獨立的做事流程在進行。
subThread2.Start();
subThread1.Start();

// subThread1線程完成後創建subThread3 subThread4線程開始運行
subThread1.GetAwaiter().OnCompleted(()=> {
    subThread4.Start();
    subThread3.Start();
});

// 等待 subThread3完成主線程才繼續往下
subThread3.Wait();

範例輸出 :

https://ithelp.ithome.com.tw/upload/images/20210711/20131164dOKcY8kTnk.png

https://ithelp.ithome.com.tw/upload/images/20210711/20131164TWzaDXDNKo.png

結果不同的原因, 下方的溝通只定義了等到XXX完成才繼續, 但在XXX.Start()後, 一堆線程早就同時在運行了, 有可能主線程還沒運行到等待那行, XXX就已經完成了。

常見用法 async await

與第一部分程式碼效果基本一致, 可以自行對照

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            //Main 為C#進入點, 不可為非同步函式, 所以用傳統語法對我們的同步函數進行包裝
            int n = main().GetAwaiter().GetResult();
            Console.WriteLine(n);
        }
        // 同步函數會回傳、創建且運行一個線程, return後方的回傳值會直接包在Task裡面
        // 所以若是 return後面是一個字串, 則實際傳出的就是一個 Task<string>
        static async Task createTask(int threadNum)
        {
            //這裡可以填入一系列要讓該線程做的事
            // await表示該線程完成再繼續執行, Task.Delay表示創建一個線程其行為為等待XXX毫秒
            await Task.Delay(1000);
            Console.WriteLine($"I am subThread{threadNum}!");
            return;
        }

        static async Task<int> main()
        {
            //創建且執行兩條新線程
            Task subThread1 = createTask(1);
            Task subThread2 = createTask(2);
            // 等待某一線程完成
            await subThread1;
            //創建且執行兩條新線程
            Task subThread3 = createTask(3);
            Task subThread4 = createTask(4);
            // 等待以下線程完成後 return 
            await subThread2;
            await subThread3;
            await subThread4;
            return 1;
        }
    }
}

實戰演練

以下為call API常用到的程式碼, 引用組件, 寄送http request, 由於該組件寄request的方法為創建一個新線程來寄送, 所以須利用本篇教學的內容來完成撰寫。

  1. 傳統語法

    using System;
    using System.IO;
    using System.Net.Http;
    using System.Threading.Tasks;
    
    namespace general
    {
        class Program
        {
            static HttpClient client = new HttpClient();
    
            static void Main(string[] args)
            {
                //讀取參數 非本教學重點
                StreamReader r = new StreamReader("xxx.json");
                string jsonString = r.ReadToEnd();
                string res = PostRequest("API", jsonString);
            }
    
            public static string PostRequest(string URI, string PostParams)
            {
    			//設定API 非本教學重點
                client.BaseAddress = new Uri("http://XXX");
                client.DefaultRequestHeaders.Add("sat", "1234");
                client.DefaultRequestHeaders.Add("sid", "1234");
                client.DefaultRequestHeaders.Add("code", "");
                client.Timeout = TimeSpan.FromSeconds(30);
    			//創建新線程, 實際利用組件寄request, 且等待http response回傳才會繼續往下走。(該組件該函數會自行創建線程且運行)
                HttpResponseMessage response = client.PostAsync(URI, new StringContent(PostParams)).GetAwaiter().GetResult();
    			//創建新線程, 利用組件解讀response, 且等待解讀完成回傳後才繼續運行。(該組件該函數會自行創建線程且運行)
    			string content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
                return content;
            }
        }
    }
    
  2. async await

    using System;
    using System.IO;
    using System.Net.Http;
    using System.Threading.Tasks;

    namespace AsyncAwait
    {
        class Program
        {
            static HttpClient client = new HttpClient();

            static void Main(string[] args)
            {
                main().GetAwaiter().GetResult();
            }

            static async Task main()
            {
    			//讀取參數 非本教學重點
                StreamReader r = new StreamReader("xxx.json");
                string jsonString = r.ReadToEnd();
    			//創建新線程來完成寄request, 且等待回傳才會繼續往下走。
                string res = await PostRequest("API", jsonString);
                //do things about res
                return;
            }

            public static async Task<string> PostRequest(string URI, string PostParams)
            {
    			//設定API 非本教學重點
                client.BaseAddress = new Uri("http://XXX");
                client.DefaultRequestHeaders.Add("sat", "1234");
                client.DefaultRequestHeaders.Add("sid", "1234");
                client.DefaultRequestHeaders.Add("code", "");
                client.Timeout = TimeSpan.FromSeconds(30);
    			//創建新線程, 實際利用組件寄request, 且等待http response回傳才會繼續往下走。
                HttpResponseMessage response = await client.PostAsync(URI, new StringContent(PostParams));
    			//創建新線程, 利用組件解讀response, 且等待解讀完成回傳後才繼續運行。
    			string content = await response.Content.ReadAsStringAsync();
    			//回傳解讀內容給正在等待的父線程
                return content;
            }
        }
    }

進階用法 whenAll

本程式的目的為把pathOfFolder資料夾下從0.jpg~99.jpg的檔案名變更成0_new.jpg~99_new.jpg

其中利用把整個操作打包成一個線程, 再把線程複製100次, 使其可以100件事同時做(實際上基於底層原理不會如此理想)。

whenAll的功能是同時執行其傳入作為參數的所有線程, 且傳入參數須為List型別。

private void whenAllDemo()
{
            List<Task> taskList = new List<Task>();
            for(int i = 0; i < 100; i++)
            {
                string sourceName = i.toString();
                string disName = i.toString() + "_new";
                taskList.Add(Task.Run(() => {
                    try
                    {
                        File.Move($"{pathOfFolder}\\{sourceName}.jpg" , $"{pathOfFolder}\\{disName}.jpg");
                    }
                    catch (Exception err)
                    {
                        MessageBox.Show(err.Message);
                        throw;
                    }
                }));
            }
            Task allTask = Task.WhenAll(taskList);
            try
            {
                allTask.Wait();
            }
            catch { }

            if (allTask.Status == TaskStatus.RanToCompletion)
                MessageBox.Show("success!");
            else if (allTask.Status == TaskStatus.Faulted)
                MessageBox.Show("something wrong");
}

尚未有邦友留言

立即登入留言