为缓存、外部接口调用添加超时处理

快速失败

抛开各种细枝末节的原因,外部组件/依赖不可用的情况时常发生。站点应用有容错能力,能够从常规情况下的失败中恢复过来继续处理后续请求,但部分条件下,失败会从源头逐步蔓延直至拖垮所有应用,称之为雪崩效应

为了避免此类问题发生,我们需要使用一些手段规避,比较典型的像熔断器比如 Netflix/Hystrix,使用窗口机制,主动移除或者恢复失败的组件/依赖,文档很多且不是本 issue 重点,请自行查阅。

另外的有效手段是添加超时机制,即外部依赖如果明显失效则应快速失败,而不是无限制地等待。举例来说,Redis 超过1秒都没能返回响应,要么是数据量过大(即设计或实现不合理),要么是已经失活了;SSO 调用时间可能长一点,但根据以往经验总是能得到常规值,而不是傻傻地等下去。

编程世界里的超时/取消

高级语言形如 .net 提供了非常多超时的 API,像 Connection.Timeout,Request.Timeout 或者 FileStream.ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) 等等,但麻烦的是 .net 的任务均为基于 CancellationToken 的协作式取消(有关资料仍请自行查阅),这意味着

  • 超时/取消 API 是随着版本更迭逐步添加的,于是覆盖率有限
  • 在业务级别设置 CancellationToken 并不容易,并暴露了低级别对象,对封装性存在挑战

好在 aspnet core 里异步无处不在,我们可以很安全地使用 task 以很小代价完成超时设置。

原始的超时实现往往使用 System.Threading.Timer 实现

  1. 将原本无法使用超时/取消的 API,使用异步任务启动;
  2. 初始化 Timer 并使用超时时间作为触发时间;
  3. 在 Timer 的回调逻辑里检查第1的任务的完成情况

可能未等到 Timer 回调触发任务已完成,但在回调逻辑里检查任务未完成时,就可以主动告诉外部调用方时间已到,从而达到超时与取消目的。

好在 CancellationTokenSource 封装了类似逻辑,合适使用可以达到相同目的。一个供参考的实现如下:

public static async Task<T> SetTimeout<T>(this Task<T> task, TimeSpan timeout) {
    using (var cts = new CancellationTokenSource(timeout)) {
        var tsc = new TaskCompletionSource<T>();
        using (cts.Token.Register(state => tsc.SetCanceled(), tsc)) {
            if (task != await Task.WhenAny(task, tsc.Task)) {
                throw new OperationCanceledException(cts.Token);
            }
        }
        return await task;
    }
}

代码使用 Task.WhenAny 返回一个等待特定时间完成的简单任务,并与目标任务竞争;当返回结果和入参不同时表示超时,单元测试如下:

[Fact]
public async Task canel_timeout_void_task_should_throw_exception()
{
    var task = WorkHardly(1000);
    await Assert.ThrowsAsync<OperationCanceledException>(async () => await task.SetTimeout(TimeSpan.FromMilliseconds(500)));
}

[Fact]
public async Task canel_timely_void_task_should_pass()
{
    var task = WorkHardly(500);
    await task.SetTimeout(TimeSpan.FromMilliseconds(1000));
}

async Task<Guid> WorkHardly(Int32 ms)
{
    await Task.Delay(ms);
    return Guid.NewGuid();
}

至此超时/取消的目标实现,但其中有若干微秒的地方:

  1. 线程调度、用户/内核模式转换,使得一个什么也不做的任务完成也要 50 Milliseconds 左右,只能模糊计时;
  2. 极少数情况下超时(即调用方 catch 到 OperationCanceledException 异常)并不代表任务没有完成;

leoninew 原创,转载请保留出处 www.cnblogs.com/leoninew

原文地址:https://www.cnblogs.com/leoninew/p/12143315.html