[英]Parallelizing execution with Task.Run
我正在嘗試改進一些代碼的性能,這些代碼執行一些購物功能,調用不同供應商的數量。 第 3 方供應商調用是異步的,並且會處理結果以生成結果。 代碼結構如下。
public async Task<List<ShopResult>> DoShopping(IEnumerable<Vendor> vendors)
{
var res = vendors.Select(async s => await DoShopAndProcessResultAsync(s));
await Task.WhenAll(res); ....
}
由於 DoShopAndProcessResultAsync 既受 IO 限制又受 CPU 限制,並且每個供應商迭代都是獨立的,我認為 Task.Run 可用於執行以下操作。
public async Task<List<ShopResult>> DoShopping(IEnumerable<Vendor> vendors)
{
var res = vendors.Select(s => Task.Run(() => DoShopAndProcessResultAsync(s)));
await Task.WhenAll(res); ...
}
使用 Task.Run 可以獲得性能提升,我可以從調用的執行順序看到這里涉及多個線程。 它在我的機器上本地運行沒有任何問題。 然而,這是一個任務場景的任務,並且想知道在高流量生產環境中是否存在任何陷阱或這是否容易出現死鎖。
您對使用 Task.Run 並行化異步調用的方法有何看法?
您的問題中的Task.Run
方法令人擔憂的是,它以非受控方式耗盡了可用工作線程中的ThreadPool
。 它不提供任何允許您減少每個單獨請求的並行性的配置選項,有利於保持整個服務的可伸縮性。 從長遠來看,這可能會咬你一口。
理想情況下,您希望同時控制並行性和並發性,並獨立控制它們。 例如,您可能希望將 I/O-bound 工作的最大並發限制為 10,並將 CPU-bound 工作的最大並行度限制為 2。關於前者,您可以看看這個問題: 如何限制並發異步 I/O 操作的數量?
關於后者,您可以使用具有有限並發性的TaskScheduler
。 ConcurrentExclusiveSchedulerPair
是用於此目的的一個方便的類。 這是一個示例,說明如何重寫DoShopping
方法,以將ThreadPool
的使用限制為最多兩個線程(每個請求),而不限制 I/O 綁定工作的所有並發性:
public async Task<ShopResult[]> DoShopping(IEnumerable<Vendor> vendors)
{
var scheduler = new ConcurrentExclusiveSchedulerPair(
TaskScheduler.Default, maxConcurrencyLevel: 2).ConcurrentScheduler;
var tasks = vendors.Select(vendor =>
{
return Task.Factory.StartNew(() => DoShopAndProcessResultAsync(vendor),
default, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();
});
return await Task.WhenAll(tasks);
}
重要提示:為了使其工作, DoShopAndProcessResultAsync
方法應在內部實現,而無需在await
點使用 .ConfigureAwait .ConfigureAwait(false)
。 否則await
之后的延續將不會在我們首選的scheduler
上運行,並且限制ThreadPool
利用率的目標將失敗。
不過,我個人的偏好是使用新的 (.NET 6) Parallel.ForEachAsync
API。 除了通過MaxDegreeOfParallelism
選項輕松控制並發性之外,它還具有更好的異常情況下的行為。 它不會總是啟動所有異步操作,而是在先前啟動的操作失敗后立即停止啟動新操作。 這可能會對服務的響應能力產生很大影響,例如,如果所有單獨的異步操作都因超時異常而失敗。 您可以在此處找到Parallel.ForEachAsync
和Task.WhenAll
API 之間主要區別的概要。
不幸的是, Parallel.ForEachAsync
的缺點是它不返回異步操作的結果。 這意味着您必須手動收集結果作為每個異步操作的副作用。 我在這里發布了一個返回結果的ForEachAsync
變體,它結合了Parallel.ForEachAsync
和Task.WhenAll
API 的最佳方面。 你可以像這樣使用它:
public async Task<ShopResult[]> DoShopping(IEnumerable<Vendor> vendors)
{
var scheduler = new ConcurrentExclusiveSchedulerPair(
TaskScheduler.Default, maxConcurrencyLevel: 2).ConcurrentScheduler;
ParallelOptions options = new() { MaxDegreeOfParallelism = 10 };
return await ForEachAsync(vendors, options, async (vendor, ct) =>
{
return await Task.Factory.StartNew(() => DoShopAndProcessResultAsync(vendor),
ct, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();
});
}
注意:在我最初的答案(修訂版 1 )中,我錯誤地建議通過ParallelOptions.TaskScheduler
屬性傳遞scheduler
。 我剛剛發現這並不像我預期的那樣工作。 ParallelOptions
類有一個內部屬性EffectiveMaxConcurrencyLevel
,它表示MaxDegreeOfParallelism
和TaskScheduler.MaximumConcurrencyLevel
的最小值。 Parallel.ForEachAsync
方法的實現使用此屬性,而不是直接讀取MaxDegreeOfParallelism
。 所以MaxDegreeOfParallelism
比MaximumConcurrencyLevel
大,實際上被忽略了。
您現在可能還注意到這兩個設置的名稱令人困惑。 我們使用MaximumConcurrencyLevel
來控制線程的數量(也就是並行化),我們使用MaxDegreeOfParallelism
來控制並發異步操作的數量(也就是並發)。 這種令人困惑的術語的原因可以追溯到這些 API 的歷史淵源。 ParallelOptions
類是在 async-await 時代之前引入的,新的Parallel.ForEachAsync
API 的設計者旨在使其與Parallel
類的舊非異步成員兼容。
任務是 .NET 的低級構建塊。 .NET 幾乎總是對特定並發范例具有更好的高級抽象。
套用Rob Pike (幻燈片) Concurrency is not parallelism is not asynchronous execution
。 你問的是並發執行,具有特定的並行度。 NET 已經提供了可以做到這一點的高級類,而無需求助於低級任務處理。
最后,我解釋了為什么這些區別很重要以及如何使用不同的 .NET 類或庫來實現它們
數據流塊
在最高級別,Dataflow 類允許創建類似於 Powershell 或 Bash 管道的處理塊管道,其中每個塊可以使用一個或多個任務來處理輸入。 數據流塊保留消息順序,確保按照接收輸入消息的順序發出結果。
您經常會看到稱為網格的塊組合,而不是管道。 Dataflow 源自 Microsoft Robotics Framework,可用於創建獨立處理塊的網絡。 大多數程序員只是用來構建一個步驟管道。
在您的情況下,您可以使用TransformBlock
執行DoShopAndProcessResultAsync
並將輸出提供給另一個處理塊,或者在處理所有結果后可以讀取的 BufferBlock 。 您甚至可以將 Shop 和 Process 拆分為單獨的塊,每個塊都有自己的邏輯和並行度
例如。
var buffer=new BufferBlock<ShopResult>();
var blockOptions=new ExecutionDataflowBlockOptions {
MaxDegreeOfParallelism=3,
BoundedCapacity=1
};
var shop=new TransformBlock<Vendor,ShopResult)(DoShopAndProcessResultAsync,
blockOptions);
var linkOptions=new DataflowLinkOptions{ PropagateCompletion=true;}
shop.LinkTo(buffer,linkOptions);
foreach(var v in vendors)
{
await shop.SendAsync(v);
}
shop.Complete();
await shop.Completion;
buffer.TryReceiveAll(out IList<ShopResult> results);
您可以使用兩個單獨的塊來購物和處理:
var shop=new TransformBlock<Vendor,ShopResponse>(DoShopAsync,shopOptions);
var process=new TransformBlock<ShopResponse,ShopResult>(DoProcessAsync,processOptions);
shop.LinkTo(process,linkOptions);
process.LinkTo(results,linkOptions);
foreach(var v in vendors)
{
await shop.SendAsync(v);
}
shop.Complete();
await process.Completion;
在這種情況下,我們在讀取結果之前等待鏈中最后一個塊的完成。
我們可以在最后使用 ActionBlock 來對結果做任何我們想做的事情,而不是從緩沖區塊中讀取,例如將它們存儲到數據庫中。 可以使用 BatchBlock 對結果進行批處理,以減少存儲操作的數量
...
var batch=new BatchBlock<ShopResult>(100);
var store=new ActionBlock<ShopResult[]>(DoStoreAsync);
shop.LinkTo(process,linkOptions);
process.LinkTo(batch,linkOptions);
batch.LinkTo(store,linkOptions);
...
shop.Complete();
await store.Completion;
為什么名字很重要
任務是用於實現多個范例的最低級別的構建塊。 在其他語言中,您會看到它們被描述為 Futures 或 Promises(例如 Javascript)
.NET 中的並行性意味着使用所有可用內核對大量數據執行 CPU 密集型計算。 Parallel.ForEach
會將輸入數據划分為與內核數量大致相同的分區,並且每個分區使用一個工作任務。 PLINQ 更進一步,允許使用 LINQ 運算符來指定計算,並讓 PLINQ 使用針對並行執行優化的算法來映射、過濾、排序、分組和收集結果。 這就是為什么Parallel.ForEach
根本不能用於異步工作的原因。
並發意味着執行多個獨立且通常是 IO 綁定的作業。 在最低級別,您可以使用任務,但 Dataflow、Rx.NET、Channels、IAsyncEnumerable 等允許使用高級模式,如 CSP/管道、事件流處理等
異步執行意味着您不必在等待 I/O 綁定工作完成時進行阻塞。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.