【问题标题】:System.Lazy<T> with different thread-safety modeSystem.Lazy<T> 具有不同的线程安全模式
【发布时间】:2017-03-03 06:39:23
【问题描述】:

.NET 4.0 的 System.Lazy<T> 类通过枚举 LazyThreadSafetyMode 提供三种线程安全模式,我将其总结为:

  • LazyThreadSafetyMode.None - 不是线程安全的。
  • LazyThreadSafetyMode.ExecutionAndPublication - 只有一个并发线程会尝试创建底层值。成功创建后,所有等待的线程都将收到相同的值。如果在创建过程中发生未处理的异常,它将在每个等待线程上重新抛出,缓存并在随后每次尝试访问基础值时重新抛出。
  • LazyThreadSafetyMode.PublicationOnly - 多个并发线程将尝试创建底层值,但第一个成功的线程将确定传递给所有线程的值。如果在创建过程中发生未处理的异常,它将不会被缓存,并且并发和后续尝试访问基础值将重新尝试创建并可能成功。

我想要一个延迟初始化的值,它遵循稍微不同的线程安全规则,即:

只有一个并发线程会尝试创建底层值。成功创建后,所有等待的线程都将收到相同的值。如果在创建过程中发生未处理的异常,它将在每个等待线程上重新抛出,但不会被缓存,后续访问底层值的尝试将重新尝试创建并可能成功。

因此,与 LazyThreadSafetyMode.ExecutionAndPublication 的主要区别在于,如果创建时的“第一次”失败,则可以稍后重新尝试。

是否存在提供这些语义的现有 (.NET 4.0) 类,还是我必须自己推出?如果我自己动手,是否有一种聪明的方法可以在实现中重用现有的 Lazy 以避免显式锁定/同步?


注意对于一个用例,想象一下“创建”可能很昂贵并且容易出现间歇性错误,例如涉及从远程服务器获取大量数据。我不想进行多次并发尝试来获取数据,因为它们可能全部失败或全部成功。但是,如果它们失败了,我希望以后能够重试。

【问题讨论】:

  • “稍后重试”非常模糊。也许您可以使用重新创建 Lazy 实例的 Timer。
  • 嗨@HansPassant。我并不是说 Lazy 本身应该稍后重试。我的意思是,如果用户多次调用 myLazy.value,那么如果第一次失败,那么在第二次调用时,它将再次尝试实例化底层值,而不是简单地重新抛出之前的异常。
  • “稍后”是什么时候?是“在主线程抛出异常并且所有等待线程都通过观察它被解除阻塞之后的任何时间”?它是否由任何呼叫者管理/正在观察您假设的LazyWithSprinkles&lt;T&gt;?听起来有一个比您发布的问题稍大的问题,这表明与Lazy&lt;T&gt; 类似的解决方案大不相同。
  • 例如,也许你可以让Lazy&lt;Task&lt;T&gt;&gt; 在第一个请求时启动一个Task&lt;T&gt; 重复发出请求直到它最终成功(或抛出一个它没有成功的错误知道如何从中恢复);然后,呼叫者可以等待Task&lt;T&gt;,只要他们可以证明是合理的。但是,它与您要查找的内容不完全相同,因为有时您会在值准备好之前超时 100 毫秒...但这实际上与稍后必须重试不同你的Lazy&lt;T&gt;-like 想法中抛出了异常?
  • (另一种不同的方式是,如果所有调用者在看到第一个异常后都感到沮丧并且不想再试一次,这将在后台线程上浪费资源,可能会无限期地重试......再次,不知道用例的大局观,我很难说这是否可行,甚至有什么需要担心的)

标签: c# .net-4.0 thread-safety


【解决方案1】:

只有一个并发线程会尝试创建底层 价值。创建成功后,所有等待的线程都会收到 相同的值。如果在创建过程中发生未处理的异常,它将 在每个等待线程上重新抛出,但不会被缓存和 随后尝试访问基础价值将重试 创建并可能成功。

由于 Lazy 不支持,您可以尝试自行滚动:

private static object syncRoot = new object();
private static object value = null;
public static object Value
{
    get
    {
        if (value == null)
        {
            lock (syncRoot)
            {
                if (value == null)
                {
                    // Only one concurrent thread will attempt to create the underlying value.
                    // And if `GetTheValueFromSomewhere` throws an exception, then the value field
                    // will not be assigned to anything and later access
                    // to the Value property will retry. As far as the exception
                    // is concerned it will obviously be propagated
                    // to the consumer of the Value getter
                    value = GetTheValueFromSomewhere();
                }
            }
        }
        return value;
    }
}

更新:

为了满足您对传播到所有等待读取线程的相同异常的要求:

private static Lazy<object> lazy = new Lazy<object>(GetTheValueFromSomewhere);
public static object Value
{
    get
    {
        try
        {
            return lazy.Value;
        }
        catch
        {
            // We recreate the lazy field so that subsequent readers
            // don't just get a cached exception but rather attempt
            // to call the GetTheValueFromSomewhere() expensive method
            // in order to calculate the value again
            lazy = new Lazy<object>(GetTheValueFromSomewhere);

            // Re-throw the exception so that all blocked reader threads
            // will get this exact same exception thrown.
            throw;
        }
    }
}

【讨论】:

  • 错过了“将在每个等待线程上重新抛出”位,但我猜这不是 真正的要求。
  • 是的,实际上如果 2 个线程在分配值时同时尝试读取该值(假设 GetTheValueFromSomewhere 将抛出异常),两个线程将获得 2 个不同异常,因为它们最终都会命中GetTheValueFromSomewhere 方法。所以基本上这个例子中的第二个读取线程将重试重新创建值。
  • Joe - 这是一个真正的要求。如果有多个并发线程调用 Value,并且第一个获取锁的线程调用 GetTheValueFromSomewhere() 引发异常,我不希望所有其他等待线程也去调用 GetTheValueFromSomewhere(),因为它可能很昂贵。
  • @MattBecker82,这是一个非常好的观点,也是一个完全有效的要求。我已经更新了我的答案,通过使用 Lazy 并在出现异常时重新创建静态字段实例来说明可能的解决方案,以避免将此异常缓存给所有未来的读者。
  • 更新中有syncRoot的原因吗?看起来它要么是不必要的,要么不足以解决它应该保护我们免受的任何坏事(除非出于某种原因,对引用类型字段的写入不是原子的,但读取是原子的),或者两者兼而有之。
【解决方案2】:

这样的事情可能会有所帮助:

using System;
using System.Threading;

namespace ADifferentLazy
{
    /// <summary>
    /// Basically the same as Lazy with LazyThreadSafetyMode of ExecutionAndPublication, BUT exceptions are not cached 
    /// </summary>
    public class LazyWithNoExceptionCaching<T>
    {
        private Func<T> valueFactory;
        private T value = default(T);
        private readonly object lockObject = new object();
        private bool initialized = false;
        private static readonly Func<T> ALREADY_INVOKED_SENTINEL = () => default(T);

        public LazyWithNoExceptionCaching(Func<T> valueFactory)
        {
            this.valueFactory = valueFactory;
        }

        public bool IsValueCreated
        {
            get { return initialized; }
        }

        public T Value
        {
            get
            {
                //Mimic LazyInitializer.EnsureInitialized()'s double-checked locking, whilst allowing control flow to clear valueFactory on successful initialisation
                if (Volatile.Read(ref initialized))
                    return value;

                lock (lockObject)
                {
                    if (Volatile.Read(ref initialized))
                        return value;

                    value = valueFactory();
                    Volatile.Write(ref initialized, true);
                }
                valueFactory = ALREADY_INVOKED_SENTINEL;
                return value;
            }
        }
    }
}

【讨论】:

  • 你能解释一下为什么这个异常没有被缓存吗?
  • @KininRoza 因为如果有异常,value 不会被写入。
【解决方案3】:

懒惰不支持这个。这是 Lazy 的设计问题,因为异常“缓存”意味着该惰性实例不会永远提供真正的值。由于网络问题等瞬态错误,这可能会导致应用程序永久停机。然后通常需要人工干预。

我敢打赌,这个地雷存在于相当多的 .NET 应用程序中......

您需要编写自己的懒惰来执行此操作。或者,为此打开 CoreFx Github 问题。

【讨论】:

  • 谢谢,用户。我意识到Lazy&lt;T&gt; 不支持这一点,所以我的问题是是否真的存在另一种支持这种行为的现有机制(或者可以很容易地适应)。抱歉,如果不清楚。
  • 答案是否定的,我应该说出来。 @MattBecker82 没有建造它。可惜了。
  • @JoeAmenta 一直以来我从未想要过这个。如果您希望您使用异常来控制流。所以似乎不太可能有人想要这个。如果需要,只需在惰性主体中捕获异常并将其作为值返回。给你。
  • @JoeAmenta 这取决于您的期望是Lazy 将调用值工厂一次 还是成功一次。许多开发人员认为是后者(错误地)。
  • Lazy 确实缺少 'ExecutionRetryWithPublication' 模式 - 哦,这是编写(并为其编写强大的测试!)“改进的” Lazy 实现的有趣的一天,遗憾的是没有基础接口/类可以用于类型签名:}
【解决方案4】:

我尝试使用没有竞争条件的 Darin's updated answer 版本 I pointed out... 警告,我不完全确定这最终完全没有竞争条件。

private static int waiters = 0;
private static volatile Lazy<object> lazy = new Lazy<object>(GetValueFromSomewhere);
public static object Value
{
    get
    {
        Lazy<object> currLazy = lazy;
        if (currLazy.IsValueCreated)
            return currLazy.Value;

        Interlocked.Increment(ref waiters);

        try
        {
            return lazy.Value;

            // just leave "waiters" at whatever it is... no harm in it.
        }
        catch
        {
            if (Interlocked.Decrement(ref waiters) == 0)
                lazy = new Lazy<object>(GetValueFromSomewhere);
            throw;
        }
    }
}

更新:我以为我在发布此内容后发现了一个竞争条件。该行为实际上应该是可以接受的,只要您可以接受可能很少见的情况,即在另一个线程已经从成功的快速Lazy&lt;T&gt; 返回之后,某个线程从慢速Lazy&lt;T&gt; 观察到异常(未来的请求将都成功了)。

  • waiters = 0
  • t1:在 Interlocked.Decrement (waiters = 1) 之前运行
  • t2:进来并跑到Interlocked.Increment之前(waiters = 1)
  • t1:执行其Interlocked.Decrement 并准备覆盖 (waiters = 0)
  • t2:运行到 Interlocked.Decrement (waiters = 1) 之前的位置
  • t1:用新的覆盖lazy(称为lazy1)(waiters = 1)
  • t3:进入并阻止lazy1 (waiters = 2)
  • t2:它的Interlocked.Decrement (waiters = 1)
  • t3:从lazy1 获取并返回值(waiters 现在无关紧要)
  • t2:重新抛出异常

我想不出比“这个线程在另一个线程产生成功结果后抛出异常”更糟糕的事件序列。

Update2:将lazy 声明为volatile,以确保所有读者立即看到受保护的覆盖。有些人(包括我自己)看到volatile 并立即认为“嗯,这可能被错误地使用了”,他们通常是对的。这就是我在这里使用它的原因:在上面示例中的事件序列中,t3 仍然可以读取旧的 lazy 而不是 lazy1,如果它位于读取 lazy.Value 之前 t1 修改 @987654352 的那一刻@ 包含lazy1volatile 防止这种情况发生,以便可以立即开始下一次尝试。

我也提醒过自己,为什么我在脑后会说“低锁并发编程很难,只需使用 C# lock 语句!!!”我一直在写原始答案。

Update3:刚刚在 Update2 中更改了一些文本,指出了使volatile 成为必要的实际情况——这里使用的Interlocked 操作显然是在当今重要的 CPU 架构上实现的全围栏而不是半围栏我最初只是假设,所以volatile 保护的部分比我最初想象的要窄得多。

【讨论】:

  • 似乎是一个优雅的解决方案,我不介意你描述的竞争条件。但是,如果我们真的想排除它,添加static object syncRoot = new object(); 并将Interlocked.Increment(ref _waiters); 更改为lock(syncRoot) {++waiters;} 并将if 语句更改为lock(syncRoot) { if(--waiters == 0) _lazy = new Lazy&lt;object&gt;(GetValueFromSomewhere); } 就足够了吗?
  • @MattBecker82:我认为这没有帮助。充其量(不确定这个),它让我们从字段声明中删除volatile,因为Monitor.Enter 做了一个完整的内存屏障。我无法弄清楚如何以合理的方式防止这种情况——我认为这将迫使我们等待所有当前正在执行的 catch 处理程序(如果有)完成(还有另一个变量,因为 waiters 可能都是等待成功运行)在读取 !currLazy.IsValueCreated 之后立即读取,然后再次读取 lazy。这是可行的,但会增加复杂性以获得可疑的好处。
  • 好的,我将它标记为正确,因为我觉得它是最优雅的,并且关于已知竞争条件的警告足够清楚。也支持Darin'sDamien's 答案。谢谢大家。
【解决方案5】:

部分受到Darin's answer 的启发,但试图让这个“受异常影响的等待线程队列”和“重试”功能正常工作:

private static Task<object> _fetcher = null;
private static object _value = null;

public static object Value
{
    get
    {
        if (_value != null) return _value;
        //We're "locking" then
        var tcs = new TaskCompletionSource<object>();
        var tsk = Interlocked.CompareExchange(ref _fetcher, tcs.Task, null);
        if (tsk == null) //We won the race to set up the task
        {
            try
            {
                var result = new object(); //Whatever the real, expensive operation is
                tcs.SetResult(result);
                _value = result;
                return result;
            }
            catch (Exception ex)
            {
                Interlocked.Exchange(ref _fetcher, null); //We failed. Let someone else try again in the future
                tcs.SetException(ex);
                throw;
            }
        }
        tsk.Wait(); //Someone else is doing the work
        return tsk.Result;
    }
}

我有点担心 - 任何人都可以在这里看到任何明显的比赛,它会以不明显的方式失败吗?

【讨论】:

  • 我想不出会导致这种情况发生的一系列操作......几乎任何不受欢迎的事情。请注意,如果值未准备好,tsk.Result 会自行等待,因此显式 tsk.Wait() 是多余的。
猜你喜欢
  • 2014-06-13
  • 2012-01-26
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-11-26
  • 2011-08-17
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多