我最近编写了一个 async 方法,该方法调用外部长时间运行的 async 方法,因此我决定通过 CancellationToken 启用取消。该方法可以同时调用。
实施结合了Stephen Cleary的书籍 C ++ Cookbook中的并发中描述的指数退避和超时技术如下:
/// <summary>
/// Sets bar
/// </summary>
/// <param name="cancellationToken">The cancellation token that cancels the operation</param>
/// <returns>A <see cref="Task"/> representing the task of setting bar value</returns>
/// <exception cref="OperationCanceledException">Is thrown when the task is cancelled via <paramref name="cancellationToken"/></exception>
/// <exception cref="TimeoutException">Is thrown when unable to get bar value due to time out</exception>
public async Task FooAsync(CancellationToken cancellationToken)
{
TimeSpan delay = TimeSpan.FromMilliseconds(250);
for (int i = 0; i < RetryLimit; i++)
{
if (i != 0)
{
await Task.Delay(delay, cancellationToken);
delay += delay; // Exponential backoff
}
await semaphoreSlim.WaitAsync(cancellationToken); // Critical section is introduced for long running operation to prevent race condition
using (CancellationTokenSource cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(Timeout));
CancellationToken linkedCancellationToken = cancellationTokenSource.Token;
try
{
cancellationToken.ThrowIfCancellationRequested();
bar = await barService.GetBarAsync(barId, linkedCancellationToken).ConfigureAwait(false);
break;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
if (i == RetryLimit - 1)
{
throw new TimeoutException("Unable to get bar, operation timed out!");
}
// Otherwise, exception is ignored. Will give it another try
}
finally
{
semaphoreSlim.Release();
}
}
}
}
我想知道是否应该编写一个单元测试,明确断言在取消barService.GetBarAsync()
时内部任务FooAsync()
被取消。如果是这样,如何干净利落地实施呢?
最重要的是,我应该忽略实现细节,只测试方法摘要中描述的客户端/调用者(条形图已更新,取消触发器OperationCanceledException
,超时触发器TimeoutException
)。
如果没有,我应该让自己的脚湿透,并开始对以下情况进行单元测试:
答案 0 :(得分:3)
我想知道是否应该编写一个单元测试,明确断言每当取消FooAsync()时内部任务barService.GetBarAsync()都会被取消。
当传递给GetBarAsync
的取消令牌被取消时,编写一个断言传递给FooAsync
的取消令牌被取消的测试会更容易。
对于异步单元测试,我选择的信号对于异步信号是TaskCompletionSource<object>
,对于同步信号是ManualResetEvent
。由于GetBarAsync
是异步的,我使用异步的,例如,
var cts = new CancellationTokenSource(); // passed into FooAsync
var getBarAsyncReady = new TaskCompletionSource<object>();
var getBarAsyncContinue = new TaskCompletionSource<object>();
bool triggered = false;
[inject] GetBarAsync = async (barId, cancellationToken) =>
{
getBarAsyncReady.SetResult(null);
await getBarAsyncContinue.Task;
triggered = cancellationToken.IsCancellationRequested;
cancellationToken.ThrowIfCancellationRequested();
};
var task = FooAsync(cts.Token);
await getBarAsyncReady.Task;
cts.Cancel();
getBarAsyncContinue.SetResult(null);
Assert(triggered);
Assert(task throws OperationCanceledException);
您可以使用这样的信号来创建一种&#34;锁定步骤&#34;。
旁注:在我自己的代码中,我从不写重试逻辑。我使用Polly,它完全async
- 兼容并经过全面测试。这将减少需要测试的语义:
OperationCanceledException
。TimeoutException
。(1)就像上面那样完成。 (2)和(3)不太容易测试(对于正确的测试,需要MS Fakes或抽象时间/互斥量)。在单元测试方面,肯定会有一个收益递减点,并且由您决定要走多远。
答案 1 :(得分:2)
感谢Stephen Cleary对Polly重试的点头。未来读者可能感兴趣的是,原始海报代码示例中的所有功能现在都可以使用已经过单元测试的现成Polly原型构建:
所有Polly策略都是fully unit-tested,同步和异步兼容,并发执行的线程安全,并且具有传递取消支持。
因此,原始代码的意图可以实现如下:
Policy retry = Policy.Handle<WhateverExceptions>().WaitAndRetryAsync(RetryLimit, retryAttempt => TimeSpan.FromMilliseconds(250 * Math.Pow(2, retryAttempt)));
Policy mutex = Policy.BulkheadAsync(1);
Policy timeout = Policy.TimeoutAsync(/* define overall timeout */);
bar = await timeout.WrapAsync(retry).WrapAsync(mutex).ExecuteAsync(ct => barService.GetBarAsync(barId, ct), cancellationToken);
我会在单词测试中添加一些关于单元测试的评论(OP的原始问题)给Stephen的答案(更为相关)。