调用异步委托时防止 Lazy<T> 缓存异常
Prevent Lazy<T> caching exceptions when invoking Async delegate
我需要一个简单的 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.
的使用
If the initialization method throws an exception on any thread, the
exception is propagated out of the Value property on that thread. The
exception is not cached.
然后我按以下方式调用它。
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>
task/object 初始化失败的方法并且不应被缓存。
有人能提供任何意见吗?
编辑:
感谢伊万的回答。我已经成功地通过您的反馈获得了一个基本示例,但事实证明我的问题实际上比上面的基本示例更复杂,毫无疑问,这个问题会影响其他处于类似情况的人。
因此,如果我将 属性 签名更改为类似这样的内容(根据 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);
然后它自己调用另一个异步方法。
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) { }
为了帮助您理解这里发生的事情,有一个简单的程序:
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
。该方法总是返回一个结果,即已捕获异常的任务。
为什么会发生捕获?它的发生是因为方法的 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
修饰符,因此未将异常捕获为错误任务。
因此,为了让您的示例按照您的意愿工作,您需要删除异步和等待:
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);
问题是 Lazy<T>
定义 "failed" 的方式干扰了 Task<T>
定义 "failed" 的方式。
对于 Lazy<T>
到 "fail" 的初始化,它必须引发异常。这是完全自然且可以接受的,尽管它是隐式同步的。
对于Task<T>
到"fail",异常被捕获并放在任务上。这是异步代码的正常模式。
将两者结合起来会导致问题。如果直接引发异常,Lazy<Task<T>>
的 Lazy<T>
部分只会 "fail",而 Task<T>
的 async
模式不会直接传播异常。因此 async
工厂方法将始终(同步)出现 "succeed",因为它们 return 和 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);
}
}
我需要一个简单的 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.
的使用If the initialization method throws an exception on any thread, the exception is propagated out of the Value property on that thread. The exception is not cached.
然后我按以下方式调用它。
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>
task/object 初始化失败的方法并且不应被缓存。
有人能提供任何意见吗?
编辑:
感谢伊万的回答。我已经成功地通过您的反馈获得了一个基本示例,但事实证明我的问题实际上比上面的基本示例更复杂,毫无疑问,这个问题会影响其他处于类似情况的人。
因此,如果我将 属性 签名更改为类似这样的内容(根据 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);
然后它自己调用另一个异步方法。
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) { }
为了帮助您理解这里发生的事情,有一个简单的程序:
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
。该方法总是返回一个结果,即已捕获异常的任务。
为什么会发生捕获?它的发生是因为方法的 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
修饰符,因此未将异常捕获为错误任务。
因此,为了让您的示例按照您的意愿工作,您需要删除异步和等待:
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);
问题是 Lazy<T>
定义 "failed" 的方式干扰了 Task<T>
定义 "failed" 的方式。
对于 Lazy<T>
到 "fail" 的初始化,它必须引发异常。这是完全自然且可以接受的,尽管它是隐式同步的。
对于Task<T>
到"fail",异常被捕获并放在任务上。这是异步代码的正常模式。
将两者结合起来会导致问题。如果直接引发异常,Lazy<Task<T>>
的 Lazy<T>
部分只会 "fail",而 Task<T>
的 async
模式不会直接传播异常。因此 async
工厂方法将始终(同步)出现 "succeed",因为它们 return 和 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);
}
}