.NET中測量多執行緒基準效能

2023-09-12 12:00:36

多執行緒基準效能是用來衡量計算機系統或應用程式在多執行緒環境下的執行能力和效能的度量指標。它通常用來評估系統在並行處理任務時的效率和效能。測量中通常建立多個執行緒並在這些執行緒上執行並行任務,以模擬實際應用程式的並行處理需求。

在此,我們用多個執行緒來完成一個計數任務,簡單地測量系統的多執行緒基準效能,以下的5種測量程式碼(程式碼1,程式碼4,程式碼5,程式碼6,程式碼7)中,都設定了計數器,每一秒計數器的計數量體現了系統的效能。通過對比這些測量方法,可以直觀地理解多執行緒、如何通過多執行緒充分利用系統效能,以及執行多執行緒可能存在的瓶頸。

測量方法

先用一個多執行緒的共用變數自增例子來做多執行緒基準效能測量:

//程式碼1:簡單的多執行緒測量多執行緒基準效能
long totalCount = 0;
int threadCount = Environment.ProcessorCount;

Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
    tasks[i] = Task.Run(DoWork);
}
while (true)
{
    long t = totalCount;
    Thread.Sleep(1000);
    Console.WriteLine($"{totalCount - t:N0}");
}
void DoWork()
{
    while (true)
    {
        totalCount++;
    }
}

//結果
48,493,031
48,572,321
47,788,843
48,128,734
50,461,679
……

因為在多執行緒環境中,執行緒之間的切換會導致一些開銷,例如儲存和恢復執行緒上下文的時間。如果上下文切換頻繁發生,可能會對效能測試結果產生影響,因此上面的程式碼根據系統的CPU核心數設定啟動測試執行緒的執行緒數量,這些執行緒對一個共用的變數進行自增操作。

有多執行緒程式設計經驗的人不難看出,上面的程式碼沒有正確地保護共用資源,會出現競態條件。這可能導致資料不一致,操作順序不確定,或者無法重現一致的效能結果。我們將用程式碼展示這種情況。

//程式碼2:展示出競態條件的程式碼
long totalCount = 0;
int threadCount = Environment.ProcessorCount;

Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
    tasks[i] = Task.Run(DoWork);
}
void DoWork()
{
    while (true)
    {
        totalCount++;
        Console.Write($"{totalCount}"+",");
    }
}
//結果
1,9,10,3,12,13,4,14,15,16……270035,269913,270037,270038,270036,270040,269987,270042,270043……

程式碼2的執行結果可以看到,由於被不同執行緒操作,這些執行緒同時存取和修改totalCount的值,列印出來的totalCount不是順序遞增的。

可見,程式碼1沒有執行緒同步機制,我們不能準確測量多執行緒基準效能。
C#中執行緒的同步方式,比如傳統的鎖機制(如lock語句、Monitor類、Mutex類、Semaphore類等)通常使用互斥機制來保護共用資源,以確保同一時間只有一個執行緒可以存取資源,避免競爭條件。這些鎖機制會在程式碼塊被鎖定期間阻塞其他執行緒的存取,使得同一時間只有一個執行緒可以執行被鎖定的程式碼。
這裡使用lock鎖作為執行緒同步機制,修正上面的程式碼,對共用的變數進行保護,避免共用變數同時被多個執行緒修改。

//程式碼3:使用lock鎖
long totalCount = 0;
int threadCount = Environment.ProcessorCount;
object totalCountLock = new object();

Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
    tasks[i] = Task.Run(DoWork);
}

void DoWork()
{
    while (true)
    {
        lock (totalCountLock)
        {
            totalCount++;
            Console.Write($"{totalCount}"+",");
        }
    }
}

//結果
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30……

這時的結果就是順序輸出。

我們用含lock的程式碼來測量多執行緒基準效能:

//程式碼4:運用含lock鎖的程式碼測量多執行緒基準效能
long totalCount = 0;
int threadCount = Environment.ProcessorCount;
object totalCountLock = new object();

Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
    tasks[i] = Task.Run(DoWork);
}
while (true)
{
    long t = totalCount;
    Thread.Sleep(1000);
    Console.WriteLine($"{totalCount - t:N0}");
}
void DoWork()
{
    while (true)
    {
        lock (totalCountLock)
        {
            totalCount++;
        }
    }
}

//結果
16,593,517
16,694,824
16,514,421
16,517,431
16,652,867
……

保證多執行緒環境下執行緒安全性,還有一種方式是使用原子操作Interlocked。與傳統的鎖機制(如lock語句等)不同,Interlocked類提供了一些特殊的原子操作,如Increment、Decrement、Exchange、CompareExchange等,用於對共用變數進行原子操作。這些原子操作是直接在CPU指令級別上執行的,而不需要使用傳統的阻塞和互斥機制。它通過硬體級別的操作,確保對共用變數的操作是原子性的,避免了競爭條件和資料不一致的問題。
它更適合用於特定的原子操作,而不是用作通用的執行緒同步機制。

//程式碼5:運用原子操作的程式碼測量多執行緒基準效能
long totalCount = 0;
int threadCount = Environment.ProcessorCount;

Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
    tasks[i] = Task.Run(DoWork);
}

while (true)
{
    long t = totalCount;
    Thread.Sleep(1000);
    Console.WriteLine($"{totalCount - t:N0}");
}

void DoWork()
{
    while (true)
    {
        Interlocked.Increment(ref totalCount);
    }
}
//結果
37,230,208
43,163,444
43,147,585
43,051,419
42,532,695
……

除了使用互斥鎖、原子操作,我們也可以設法對多個執行緒進行資料隔離。ThreadLocal類提供了執行緒本地儲存功能,用於在多執行緒環境下的資料隔離。每個執行緒都會有自己獨立的資料副本,被儲存在ThreadLocal範例中,每個ThreadLocal可以被對應執行緒存取到。

//程式碼6:運用含ThreadLocal的程式碼測量多執行緒基準效能
int threadCount = Environment.ProcessorCount;

Task[] tasks = new Task[threadCount];
ThreadLocal<long> count = new ThreadLocal<long>(trackAllValues: true);

for (int i = 0; i < threadCount; ++i)
{
    int threadId = i;
    tasks[i] = Task.Run(() => DoWork(threadId));
}

while (true)
{
    long old = count.Values.Sum();
    Thread.Sleep(1000);
    Console.WriteLine($"{count.Values.Sum() - old:N0}");
}

void DoWork(int threadId)
{
    while (true)
    {
        count.Value++;
    }
}

//結果
177,851,600
280,076,173
296,359,986
296,140,821
295,956,535
……

上面的程式碼使用了ThreadLocal類,我們也可以自定義一個類,給每個執行緒建立一個物件作為上下文,程式碼如下:

//程式碼7:運用含自定義上下文的程式碼測量多執行緒基準效能
int threadCount = Environment.ProcessorCount;

Task[] tasks = new Task[threadCount];
Context[] ctxs = new Context[threadCount];

for (int i = 0; i < threadCount; ++i)
{
    int threadId = i;
    ctxs[i] = new Context();
    tasks[i] = Task.Run(() => DoWork(threadId));
}

while (true)
{
    long old = ctxs.Sum(v => v.TotalCount);
    Thread.Sleep(1000);
    Console.WriteLine($"{ctxs.Sum(v => v.TotalCount) - old:N0}");
}

void DoWork(int threadId)
{
    while (true)
    {
        ctxs[threadId].TotalCount++;
    }
}

class Context
{
    public long TotalCount = 0;
}

//結果:
1,067,502,570
1,100,966,648
1,145,726,019
1,110,163,963
1,069,322,606
……

系統設定

元件 規格
CPU 11th Gen Intel(R) Core(TM) i5-11300H
記憶體 16 GB DDR4
作業系統 Microsoft Windows 10 家庭中文版
電源選項 已設定為高效能
軟體 LINQPad 7.8.5 Beta
執行時 .NET 7.0.10

測量結果

測量方法 1秒計數 效能百分比
未做執行緒同步 50,461,679 118.6%
lock鎖 16,652,867 39.2%
原子操作(Interlocked) 42,532,695 100%
ThreadLocal 295,956,535 695.8%
自定義上下文(Context) 1,069,322,606 2514.1%

結果分析

未作執行緒同步測量到的結果是不準確的,不能作為依據。

根據程式執行的結果可以看到,使用傳統的lock鎖機制,效率不高。使用原子操作Interlocked,效率比傳統鎖要高近2倍。
而實現了執行緒間隔離的2種方法,效率都比前面的方法要高。使用自定義上下文的程式效率是最高的。

執行緒間隔離的兩種程式碼,它們主要區別在於執行緒安全性的實現方式。程式碼6使用ThreadLocal 類來實現,而程式碼7使用了自定義的上下文,用一個陣列來為每個執行緒提供一個唯一的上下文。程式碼6使用的是執行緒本地儲存(Thread Local Storage,TLS)來實現其功能。它是一種全域性變數,可以被正在執行的所有執行緒存取,但每個執行緒所看到的值都是私有的。雖然這個特性使ThreadLocal在多執行緒程式設計中變得非常有用,但為了實現這個特性,它在內部實現了一套複雜的機制,比如它會建立一個弱參照的雜湊表來儲存每個執行緒的資料。這個內部實現細節增加了相應的計算和存取開銷。

對於程式碼7,它建立了一個名為Context的類陣列,每個執行緒都有其自己的Context物件,並在執行過程中修改這個物件。由於每個執行緒自身管理其Context物件,不存在任何執行緒間衝突,這就減少了許多額外的開銷。

因此,雖然程式碼6程式碼7都實現了執行緒資料隔離,但程式碼7避開了ThreadLocal的額外開銷,因此在效能上表現得更好。

結論

如果能實現執行緒間的隔離,可以大幅提高多執行緒程式碼效率,測量出系統的最大效能值。

作者:百寶門-後端組-周智

原文地址:https://blog.baibaomen.com/120-2/