在yield return函数中,是否可以确保在同一个线程上调用终结器



我的一些代码中出现了一个棘手的问题。我有一个缓存管理器,它要么从缓存中返回项目,要么调用委托来创建它们(开销很大)。

我发现我的方法的finalize部分在与其他线程不同的线程上运行时遇到了问题

这是一个精简版

public IEnumerable<Tuple<string, T>> CacheGetBatchT<T>(IEnumerable<string> ids, BatchFuncT<T> factory_fn) where T : class
{
Dictionary<string, LockPoolItem> missing = new Dictionary<string, LockPoolItem>();
try
{
foreach (string id in ids.Distinct())
{
LockPoolItem lk = AcquireLock(id);
T item;
item = (T)resCache.GetData(id); // try and get from cache
if (item != null)
{
ReleaseLock(lk);
yield return new Tuple<string, T>(id, item);
}
else
missing.Add(id, lk);                    
}
foreach (Tuple<string, T> i in factory_fn(missing.Keys.ToList()))
{
resCache.Add(i.Item1, i.Item2);
yield return i;
}
yield break;                        // why is this needed?
}
finally
{
foreach (string s in missing.Keys)
{
ReleaseLock(l);
}
}
}

获取和释放锁定使用Monitor锁定的LockPoolItem对象填充字典。输入/监视器。退出[我也尝试过互斥]。当ReleaseLock在不同于AcquireLock的线程上调用时,问题就来了。

当从另一个使用线程的函数调用此函数时,问题就出现了——有时会调用finalize块,这是由于处理了在返回的迭代中运行的IEnumerator。

下面的块是一个简单的示例。

BlockingCollection<Tuple<Guid, int>> c = new BlockingCollection<Tuple<Guid,int>>();
using (IEnumerator<Tuple<Guid, int>> iter = global.NarrowItemResultRepository.Narrow_GetCount_Batch(userData.NarrowItems, dicId2Nar.Values).GetEnumerator()) {
Task.Factory.StartNew(() => {
while (iter.MoveNext()) {
c.Add(iter.Current);
}
c.CompleteAdding();
});
}

当我添加yield break时,这种情况似乎不会发生——然而,我发现这种情况很难调试,因为它只是偶尔发生。然而,它确实发生了——我已经尝试记录线程ID,如果在不同的线程上被调用,则最终确定。。。

我确信这不可能是正确的行为:我不明白为什么dispose方法(即exit-using)会在不同的线程上被调用。

有什么防范措施吗?

这里似乎有一场比赛。

看起来您的调用代码创建了枚举器,然后在线程池上启动一个任务以通过它进行枚举,然后处理该枚举器。我最初的想法:

  • 如果枚举器在枚举开始之前被释放,则不会发生任何事情。从一个简短的测试来看,这并不能阻止在它被处理后的枚举。

  • 如果枚举器在枚举时被释放,finally块将被调用(在调用线程上),枚举将停止。

  • 如果枚举是由任务操作完成的,那么finally块将被调用(在线程池线程上)。

要尝试演示,请考虑以下方法:

private static IEnumerable<int> Items()
{            
try
{
Console.WriteLine("Before 0");
yield return 0;
Console.WriteLine("Before 1");
yield return 1;
Console.WriteLine("After 1");
}
finally 
{
Console.WriteLine("Finally");
}
}

如果在枚举之前进行处置,则不会向控制台写入任何内容。我怀疑您大部分时间都会这样做,因为当前线程在任务开始前到达using块的末尾:

var enumerator = Items().GetEnumerator();
enumerator.Dispose();    

如果枚举在Dispose之前完成,则对MoveNext的最后调用将调用finally块。

var enumerator = Items().GetEnumerator();
enumerator.MoveNext();
enumerator.MoveNext();
enumerator.MoveNext();

结果:

"Before 0"
"Before 1"
"After 1"
"Finally"

如果在枚举时进行处置,则对Dispose的调用将调用finally块:

var enumerator = Items().GetEnumerator();
enumerator.MoveNext();
enumerator.Dispose();

结果:

"Before 0"
"Finally"

我建议您在同一个线程上创建、枚举和处理枚举器。

感谢所有的回复,我意识到发生了什么以及为什么。那时我的问题很容易解决。我只需要确保所有东西都在同一个线程上调用。

BlockingCollection<Tuple<Guid, int>> c = new BlockingCollection<Tuple<Guid,int>>();
Task.Factory.StartNew(() => {
using (IEnumerator<Tuple<Guid, int>> iter = global.NarrowItemResultRepository.Narrow_GetCount_Batch(userData.NarrowItems, dicId2Nar.Values).GetEnumerator()) {
while (iter.MoveNext()) {
c.Add(iter.Current);
}
c.CompleteAdding();
}
});

术语"终结器"与一个与"最终"块完全无关的概念有关;终结器的线程上下文没有任何保证,但我认为您实际上对"finally"块感兴趣。

yield return包围的finally块将由迭代器的枚举器上调用Dispose的任何线程执行。枚举器通常有权假设对它们执行的所有操作,包括Dispose,都将由创建它们的同一线程完成,并且通常没有义务以任何类似于合理方式的方式进行操作,即使在不这样做的情况下也是如此。系统不会阻止代码在多个线程上使用枚举器,但如果程序从多个线程使用枚举器(该枚举器不承诺在这方面工作),则意味着由此产生的任何后果都不是枚举器的错,而是非法使用它的程序的错。

一般来说,类最好包括足够的保护,防止无效的多线程,以确保不适当的多线程使用不会导致安全漏洞,但不必担心防止任何其他类型的伤害或混乱。

最新更新