防止在调用异步委托时,Lazy<T> 缓存异常。

5
我需要一个简单的 AsyncLazy<T>,正好像Lazy<T>一样,但能正确地处理异常并避免缓存它们。
具体而言,我遇到的问题如下:
我可以写出以下代码:
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<T>任务/对象初始化已经失败且不应被缓存的方法。
有人能提供任何意见吗?
编辑:
感谢您的答案Ivan。我已经通过您的反馈成功地得到了一个基本的示例,但事实证明我的问题实际上比上面的基本示例更复杂,毫无疑问,这个问题会影响到其他人。
因此,如果我将属性签名更改为像这样的内容(根据Ivan的建议),则:
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);

然后该方法会调用另一个异步方法。

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

懒惰缓存bug再次出现,问题可以重现。
以下是一个完整的示例以重现此问题:
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才能使用嵌套函数。 - ColinM
请查看以下SharpLab代码以查看嵌套函数代码的编译情况:点击此处 - ColinM
这看起来很有趣:https://learn.microsoft.com/en-us/dotnet/api/microsoft.visualstudio.threading.asynclazy-1?view=visualstudiosdk-2017 - spender
感谢@spender,那个库看起来非常有前途,直到你发现它似乎不支持在异常情况下禁用缓存。也希望避免对另一个库的引用,但开始接受我可能无法避免它的事实。 :-( - Maxim Gershkovich
1
这个库https://github.com/StephenCleary/AsyncEx似乎正确处理了带有异常的重试。 Microsoft版本似乎无法处理重试https://github.com/microsoft/vs-threading/blob/master/src/Microsoft.VisualStudio.Threading/AsyncLazy.cs。如果我能够只使用Lazy<T>,那会很好,但越来越不可能了。 - Maxim Gershkovich
2个回答

9
问题在于 Lazy<T> 如何定义“失败”与 Task<T> 如何定义“失败”发生了干扰。
对于 Lazy<T> 的初始化来说,“失败”意味着引发异常。虽然这是隐式同步的,但这是非常自然和可接受的。
对于 Task<T> 来说,“失败”会捕获异常并将其放置在任务中。这是异步编程的常规模式。
将两者结合起来会造成问题。 Lazy<Task<T>> 中的 Lazy<T> 部分只有在直接引发异常时才会“失败”,而 Task<T> 的异步模式并不会直接传播异常。因此,async 工厂方法似乎总是会(同步)“成功”,因为它们返回一个 Task<T>。此时,Lazy<T> 部分实际上已经完成;其值已生成(即使 Task<T> 还没有完成)。
您可以很容易地构建自己的 AsyncLazy<T> 类型,而无需花费太多精力。您不必仅仅为了一个类型依赖于 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,是否有用? - Theodor Zoulias
@TheodorZoulias:当然,如果需要的话,您可以添加一个缓存异常的选项,尽管这是Lazy<Task<T>>的默认行为。 - Stephen Cleary
2
非常感谢您的回复并创建AsyncEx!总是很高兴从领域专家那里得到回应。 - Maxim Gershkovich

4
为了帮助您理解这里的情况,这是一个简单的程序:
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修饰符,因此异常不会作为故障任务捕获。
所以为了使你的示例按照你想要的方式工作,需要移除asyncawait
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);

嗨,伊万,非常感谢你的回答,我已经更新了我的问题并提供了一些进一步的信息。 - Maxim Gershkovich

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接