HI,前幾天被.NET圈紀檢委@懶得勤快問到共享記憶體Actor併發模型哪個速度更快。

前文傳送門:

說實在,我內心10w頭羊駝跑過......

先說結論

  1. 首先兩者對於併發的風格模型不一樣。

共享記憶體利用多核CPU的優勢,使用強一致的鎖機制控制併發, 各種鎖交織,稍不注意可能出現死鎖,更適合熟手。

Actor模型易於控制和管理,以訊息觸發,流水線挨個處理, 思路清晰。

  1. 真要說效能,求100000 以內的素數的個數]場景 & 我電腦8c 16g的配置, 我根據這個示例拍腦袋對比。。。。。
  • 2.1 理論上如果以預設的Actor併發模型來做這個事情,Actor的效能是遜於共享記憶體模型的;

  • 2.2 上文中我對於Actor做了多執行緒優化,效能慢慢追上來了。

預設Actor模型

計算[100_000內素數的個數], 分為兩步:

(1) 迭代判斷當前數字是不是素數

(2) 如果是素數,執行sum++

共享記憶體完成以上兩步, 均能充分利用CPU多核心。

Actor模型:與TPL中的原語不同,TPL datflow中的所有塊預設是單執行緒的,這就意味著完成以上兩步的TransfromBlockActionBlock都是以一個執行緒挨個處理訊息資料(這也是Dataflow的設計初衷,形成清晰單純的流水線)。

猜測起來也是共享記憶體相比預設的Actor模型更具優勢。

使用NUnit做單元測試,資料量從小到大: 10_000,50_000,100_000,200_000,300_000,500_000

using NUnit.Framework;
using System;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks.Dataflow; namespace TestProject2
{
public class Tests
{
[TestCase(10_000)]
[TestCase(50_000)]
[TestCase(100_000)]
[TestCase(200_000)]
[TestCase(300_000)]
[TestCase(500_000)]
public void ShareMemory(int num)
{
var sum = 0;
Parallel.For(1, num + 1, (x, state) =>
{
var f = true;
if (x == 1)
f = false;
for (int i = 2; i <= x / 2; i++)
{
if (x % i == 0) // 被[2,x/2]任一數字整除,就不是質數
f = false;
}
if (f == true)
{
Interlocked.Increment(ref sum);// 共享了sum物件,“++”就是呼叫sum物件的成員方法
}
});
Console.WriteLine($"1-{num}內質數的個數是{sum}");
} [TestCase(10_000)]
[TestCase(50_000)]
[TestCase(100_000)]
[TestCase(200_000)]
[TestCase(300_000)]
[TestCase(500_000)]
public async Task Actor(int num)
{
var linkOptions = new DataflowLinkOptions { PropagateCompletion = true };
var bufferBlock = new BufferBlock<int>();
var transfromBlock = new TransformBlock<int, bool>(x =>
{
var f = true;
if (x == 1)
f = false;
for (int i = 2; i <= x / 2; i++)
{
if (x % i == 0) // 被[2,x/2]任一數字整除,就不是質數
f = false;
}
return f;
}, new ExecutionDataflowBlockOptions { EnsureOrdered = false }); var sum = 0;
var actionBlock = new ActionBlock<bool>(x =>
{
if (x == true)
sum++;
}, new ExecutionDataflowBlockOptions { EnsureOrdered = false });
transfromBlock.LinkTo(actionBlock, linkOptions);
// 準備從pipeline頭部開始投遞
try
{
var list = new List<int> { };
for (int i = 1; i <= num; i++)
{
var b = await transfromBlock.SendAsync(i);
if (b == false)
{
list.Add(i);
}
}
if (list.Count > 0)
{
Console.WriteLine($"md,num post failure,num:{list.Count},post again");
// 再投一次
foreach (var item in list)
{
transfromBlock.Post(item);
}
}
transfromBlock.Complete(); // 通知頭部,不再投遞了; 會將資訊傳遞到下游。
actionBlock.Completion.Wait(); // 等待尾部執行完
Console.WriteLine($"1-{num} Prime number include {sum}");
}
catch (Exception ex)
{
Console.WriteLine($"1-{num} cause exception.",ex);
}
}
}
}

測試結果如下:

測試結果印證我說的結論2.1

優化後的Actor模型

那後面我對Actor做了什麼優化呢?能產生下圖的結論。

請重新回看《三分鐘掌握》 TransformBlock塊的細節:

var transfromBlock = new TransformBlock<int, bool>(x =>
{
var f = true;
if (x == 1)
f = false;
for (int i = 2; i <= x / 2; i++)
{
if (x % i == 0) // 被[2,x/2]任一數字整除,就不是質數
f = false;
}
return f;
}, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism=50, EnsureOrdered = false });

上面說到預設的Actor是單執行緒處理輸入的訊息, 此時我們設定了MaxDegreeOfParallelism引數,引數能在Actor中開啟多執行緒併發執行,但是這裡面就不能有共享變數(否則你又得加鎖),恰好我們完成 (1) 迭代判斷當前數字是不是素數這一步並不依賴共享物件,所以這一步效能與共享記憶體模型基本沒差別。

那為什麼總體效能慢慢超過共享記憶體?

這是因為執行第二步(2) 如果是素數,執行sum++, 共享記憶體要加解鎖,執行緒上下文切換,而Actor單執行緒挨個處理, 總體就略勝共享記憶體模型了。

這裡再次強調,Actor模型執行第二步(2) 如果是素數,執行sum++,不可開啟MaxDegreeOfParallelism,因為依賴了共享變數sum

結束語

請大家仔細對比結論和上圖,脫離場景和硬體環境談效能就是耍流氓,理解不同併發模型的風格和能力是關鍵,本文僅針對這個示例拍腦袋對比。

實際要針對場景和未來的拓展性、可維護性、可操作性做技術選型 。

That's All, 感謝.NET圈紀檢委@懶得勤快促使我重溫了單元測試的寫法 & 深度分析Actor模型。