我有一个.Net Core MVC应用程序,可以进行一些中等重度的可计算性计算。我在~250万个条目的列表上运行并行循环。由于它是一个并行循环,我使用并发包来保存结果对象。对于每次迭代,我然后在我的并发包中找到该条目并迭代该值,基本上计算结果发生的次数。以下是正在发生的事情的简要示例:
// results class
public class RandResult
{
public int id { get; set; }
public int val { get; set; }
}
// list of ints I iterate over
var intList = new List<int>();
for(var i = 0; i < 2500000; i++)
{
intList.Add(i);
}
var bagResult = new ConcurrentBag<RandResult>()
{
new RandResult() { id = 0, val = 0 },
new RandResult() { id = 1, val = 0 },
new RandResult() { id = 2, val = 0 },
new RandResult() { id = 3, val = 0 },
new RandResult() { id = 4, val = 0 }
};
watch.Restart();
Parallel.ForEach(intList, i =>
{
bagResult.First(b => b.id == i % 5).val++;
});
timers.Add(watch.ElapsedMilliseconds / 1000.0); // ~1.3 seconds
您可以看到我在代码中放置的计时器有助于评估速度。即使这里使用这个简单的计算,该循环也需要大约1.3秒,几乎完全是由于并发包的开销。鉴于这种相对低效率,我正在寻找替代方案。这是我到目前为止所尝试的:
使用常规List<RandResult>
和锁定:
// takes ~0.6sec
var _lock = new object();
Parallel.ForEach(intList, i =>
{
lock (_lock)
{
listResult.First(b => b.id == i % 5).val++;
}
});
使用Interlock
有点复杂
// takes ~0.2sec
var dict = new Dictionary<int, int>()
{
{ 0, 0 },{ 1, 1 },{ 2, 2 },{ 3, 3 },{ 4, 4 }
};
int[] indexes = new int[5] { 0, 1, 2, 3, 4 };
int[] vals= new int[5] { 0, 0, 0, 0, 0 };
Parallel.ForEach(intList, i =>
{
dict.TryGetValue(i % 5, out int k);
Interlocked.Increment(ref vals[k]);
});
这个更复杂,因为Id值不是连续的int,因此Dictionary用作反向查找。
问题是,还有其他选择吗?
注意:
正在进行的实际计算肯定比i%5
更复杂,但这里真正的问题是关于记录结果以便用于示例。此外,即使在完整的应用程序中,RandResult
的列表/包中也不会有超过10个条目。
奖金问题:我对ConcurrentBag选项比其他所有选项都慢得多感到有点震惊。我知道并行性和并发性总体上涉及相当多的开销,但这似乎过多。有谁知道它为什么这么慢?
答案 0 :(得分:1)
通过切换到ConcurrentDictionary
,您可以使用它的AddOrUpdate
函数来获得有效的查找和线程安全增量。
var dict = new ConcurrentDictionary<int, int>();
Parallel.ForEach(intList, i =>
{
dict.AddOrUpdate(GiveSomeInt(i), 1, (key, value) => value++);
});
第一次尝试访问索引时,它会添加一个新值1
,以后对索引的任何调用都将返回old value + 1
。如果两个线程尝试同时更新该值,则当尝试保存其值的两个更新中较慢的一个然后将1添加到新的更新值时,将重新运行值工厂函数。
如果您想预先初始化字典,也可以
var dict = new ConcurrentDictionary<int, int>()
{
{ 0, 0 },{ 1, 0 },{ 2, 0 },{ 3, 0 },{ 4, 0 }
};
Parallel.ForEach(intList, i =>
{
dict.AddOrUpdate(GiveSomeInt(i), 1, (key, value) => value++);
});
要回答您的红利问题,ConcurrentBag根本没有经过优化而经常被枚举,每当您拨打bagResult.GetEnumerator()
(.First(
在幕后做)时,它必须克隆包并生成冻结时间快照。它经过优化,可以将内容推送到一个项目池中。使用.First(
会影响您的表现。
答案 1 :(得分:0)
不确定为什么要使用并发包。这不像是你要添加或删除项目。而且我认为它不会为你解决任何并行问题 - 包给你的唯一东西是线程安全访问包,而不是线程安全访问包内的RandResult
项。
如果是我,我会使用一个简单的字典,密钥为id
。或者,如果id
始终是顺序整数,请使用数组。那会更快。
至于并发问题 - 您需要做的只是使用Interlocked.Increment而不是val++
。这将为您提供足够的线程安全性来解决此特定问题。您根本不需要同步对bag / list / dictionary / array的访问,因为所有线程仅使用与该对象相关的只读访问权限。根据您的平台,Interlocked.Increment
根本不会产生任何开销,因为在许多情况下增量是自动原子的 - 它们在使用当前CLR的Windows系统上可能是99%自动原子。
var results = new int[5];
var intList = new List<int>();
for(var i = 0; i < 2500000; i++)
{
intList.Add(i);
}
watch.Restart();
Parallel.ForEach(intList, i =>
{
Interlocked.Increment(ref results[i % 5]);
});
timers.Add(watch.ElapsedMilliseconds / 1000.0); // ~1.3 seconds
其他性能说明:由于结果列表中的元素在内存中非常接近,因此可能会导致CPU缓存争用。通常,您的CPU将使用缓存突发将小块内存移动到L1或L2缓存(每个核心单独);在缓存时,将锁定对主内存板上的那些内存位置的访问。因此,如果他们正在处理彼此相距一定距离(“缓存线”)的内存部分,那么基本上所有内核都会相互锁定。这可能导致性能太差,甚至比串行运行算法更慢。此问题称为“虚假共享”。
为避免此问题,您可能希望填充结果列表中的项目,使其足够大,以超过缓存突发大小(取决于CPU)。由于数组只包含10个项目,因此您可以使用128字节的虚拟块来填充它们,而不会产生很多开销。
有关此问题的详情,请参阅this article。