2020年11月16日 星期一

C# 用最快的速度完成他,不考慮CPU記憶體,完成 10000 工作單元,你所不瞭解的多執行緒計

C# 用最快的速度完成他,不考慮CPU記憶體,完成 10000 工作單元,你所不瞭解的多執行緒計算

這兩天看到臉書社團上有篇討論文章,那就是提問的人提出一個問題:用最快的速度完成他,不考慮CPU記憶體 同時執行 10000 次

這裡原先是要透過底下的程式碼,產生出10000個併行工作單元,並且使用 Parallel 類別 提供的Parallel.For 方法來同時執行這些工作單元,在每個委派方法內,使用休息5秒的做法,模擬需要執行的處理時間,而提問的人遇到瓶頸,希望採用 用最快的速度完成他,不考慮CPU記憶體 的方式來解決此一問題。

Parallel.For(0, 10000, (i) =>
{
Thread.Sleep(5 * 1000);
});

不知道大家是否有看到提問人提出的這個簡單又明瞭的訴求 用最快的速度完成他,不考慮CPU記憶體,又姑且不論大家有著許多額外的建議與批評,這包括討論到 Thread 與 Task 是否有不同、有差異嗎?Thread.Sleep 也是浪費那條thread、thread 新增太慢、您應該先考慮有沒有了解 Thread 的意思、你要先搞清楚你要處理的事件是CPU密集型任務還是IO密集型任務,task本質上只是在同一個時間可以做更多事,不會加快處理事件的時間、這種程式我一輩子都不會寫到也不會遇到有這種需求,請問你能得到甚麼等等。

首先,我想要先針對提問人的需求,不管有著潛在問題或者後遺症,先來看看是否能夠做到且滿足他的需求,那就是同時啟動10000並行工作單元,能否在 5 秒內完成。

先使用最基本的 C# Thread 執行緒類別,看看能否做到同時執行10000個相同的工作,並且在五秒左右同時完成,底下是採用的程式碼

int MAX = 10000;
int SLEEP = 5 * 1000;
List<Thread> threads = new List<Thread>();
CountdownEvent cde = new CountdownEvent(MAX);
Console.WriteLine($"starting {MAX} threads...");
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < MAX; i++)
{
    int idx = i;
    Thread thread = new Thread(x =>
    {
        Thread.Sleep(SLEEP);
        cde.Signal();
    });
    thread.IsBackground = true;
    threads.Add(thread);
}
 
 
for (int i = 0; i < MAX; i++)
{
    threads[i].Start();
}
 
cde.Wait();
stopwatch.Stop();
Console.WriteLine();
Console.WriteLine($"{stopwatch.ElapsedMilliseconds} ms");

這裡透過 for 迴圈,產生出 10000 個執行緒物件,並且設定這些執行緒都是背景執行緒,這是可選擇性的作法,當然也可以產生 10000 個前景執行緒;為了要能夠確認這 10000 個執行是否都執行完畢,在此使用了 CountdownEvent 類別,根據這篇文章 提到 : System.Threading.CountdownEvent 是一個同步處理基本類型,在發出了特定次數的訊號給它之後,就會解除封鎖其等候中的執行緒。所以,就使用了 new CountdownEvent(MAX) 來進行 10000 次的倒數計時,只要執行緒執行完成之後,便會透過 cde.Signal(); 敘述,送出訊號,這樣就會完成倒數加一的工作。

再透過另外一個迴圈,將這些執行緒一次全部啟動執行,因此,理論上這台電腦中將會有 10000 個同時執行的工作,在此迴圈之後,使用 cde.Wait() 方法來等待 10000 委派方法的執行完成,因為這裡有使用 Stopwatch 類別 要來量測整個大量同時執行的工作花費了多少時間,最後便會顯示出總共執行時間大約是多少。

這裡將會是分別執行 3 次的輸出結果

starting 10000 threads...

6239 ms

starting 10000 threads...

6198 ms

starting 10000 threads...

6480 ms

先使用最基本的 C# Task 工作類別,看看能否做到同時執行10000個相同的工作,並且在五秒左右同時完成,底下是採用的程式碼

int MAX = 10000;
int SLEEP = 5 * 1000;
List<Task> tasks = new List<Task>();
Console.WriteLine($"starting {MAX} tasks...");
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < MAX; i++)
{
    int idx = i;
    Task task = Task.Factory.StartNew(() =>
      {
          Thread.Sleep(SLEEP);
      }, TaskCreationOptions.LongRunning);
    tasks.Add(task);
}
Task.WaitAll(tasks.ToArray());
stopwatch.Stop();
Console.WriteLine();
Console.WriteLine($"{stopwatch.ElapsedMilliseconds} ms");

這裡透過 for 迴圈,產生出 10000 個 Task 物件,不過,這裡使用了 TaskFactory.StartNew 方法 來產生這些大量工作物件,原因很簡單,因為想要這些大量的工作物件,不需要透過 執行緒集區 來取得執行緒,而是要透過直接建立一個新的執行緒來處理該工作所指派的委派程式碼

(聽起來也些模糊、有些詭異、有些迷糊,反過頭來說,若你看懂了這段話,其實,這篇文章的問題你也就會解了;另外,許多人,甚至自視為大神的人,似乎對於執行緒與工作間的差異與本質不同,存在著許多問題,請大家在學習或者觀看網路文章的時候,要多多 停、看、聽)

當啟動完成 10000 個工作之後,便使用了 Task.WaitAll 方法 來等待全部的 10000 個工作都完成。

(從這裡,聰明的你應該已經看得出 Thread 執行緒 與 Task 工作 之間的差異點了嗎?但是,你可以分辨與看得出相同點嗎?)

這裡有使用 Stopwatch 類別 要來量測整個大量同時執行的工作花費了多少時間,最後便會顯示出總共執行時間大約是多少。

這裡將會是分別執行 3 次的輸出結果

starting 10000 tasks...

6550 ms

starting 10000 tasks...

6547 ms

starting 10000 tasks...

6533 ms

說明到這裡,你應該也看得出來 Thread 執行緒 與 Task 工作 之間的相同點了嗎?其中一個是,不論是使用執行緒,或者工作來同時執行 10000 工作單元,每個工作單元預計約 5 秒鐘執行時間,而整個執行完成的時間大約是 6.1~6.6 秒,這應該與原提問人想要做到的目標有些接近吧~

若對於這裡所提到的內容,歡迎大家在這裡進行討論,看看大家是否可以推敲出問題在哪裡,畢竟

名偵探柯南最常說的一句話 : 真相只有一個

請繼續參考更精采的 C# 平行 / 並行計算 Parallel.For 隱藏在細節背後的惡魔,你所不瞭解的平行與併行計算

彩蛋

上面兩種做法為單純僅使用執行序,或者工作來滿足這個題目的需求,不過,不論是哪種作法,都可以看到要耗損將近 10000 個執行序來完成這個需求任務。

在此,稍微修改一下原先 Task 的程式碼如下

int MAX = 10000;
int SLEEP = 5 * 1000;
List<Task> tasks = new List<Task>();
Console.WriteLine($"starting {MAX} tasks...");
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < MAX; i++)
{
    int idx = i;
    Task task = Task.Run(async () =>
    {
        await Task.Delay(SLEEP);
    });
    tasks.Add(task);
}
Task.WaitAll(tasks.ToArray());
stopwatch.Stop();
Console.WriteLine();
Console.WriteLine($"{stopwatch.ElapsedMilliseconds} ms");

這裡將會是分別執行 3 次的輸出結果

starting 10000 tasks...

5024 ms

starting 10000 tasks...

5035 ms

starting 10000 tasks...

5036 ms

甚麼?上面的程式碼幾乎與前面採用 TaskFactory.StartNew 方法 的做法大致相同,但是,為什麼這樣的寫法卻更接近 5 秒的時間,幾乎是 5.0xx 秒左右。

到這裡,讀者您應該更能夠了解到直接使用執行緒或工作來強制產生大量執行緒所帶來的後遺症與副作用,之前有聽到某位自稱大神說過,想要讓程式跑的更快,就要使用更多的執行緒,殊不知嗎啡可用於幫人麻醉的緩解疼痛藥品,但是長期經常服用,而不知道嗎啡具有成癮性,將會形成吸食毒品問題與造成身體器官發生問題;用多了執行緒,到時候會很麻煩地。隨意聽信偏方、江湖術士的話,受騙的將會是你自己,因此,唯有對於整個基本知識與運作方式的徹底明瞭,才會有助於這接高階技術的學習與未來進行除錯與思考的依據。

炸(詐)彈

好的,大部分的看完這篇文章之後,再度回到原先的問題

那就是提問的人提出一個問題:用最快的速度完成他,不考慮CPU記憶體 同時執行 10000 次,這裡指名 使用 Parallel 類別 提供的Parallel.For 方法 來完成

Parallel.For(0, 10000, (i) =>
{
Thread.Sleep(5 * 1000);
});

也就想說,沒問題,我也會解決此一問題,那就是把原先的 Thread.Sleep(5 * 1000); 敘述,改成 await Task.Delay(5 * 1000); 那不就好了。而且許多大神也都是這麼順利成章的說,想要使用非同步處理,就直接使用 Parallel.For 方法 就可以做到了(也許你已經成為歐陽鋒,而所學成的九陰真經是黃蓉瞎掰給你的,最後結果是如何,要你自己去看那本書),現在也是驗證這些人說明的時候。

Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
Parallel.For(0, 10000, async (i) =>
{
    //Thread.Sleep(5 * 1000);
    await Task.Delay(5 * 1000);
});
stopwatch.Stop();
Console.WriteLine();
Console.WriteLine($"{stopwatch.ElapsedMilliseconds} ms");

OK OK 很 OK ,那就開始執行吧,將會得到底下的結果


23 ms

xxxx.exe (處理序 37520) 已結束,出現代碼 0。
按任意鍵關閉此視窗…

一看到執行結果,不要以為你練成神功了,若以剛剛的例子,還沒五秒鐘,只花費了 23 ms ,整個程式就結束執行了,這樣似乎與之前使用 Thread.Sleep 方法有些不同,因為,在這個例子中,程式一結束,那 10000 個等候 5 秒的工作單元 Unit of Work 在還沒執行完成前,也就直接提前終止執行了。

這樣的結果不是所預期的,因此,再度修改程式碼,使用執行緒同步 CountdownEvent 類別 來同時等待這 10000 個工作單元的完成時刻來臨。

CountdownEvent cde = new CountdownEvent(10000);
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
Parallel.For(0, 10000, async (i) =>
{
    //Thread.Sleep(5 * 1000);
    await Task.Delay(5 * 1000);
    cde.Signal();
});
 
cde.Wait();
stopwatch.Stop();
Console.WriteLine();
Console.WriteLine($"{stopwatch.ElapsedMilliseconds} ms");

稍做小小修正,完成上述程式碼,二話不囉嗦,再來執行3次,得到底下結果


5039 ms

xxxx.exe (處理序 37520) 已結束,出現代碼 0。

5043 ms

xxxx.exe (處理序 37520) 已結束,出現代碼 0。

5034 ms

xxxx.exe (處理序 37520) 已結束,出現代碼 0。

首先,整個處理程序 Process 沒有提早 5 秒鐘前就結束,接著,竟然使用 Parallel.For 方法 也可以做到僅需要 5 秒鐘就可以完成 10000 個工作單元的預期目標,現在,觀看這篇文章的讀者能夠知道發生了甚麼問題嗎?

若對於這裡所看到的各種疑問,歡迎大家在這裡進行討論,看看大家是否可以推敲出問題在哪裡,畢竟

名偵探柯南最常說的一句話 : 真相只有一個

請繼續參考更精采的

C# 平行 / 並行計算 Parallel.For 隱藏在細節背後的惡魔,你所不瞭解的平行與併行計算

Blazor實戰故事經驗分享 1 - 風起雲湧 如何從無到有建立Blazor團隊與採用全端開發方式設計出給上市企業使用的Web系統

Blazor實戰故事經驗分享 2 - 風雲再現 探究 Blazor 可以快速開發出來內部細節



2020年11月10日 星期二

C# 平行 / 並行計算 Parallel.For 隱藏在細節背後的惡魔,你所不瞭解的平行與併行計算

C# 平行 / 並行計算 Parallel.For 隱藏在細節背後的惡魔,你所不瞭解的平行與併行計算

幾乎所有使用 C# 程式語言的開發者,都會於 平行 / 並行計算 parallel / concurrent computing 存在著少女心的崇景,多麼希望能夠擁有與掌握些技術呀?可是,老天爺是殘酷的,對於你的願望,老天爺是有聽到,也就給你一盞明燈 Parallel 類別,滿心歡喜地來學習這項指引,該 Parallel 類別開宗明義地說到: 提供平行迴圈和區域的支援 ,神呀,這就是我要的功能與技能,請賜與我神奇的力量吧~。

順手打開 Parallel.For 方法,看到這個方法為:執行可平行執行反覆項目的 for 迴圈 (請大家務必先閱讀一下這個連結的文章內容) ,而且在這篇文章中也附上一個相當清楚的使用範例,相信你已經躍躍欲試,並且可以把大量重複性的工作,使用這個具有平行處理的方法,做到大幅提升效能的目的,因為大家可以同時一起來執行呀~~。

舉例來說,若這些重複性的工作每個需要約 500ms 才能夠執行完成,現在有 100 這樣的工作要來處理,你確信神明不會騙你,因為你也閱讀過這篇文章,你也相信你的眼睛不會看錯,眼見為憑,此時信信滿滿的和你老闆說,現在我可以把 100 個重複性工作,透過最新學成絕技,在 500ms 內把他們都執行完畢。

二話不說,寫段程式碼來測試看看,讓你老闆看到你的高超技能,先來平行執行 4 個工作,在這裡,每個工作會使用 Thread.Sleep 方法來模擬正在進行處理相關工作,這裡的休息時間將會使用變數 processCost 來代表,其單位是 ms;另外,使用變數 N 來代表要同時執行幾個相同計算作業。在這裡使用到了 Parallel.For 方法 來做到這樣平行執行能力;最後,要來量測整個大量同時執行的工作花費了多少時間,這裡使用了 Stopwatch 類別 來做到這樣的需求,這個模擬程式於執行完成之後,會輸出總共花費了多少時間。

底下是完成的程式碼

int processCost = 500;
int N = 4;
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
Parallel.For(0, N, i =>
{
    Thread.Sleep(processCost);
});
stopwatch.Stop();
Console.WriteLine($"平行處理 {N} 個工作,共需 {stopwatch.ElapsedMilliseconds} ms");

不過,請先不要觀看底下結過

看到程式碼之後,你覺到這樣的程式碼執行完成之後

會用到多少時間呢?

底下是在作者電腦上執行完成的結果

平行處理 4 個工作,共需 675 ms

感覺上還不錯,雖然有著 175 ms 的落差,似乎還不錯,不要緊張,還可以做到更好,這裡是使用除厝組態來執行,現在切換成為 Release 模式來執行

平行處理 4 個工作,共需 524 ms

哇,更佳完美了,幾乎同時完成了這四個工作,這下子更有信心了

(思考 : 不過,你知道為什麼會有這樣的大幅改善嗎?)

現在把同時要處理的工作改成 16 個,也就是把 N 改成 16 ,你覺得全部都執行完成,需要花費多少時間呢?

int processCost = 500;
int N = 16;
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
Parallel.For(0, N, i =>
{
    Thread.Sleep(processCost);
});
stopwatch.Stop();
Console.WriteLine($"平行處理 {N} 個工作,共需 {stopwatch.ElapsedMilliseconds} ms");

既然是平行處理,當然是大約 5xx ms,現在的執行結果,全部都使用 Release 模式下,採用不除錯的方式來執行。

平行處理 16 個工作,共需 1045 ms

我的老天鵝,這是在作弄我嗎?這裡只不過把同時要處理的工作提升到 16 個,就需要 1045 ms 才能夠執行完成,不過,最後是要能夠同時處理 100 個呀,那麼不是無法做到僅需要 5xx ms 同時完成的境界嗎?

(思考 : 為什麼會產生出其他額外的時間,這些時間究竟是在執行那些程式碼呢?)

(思考 : 那麼,真的使用這樣平行設計方法,可以做到約 5xx ms 來完成所有結果嗎? ==> 是第)

現在在來提升同時處理工作數量,現在提升為 50 個 (N=50)

請開始評估與猜測,你認為需要多久的時間呢?

平行處理 50 個工作,共需 3111 ms

最後讓 N=100,所得到的結果是

平行處理 100 個工作,共需 6194 ms

看樣子,100平行執行的工作,處理時間約50個平行處理工作的一倍

(思考 : 真的是每增加一倍的平行處理工作量,就會需要額外一倍的處理時間?)

再來個挑戰,讓每個處理工作設定約 5000 ms 才能夠完成,並且設定同時處理 8 個工作,所得到的結果如下

再來個挑戰,讓每個處理工作設定約 5000 ms 才能夠完成,並且設定同時處理 8 個工作,所得到的結果如下

再來個挑戰,讓每個處理工作設定約 5000 ms 才能夠完成,並且設定同時處理 8 、 50 、 100 個工作,所得到的結果如下

int processCost = 5000;
int N = 8;
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
Parallel.For(0, N, i =>
{
    Thread.Sleep(processCost);
});
stopwatch.Stop();
Console.WriteLine($"平行處理 {N} 個工作,共需 {stopwatch.ElapsedMilliseconds} ms");

同時平行執行 8 個工作的執行結果

平行處理 8 個工作,共需 5041 ms

同時平行執行 50 個工作的執行結果

平行處理 50 個工作,共需 20108 ms

同時平行執行 100 個工作的執行結果

平行處理 100 個工作,共需 35115 ms

不知道當你看到這樣的結果出來,是不是又推翻掉你之前對於 Parallel 類別 理解,不要認為這些數值的變動是不正常的,當您了解到背後的運作原理與許多核心功能,這些執行結果都是可以預測出來的,你無須撞牆、踩雷、等這彩蛋挑出來給你驚喜。

這篇文章先針對幾乎所有 C# 開發者都想要具備 平行 / 並行計算 parallel / concurrent computing 希望能夠輕鬆駕馭這個技能的心情,讓大家了解到其實要學會這樣的技能並不困難,困難的在於你是否準備好要去理解隱藏在背後的原理、小心假設並且逐步來驗證、要能夠區分所看到、聽到、碰到的人所告訴你的知識與方法是否是不正確的,畢竟,網路上很多自稱為大神的人,當包裝的光環退去之後,剩下沒有穿衣服的大神,相信這樣的裸身大神並不使養眼的,也不是你想要看到的。

若對於這裡所提到的問題,歡迎大家在這裡進行討論,看看大家是否可以推敲出問題在哪裡,畢竟

名偵探柯南最常說的一句話 : 真相只有一個 

相關文章

C# 用最快的速度完成他,不考慮CPU記憶體,完成 10000 工作單元,你所不瞭解的多執行緒計算

Blazor實戰故事經驗分享 1 - 風起雲湧 如何從無到有建立Blazor團隊與採用全端開發方式設計出給上市企業使用的Web系統

Blazor實戰故事經驗分享 2 - 風雲再現 探究 Blazor 可以快速開發出來內部細節