【问题标题】:Prevent Lazy<T> caching exceptions when invoking Async delegate调用异步委托时防止 Lazy<T> 缓存异常
【发布时间】:2019-06-01 06:55:51
【问题描述】:

我需要一个简单的AsyncLazy&lt;T&gt;,它的行为与Lazy&lt;T&gt; 完全相同,但正确支持处理异常并避免缓存它们。

具体我遇到的问题如下:

我可以这样写一段代码:

public class TestClass
{
    private int i = 0;

    public TestClass()
    {
        this.LazyProperty = new Lazy<string>(() =>
        {
            if (i == 0)
                throw new Exception("My exception");

            return "Hello World";

        }, LazyThreadSafetyMode.PublicationOnly);
    }

    public void DoSomething()
    {
        try
        {
            var res = this.LazyProperty.Value;
            Console.WriteLine(res);
            //Never gets here
        }
        catch { }
        i++;       
        try
        {
            var res1 = this.LazyProperty.Value;
            Console.WriteLine(res1);
            //Hello World
        }
        catch { }

    }

    public Lazy<string> LazyProperty { get; }

}

注意LazyThreadSafetyMode.PublicationOnly的使用。

如果初始化方法在任何线程上抛出异常,则 异常从该线程的 Value 属性中传播出来。这 异常没有被缓存。

然后我按以下方式调用它。

TestClass _testClass = new TestClass();
_testClass.DoSomething();

它的工作方式与您预期的完全一样,第一个结果因发生异常而被省略,结果保持未缓存,随后尝试读取该值成功返回“Hello World”。

不幸的是,如果我将代码更改为以下内容:

public Lazy<Task<string>> AsyncLazyProperty { get; } = new Lazy<Task<string>>(async () =>
{
    if (i == 0)
        throw new Exception("My exception");

    return await Task.FromResult("Hello World");
}, LazyThreadSafetyMode.PublicationOnly);

代码在第一次调用时失败,随后对该属性的调用被缓存(因此永远无法恢复)。

这在某种程度上是有道理的,因为我怀疑异常实际上从未冒泡到任务之外,但是我无法确定的是一种通知Lazy&lt;T&gt; 任务/对象初始化失败且不应被缓存的方法。

谁能提供任何意见?

编辑:

感谢您的回答伊万。我已经成功地通过您的反馈获得了一个基本示例,但事实证明我的问题实际上比上面的基本示例更复杂,毫无疑问,这个问题会影响其他类似情况的人。

因此,如果我将我的属性签名更改为这样的内容(根据 Ivans 的建议)

this.LazyProperty = new Lazy<Task<string>>(() =>
{
    if (i == 0)
        throw new NotImplementedException();

    return DoLazyAsync();
}, LazyThreadSafetyMode.PublicationOnly);

然后像这样调用它。

await this.LazyProperty.Value;

代码有效。

但是如果你有这样的方法

this.LazyProperty = new Lazy<Task<string>>(() =>
{
    return ExecuteAuthenticationAsync();
}, LazyThreadSafetyMode.PublicationOnly);

然后它自己调用另一个 Async 方法。

private static async Task<AccessTokenModel> ExecuteAuthenticationAsync()
{
    var response = await AuthExtensions.AuthenticateAsync();
    if (!response.Success)
        throw new Exception($"Could not authenticate {response.Error}");

    return response.Token;
}

延迟缓存错误再次出现,并且可以重现该问题。

下面是重现问题的完整示例:

this.AccessToken = new Lazy<Task<string>>(() =>
{
    return OuterFunctionAsync(counter);
}, LazyThreadSafetyMode.PublicationOnly);

public Lazy<Task<string>> AccessToken { get; private set; }

private static async Task<bool> InnerFunctionAsync(int counter)
{
    await Task.Delay(1000);
    if (counter == 0)
        throw new InvalidOperationException();
    return false;
}

private static async Task<string> OuterFunctionAsync(int counter)
{
    bool res = await InnerFunctionAsync(counter);
    await Task.Delay(1000);
    return "12345";
}

try
{
    var r = await this.AccessToken.Value;
}
catch (Exception ex) { }

counter++;

try
{
    //Retry is never performed, cached task returned.
    var r1 = await this.AccessToken.Value;

}
catch (Exception ex) { }

【问题讨论】:

  • 您包含 Ivan 答案的最新编辑已更改代码,使其行为方式与现在相同。您应该使用嵌套的async 函数保持代码原样,而父函数GetNumbersAsync 没有async 关键字——这将在GetNumbersAsync 停止缓存,允许异常冒泡。您需要 C# 7 才能使用嵌套函数。
  • 查看the following SharpLab code 了解嵌套函数代码是如何编译的..
  • 这看起来很有趣:docs.microsoft.com/en-us/dotnet/api/…
  • 感谢@spender,该库看起来很有希望,直到您发现它似乎不支持禁用异常缓存。也希望避免再次引用另一个库,但开始接受我可能无法避免它。 :-(
  • 这个库github.com/StephenCleary/AsyncEx 似乎可以正确处理异常重试。微软版本似乎不处理重试github.com/microsoft/vs-threading/blob/master/src/…。如果我可以只使用 Lazy 但看起来越来越不可能,那就太好了。

标签: c# .net asynchronous lazy-loading lazy-evaluation


【解决方案1】:

问题在于Lazy&lt;T&gt; 定义“失败”的方式与Task&lt;T&gt; 定义“失败”的方式发生了冲突。

对于“失败”的Lazy&lt;T&gt; 初始化,它必须引发异常。这是完全自然且可接受的,尽管它是隐式同步的。

如果Task&lt;T&gt;“失败”,则会捕获异常并将其放置在任务上。这是异步代码的正常模式。

将两者结合会导致问题。如果直接引发异常,Lazy&lt;Task&lt;T&gt;&gt;Lazy&lt;T&gt; 部分只会“失败”,而 Task&lt;T&gt;async 模式不会直接传播异常。所以async 工厂方法总是会出现(同步)“成功”,因为它们返回一个Task&lt;T&gt;。此时Lazy&lt;T&gt;部分实际上已经完成;它的值已生成(即使 Task&lt;T&gt; 尚未完成)。

您可以轻松构建自己的AsyncLazy&lt;T&gt; 类型。您不必仅仅为那一种类型依赖 AsyncEx:

public sealed class AsyncLazy<T>
{
  private readonly object _mutex;
  private readonly Func<Task<T>> _factory;
  private Lazy<Task<T>> _instance;

  public AsyncLazy(Func<Task<T>> factory)
  {
    _mutex = new object();
    _factory = RetryOnFailure(factory);
    _instance = new Lazy<Task<T>>(_factory);
  }

  private Func<Task<T>> RetryOnFailure(Func<Task<T>> factory)
  {
    return async () =>
    {
      try
      {
        return await factory().ConfigureAwait(false);
      }
      catch
      {
        lock (_mutex)
        {
          _instance = new Lazy<Task<T>>(_factory);
        }
        throw;
      }
    };
  }

  public Task<T> Task
  {
    get
    {
      lock (_mutex)
        return _instance.Value;
    }
  }

  public TaskAwaiter<T> GetAwaiter()
  {
    return Task.GetAwaiter();
  }

  public ConfiguredTaskAwaitable<T> ConfigureAwait(bool continueOnCapturedContext)
  {
    return Task.ConfigureAwait(continueOnCapturedContext);
  }
}

【讨论】:

  • 在构造函数中添加参数mode 是否有任何用处,会为mode == LazyThreadSafetyMode.ExecutionAndPublication 跳过RetryOnFailure
  • @TheodorZoulias:当然,如果需要,您可以添加缓存异常的选项,即使这是 Lazy&lt;Task&lt;T&gt;&gt; 的默认行为。
  • 非常感谢您的回复和创建 AsyncEx!得到领域专家的回复总是很高兴。
【解决方案2】:

为了帮助您了解这里发生了什么,这是一个简单的程序:

static void Main()
{
    var numberTask = GetNumberAsync( 0 );

    Console.WriteLine( numberTask.Status );
    Console.ReadLine();
}


private static async Task<Int32> GetNumberAsync( Int32 number )
{
    if ( number == 0 )
        throw new NotSupportedException();

    await Task.Delay( 1000 );

    return number;
}

试一试,你会看到程序的输出是Faulted。该方法总是返回一个结果,即捕获异常的Task。

为什么会发生捕获?它的发生是因为该方法的 async 修饰符。在幕后,方法的实际执行使用AsyncMethodBuilder,它捕获异常并将其设置为任务的结果。

我们该如何改变呢?

private static Task<Int32> GetNumberAsync( Int32 number )
{
    if ( number == 0 )
        throw new NotSupportedException();

    return GetNumberReallyAsync();

    async Task<Int32> GetNumberReallyAsync()
    {
        await Task.Delay( 1000 );

        return number;
    }
}

在此示例中,您可以看到该方法没有 async 修饰符,因此异常不会被捕获为错误任务。

因此,为了让您的示例按您的意愿工作,您需要删除 async 并等待:

public Lazy<Task<string>> AsyncLazyProperty { get; } = new Lazy<Task<string>>(() =>
{
    if (i == 0)
        throw new Exception("My exception");

    return Task.FromResult("Hello World");
}, LazyThreadSafetyMode.PublicationOnly);

【讨论】:

  • 嗨,Ivan,非常感谢您的回答,我已经用更多信息更新了我的问题。
猜你喜欢
  • 2017-06-23
  • 1970-01-01
  • 2012-06-08
  • 2018-09-22
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-11-27
相关资源
最近更新 更多