【问题标题】:In a yield return function is it possible to ensure that the finalizer is called on the same thread?在 yield return 函数中是否可以确保在同一个线程上调用终结器?
【发布时间】:2015-07-02 21:35:59
【问题描述】:

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

我发现我的方法的 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.Enter / Monitor.Exit 锁定的 LockPoolItem 对象填充字典 [我也尝试过互斥锁]。当在与调用 AcquireLock 的线程不同的线程上调用 ReleaseLock 时,问题就出现了。

从另一个使用线程的函数调用 this 时会出现问题,有时会调用 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 方法(即退出使用)会在不同的线程上被调用。

任何想法如何防止这种情况?

【问题讨论】:

  • 我建议您在屈服点持有锁的任何设计都是一个损坏的设计 - 您不知道在您的调用者下一次调用 MoveNext 之前需要多长时间或者,事实上,正如您所发现的,Dispose。在不了解您的具体问题的情况下,很难提供具体的建议,但这就是我要寻找的地方 - 更改设计,以便您在释放锁时不受调用者的摆布。
  • 这是一个公平的观点,但它并没有回答这个问题。我想要实现的是让提供者在从慢速存储中检索项目时返回项目 - 有些项目可能需要几秒钟,有些可能需要几毫秒,但没有办法事先知道批次中的哪些项目会很慢返回。我怀疑我最好将阻塞集合提供给缓存功能并在那里填充。但是我仍然不明白为什么 dispose/finalize 在不同的线程上被调用。
  • 不,它没有,因此它作为评论发布。如果您愿意进行此类更改,我可以付出一些努力并向您展示替代方案的外观。如果您查看我在 supercat 的答案下面的讨论,您会发现我已经争论了好几天,不能保证枚举将在同一个线程上恢复(特别是如果调用代码使用现代功能,例如 @ 987654325@)
  • 谢谢 Damien,我有很多关于如何改进这一点的想法,我只是无法弄清楚为什么 Dispose/finalize 在位于 using 块中时在不同的线程上被调用。我现在要装箱并重新启动我的缓存模型。

标签: c# thread-safety dispose yield-return finalize


【解决方案1】:

这里似乎有一场比赛。

看起来你的调用代码创建了枚举器,然后在线程池上启动一个任务来枚举它,然后释放枚举器。我最初的想法:

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

  • 如果枚举器在枚举时被释放,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"

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

【讨论】:

  • 但我认为应该在“使用”块的末尾调用 Dispose - 在外线程上。你是说 .MoveNext() 可以隐式调用 Dispose 吗?我假设 Dispose 是在 using 块的关闭 } 所在的位置调用的,即在原始外线程上。
  • Dispose 将在using 块的末尾被调用,问题是执行可能会在Task 中的代码甚至开始执行之前到达那个点。 MoveNext 可以调用 finally 块,因为没有其他事情可做,否则 Dispose 调用将在枚举完成之前调用。
  • @dominicbeesley 我已经更新了答案,希望能够澄清。
  • 谢谢你,Charles,现在说得通了——我不敢相信我没有明白这一点,但现在有人指出它是唯一可行的方法——呵呵!我试图给你投票,但我的代表太低了。
【解决方案2】:

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

        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();
            }
        });

【讨论】:

    【解决方案3】:

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

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

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

    【讨论】:

    • 我会在这里质疑你的假设。您是否有任何具体的指导方针或文档来支持它们?为什么你断言允许枚举器从单个线程进行访问?
    • 我在这里想的是,具体来说,async 方法 - 不能保证(通常)该方法中的所有代码都将在同一个线程上执行,但我不知道有任何指南说“不要在异步方法中使用foreach
    • @Damien_The_Unbeliever:据我了解,完全在异步方法中使用 foreach 会导致从未知线程上下文调用 GetEnumerator,但会导致对该枚举器执行所有后续操作在同一个线程上下文中。
    • @Damien_The_Unbeliever:一般来说,IEnumerator&lt;T&gt; 类型的引用不会被过多地传递; IEnumerable&lt;T&gt; 改为传递。虽然在极少数情况下可能有用,但将 IEnumerator&lt;T&gt; 传递给异步方法并确保在其他任何尝试使用它之前完成它(许多框架枚举器绝对是 not 线程-安全的同时访问)我不认为这是一个非常有价值的能力;如果可以预料到这种用法,我认为该语言不会允许 yield returnlock 中。
    • 我们不是在谈论 同时 访问,我们谈论的是在不同的(明确定义的)时间从不同的线程进行访问。
    猜你喜欢
    • 2017-11-26
    • 2020-05-30
    • 1970-01-01
    • 1970-01-01
    • 2023-03-12
    • 1970-01-01
    • 2023-02-08
    • 1970-01-01
    相关资源
    最近更新 更多