用.NET展現多核威力(2) - 一核一緒 王者之道?
在前一篇文章裡,我們陰了ThreadPool一下,把一個運算十分簡單,但是數量極其龐大的計算需求拆解成無數UserWorkItem交給ThreadPool執行,然後冷眼旁觀ThreadPool在lock機制的消磨下,慘敗給傻瓜都會的單一執行緒寫法,速度足足慢了七倍有餘...
lock機制看來是最大的殺手。明明人手充足,卻規定所有人員必須排隊成一列輪流完成某個動作才能繼續工作,當完成工作本身所需的時間很短,則耗費在排隊的時間就顯得漫長而荒謬。這就是前一篇文章所點出的事實。
那麼,在這個案例中,我們應如何改善? 概念很簡單---讓所有可用的CPU資源都專心投入在執行運算上,避免花費任何額外資源去處理不必要的工作(例如: lock機制)。
第一步,先要除去心頭大患---lock機制!! 回到前文提到的50個阿宅組電腦的例子上(謎之聲: 上回尊稱"電腦組裝達人",這回簡稱"阿宅",夠現實!),要求所有人排隊領組裝套件是為了確保所有的套件都有分配出去,並且每份套件只被交付給一個人。在我們的案例中,還有另一方免排隊的好方法: 先算好有多少份組件,有多少人,預先把誰處理哪幾件分配好。一開始就把各人應處理的組件發下去,就不必大家擠著排隊。而我們也不再需要再去關心還有多少組件沒有分完做完,改問每個人是否已做完所有分配到的工作即可,這麼一來也不必麻煩大家排隊到黑板上寫正字。
對應到程式碼上,假設我們有一台四核的電腦,便可以把1000萬次(0 - 9,999,999)拆成0 - 2,499,999、2,500,000 - 4,999,999、5,000,000 - 7,499,999、7,500,000 - 9,999,999四組,建立四個執行緒執行,如此讓四核同時各跑250萬次運算,理論上總運算時間就可以大幅減少(理論值是1/4,但會有其他額外的Overhead,並不會真的變成1/4)。
至於應該要拆幾條執行緒,因為CPU在切換多條執行緒時會有Context Switch的損耗,原則上設成一核一條執行緒,應可將損耗壓到最低,但事實是否如此,我們可以實驗看看。
以下的程式是由前文的Log10演算改寫而來,分為前後兩段,前段直接用迴圈計算1-5000萬的Log10,後段則建立WORKER_COUNT條執行緒,平分5000萬次的運算。每次測試時會前後段會各執行三次,以避免實驗誤差。
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace MultiCore
{
class TestAnonymousMethod
{
static void Main(string[] args)
{
//做5000萬次
int MAX_COUNT = 5000 * 10000;
Stopwatch sw = new Stopwatch();
for (int round = 0; round < 3; round++)
{
sw.Reset();
//Console.WriteLine("LOOP: {0:N0} - {1:N0}", 0, MAX_COUNT);
sw.Start();
//直接計算
for (int i = 0; i < MAX_COUNT; i++)
{
double d = Math.Log10(Convert.ToDouble(i));
}
sw.Stop();
Console.WriteLine("循序處理 = {0:N0}ms",
sw.ElapsedMilliseconds);
sw.Reset();
sw.Start();
//控制Thread數
int WORKER_COUNT = 4;
Thread[] workers = new Thread[WORKER_COUNT];
int jobsCountPerWorker = MAX_COUNT / WORKER_COUNT;
for (int i = 0; i < WORKER_COUNT; i++)
{
//將全部工作切成WORKER_COUNT份,
//分給WORKER_COUNT個Thread執行
int st = jobsCountPerWorker * i;
int ed = jobsCountPerWorker * (i + 1);
if (ed > MAX_COUNT) ed = MAX_COUNT;
workers[i] = new Thread(() =>
{
//Console.WriteLine("LOOP: {0:N0} - {1:N0}", st, ed);
for (int j = st; j < ed; j++)
{
double d = Math.Log10(Convert.ToDouble(j));
}
});
workers[i].Start();
}
for (int i = 0; i < WORKER_COUNT; i++)
workers[i].Join();
sw.Stop();
Console.WriteLine("平行處理[{1}] = {0:N0}ms",
sw.ElapsedMilliseconds, WORKER_COUNT);
}
Console.Read();
}
}
}
測試結果為: (測試環境為Windows 7 Hyper VM on Windows 2008 R2,VM啟用了四個CPU)
循序處理 = 2,750ms
平行處理[4] = 1,006ms
循序處理 = 2,762ms
平行處理[4] = 1,121ms
循序處理 = 2,764ms
平行處理[4] = 1,409ms
執行時間縮短為1/2到1/3左右。(數字不知是否受"VM模擬的4 CPU環境"影響,或許在實體機器上效益會更明顯)
接著我們修改程式,讓WORKER_COUNT由1-16,跑十次取平均時間,程式與結果以下:
//做5000萬次
int MAX_COUNT = 5000 * 10000;
Stopwatch sw = new Stopwatch();
for (int WORKER_COUNT = 1; WORKER_COUNT < 16; WORKER_COUNT++)
{=
long sum = 0;
for (int round = 0; round < 50; round++)
{
sw.Reset();
sw.Start();
Thread[] workers = new Thread[WORKER_COUNT];
int jobsCountPerWorker = MAX_COUNT / WORKER_COUNT;
for (int i = 0; i < WORKER_COUNT; i++)
{
//...省略...
workers[i].Start();
}
for (int i = 0; i < WORKER_COUNT; i++)
workers[i].Join();
sw.Stop();
sum += sw.ElapsedMilliseconds;
}
Console.WriteLine("平行處理[{1}] = {0:N0}ms",
sum / 50, WORKER_COUNT);
}
Console.Read();
平行處理[1] = 3,253ms
平行處理[2] = 1,723ms
平行處理[3] = 1,316ms
平行處理[4] = 1,155ms
平行處理[5] = 1,459ms
平行處理[6] = 1,448ms
平行處理[7] = 1,466ms
平行處理[8] = 1,517ms
平行處理[9] = 1,587ms
平行處理[10] = 1,633ms
平行處理[11] = 1,642ms
平行處理[12] = 1,735ms
平行處理[13] = 1,716ms
平行處理[14] = 1,759ms
平行處理[15] = 1,810ms
平行處理[16] = 1,863ms
測試數據證明了我們的假設--針對以運算為主的大量作業在這個以簡單運算為主的大量作業案例中,Thread數與CPU數目相同時可獲得最佳執行效率。
在接下來的文章裡,我們要來看看CLR 4.0幹了什麼好事?