深入瞭解C#(TPL)之Parallel.ForEach非同步

Jeffcky發表於2020-06-20

前言

最近在做專案過程中使用到了如題並行方法,當時還是有點猶豫不決,因為平常使用不多, 於是藉助週末時間稍微深入了下,發現我用錯了,故此做一詳細記錄,希望對也不是很瞭解的童鞋在看到此文後不要再犯和我同樣的錯誤。

並行遍歷非同步表象

這裡我們就不再講解該語法的作用以及和正常遍歷處理的區別,網上文章比比皆是,我們直接進入主題,本文所演示程式在控制檯中進行。可能大部分童鞋都是如下大概這樣用的

Parallel.ForEach(Enumerable.Range(0, 10), index =>
{
    Console.WriteLine(index);
});

 

我們採取並行方式遍歷10個元素,然後結果也隨機列印出10個元素,一點毛病也沒有。然而我是用的非同步方式,如下:

Parallel.ForEach(Enumerable.Range(0, 10), async index =>
{
    await AsyncTask(index);
});
static async Task<int> AsyncTask(int i)
{
    await Task.Delay(100);
    
    var calculate = i * 2;
    
    Console.WriteLine(calculate);

    return calculate;
}

我們只是將並行操作更改為了非同步形式,然後對每個元素進行對應處理,列印無序結果,一切也是如我們所期望,接下來我再來看一個例子,經過並行非同步處理後猜猜最終字典中元素個數可能或一定為多少呢?

var dicts = new ConcurrentDictionary<string, int>();

Parallel.ForEach(Enumerable.Range(0, 10), async index =>
{
    var result = await AsyncTask(index);

    dicts.TryAdd(index.ToString(), result);
});

Console.WriteLine($"element count in dictionary {dicts.Count}");

 

如果對該並行方法沒有深入瞭解的話,大概率都會猜錯,我們看到字典中元素為0,主要原因是用了非同步後引起的,為何會這樣呢?我們首先從表象上來分析,當我們在控制檯上對並行方法用了非同步後,你會發現編譯器會告警(主函式入口已用非同步標識),如下:

接下來我們再來看看呼叫該並行非同步方法的最終呼叫構造,如下:

public static ParallelLoopResult ForEach<TSource>(IEnumerable<TSource> source, Action<TSource> body);

第二個引數為內建委託Action,所以我們也可以看出並不能用於非同步,因為要是非同步至少也是Func<Task>,比如如下方法引數形式

static async Task AsyncDemo(Func<int,Task> func)
{
    await func(1);
}

並行遍歷非同步本質

通過如上表象的分析我們得出並行遍歷方法應該是並不支援非同步(通過最終結果分析得知,表述為不能用於非同步更恰當),但是在實際專案開發中我們若沒有注意到該方法的構造很容易就會誤以為支援非同步,如我一樣寫完也沒報錯,也就草草了事。那麼接下來我們反編譯看下最終實際情況會是怎樣的呢。

進入主函式,我們已將主函式進行非同步標識,所以將主函式放在狀態機中執行(狀態機類,<Main>d_0),這點我們毫無保留的贊同,接下來例項化字典,並通過並行遍歷非同步處理元素集合並將其結果嘗試放入到字典中

由上我們可以看到主函式是在狀態機中執行且構造為AsyncTaskMethodBuilder,當我們通過並行遍歷非同步處理時每次都會例項化一個狀態機類即如上<<Main>b__0>d,但我們發現此狀態機的構造是AsyncVoidMethodBuilder,利用此狀態機類來非同步處理每一個元素,如下

最終呼叫AsyncTask非同步方法,這裡我就不再截圖,同樣也是生成一個此非同步方法的狀態機類。稍加分析想必我們已經知曉結果,AsyncTaskMethodBuilder指的就是(async task),而AsyncVoidMethodBuilder指的是(async void),所以對並行遍歷非同步操作是將其隱式轉換為async void,而不是async task,這也和我們從其構造為Action得出的結論一致,我們知道(async void)僅限於基於事件的處理程式(常見於客戶端應用程式),其他情況避免用async void,也就是說將返回值放在Task或Task<T>中。當並行執行任務時,由於返回值為void,不會等待操作完成,這也就不難解釋為何字典中元素個數為0。

總結

當時並沒有過多的去了解,只是想當然的認為用了非同步也沒出現編譯報錯,但是又由於沒怎麼用過,我還是抱著懷疑的態度,於是再深究了下,發現用法是大錯特錯。通過構造僅接受為Action委託,這也就意味著根本無法等待非同步操作完成,之所以能接受非同步索引其本質是隱式轉換為(async void),從另外一個角度看,非同步主要用於IO密集型,而並行處理用於CPU密集型計算,基於此上種種一定不能用於非同步,否則結果你懂的。

相關文章