【问题标题】:Execution of ContinueWith when the antecedent Task is in canceled state and usage of async delegate inside of ContinueWith当前面的任务处于取消状态时执行 ContinueWith 并在 ContinueWith 内部使用异步委托
【发布时间】:2021-04-06 09:12:28
【问题描述】:

这里讨论的代码是用 C# 编写并使用 .netcore 3.1 执行的

我有以下代码,它在后台启动工作负载而不等待它完成(即发即弃):

public void StartBackgroundWork(IAsyncDisposable resource, CancellationToken token)
{
    // some background work is started in a fire and forget manner
    _ = Task.Run(async () => 
    {
        
        try 
        {
            // here I perform my background work. Regardless of the outcome resource must be released as soon as possible
            // I want that cancellation requests coming from the provided cancellation token are correctly listened by this code
            // So, I pass the cancellation token everywhere
            
            await Task.Delay(1500, token);
        }
        finally 
        {
            // here I need to release the resource. Releasing this resource is important and must be done as soon as possible
            await resource.DisposeAsync();
        }       
    }, token);
}

有三个重点:

  • 后台工作以一劳永逸的方式启动。我对等待它的完成不感兴趣
  • 提供的取消令牌很重要,后台工作必须在传入的取消请求中列出
  • 无论后台工作的结果如何,必须尽快释放提供的资源 (IAsyncDisposable)。为了释放资源,需要调用DisposeAsync

此代码的问题在于取消令牌被传递给Task.Run 调用。如果令牌在异步委托开始执行之前被取消,则异步委托永远不会执行,因此finally 块永远不会执行。这样做不会满足释放IAsyncDisposable 资源的要求(基本上,永远不会调用DisposeAsync)。

解决此问题的最简单方法是在调用 Task.Run 时提供取消令牌。这样,异步委托总是被执行,因此 finally 块也被执行。异步委托内部的代码监听取消请求,所以也满足取消执行的要求:

public void StartBackgroundWork(IAsyncDisposable resource, CancellationToken token)
{
    // some background work is started in a fire and forget manner
    _ = Task.Run(async () => 
    {
        
        try 
        {
            // here I perform my background work. Regardless of the outcome resource must be released as soon as possible
            // I want that cancellation requests coming from the provided cancellation token are correctly listened by this code
            // So, I pass the cancellation token everywhere
            
            await Task.Delay(1500, token);
        }
        finally 
        {
            // here I need to release the resource. Releasing this resource is important and must be done as soon as possible
            await resource.DisposeAsync();
        }       
    }, CancellationToken.None);
}

我在问自己是否应该将IAsyncDisposable 资源的释放委托给延续任务。使用这种方法重构的代码如下:

public void StartBackgroundWork(IAsyncDisposable resource, CancellationToken token)
{
    // some background work is started in a fire and forget manner
    _ = Task.Run(async () => 
    {
        // here I perform my background work. Regardless of the outcome resource must be released as soon as possible
        // I want that cancellation requests coming from the provided cancellation token are correctly listened by this code
        // So, I pass the cancellation token everywhere
        
        await Task.Delay(1500, token);
    }, 
    token).ContinueWith(async _ => 
    {
        // release the IAsyncDisposable resource here, afte the completion of the antecedent task and regardless
        // of the antecedent task actual state
        await resource.DisposeAsync();
    });
}

我对@9​​87654333@ gotchas 不是很熟悉,所以我的问题如下:

  1. 我是否可以保证继续始终执行,即使取消令牌前面的任务开始执行之前被取消?
  2. 为调用ContinueWith 提供异步委托有什么问题吗?异步委托的执行是否按预期完全完成?
  3. 最好的方法是什么?将CancellationToken.None 传递给Task.Run 的调用,或者通过使用ContinueWith 依赖延续?

重要提示:我知道在服务器应用程序中使用Task.Run 不是最好的方法(更多信息可以在here 找到),所以有可能是设计我的整体架构的更好方法。我发布这个问题是为了更好地理解 ContinueWith 的实际行为,因为我并不真正熟悉它的用法(在现代 .NET 代码中,它在很大程度上被 async await 的用法所取代)。

【问题讨论】:

  • 老实说,ContinueWith 是遗留 API,如果有的话,不应该真正使用太多;如果是我,我会在Task.Run 调用中丢失token,而只使用await using(或try/finally/await resource.DisposeAsync(),如果逻辑特别复杂)inside async lambda。
  • 免责声明在这里确实很重要,因为您肯定在这里做了一些可疑和代码审查失败的事情
  • 作为旁注,您不需要将CancellationToken.None 作为参数传递。 Task.Run 方法有一个只接受一个参数的重载(action 委托),这也是最常用的重载。

标签: c# asynchronous async-await task-parallel-library cancellation-token


【解决方案1】:

您可以考虑使用await using 语句,该语句自动处理resource 的异步处理:

public async void StartBackgroundWork(IAsyncDisposable resource, CancellationToken token)
{
    await using var _ = resource;
    try
    {
        await Task.Run(async () => 
        {
            await Task.Delay(1500, token);
        }, token);
    } catch (OperationCanceledException) { }
}

我还将您的“即发即弃”任务转换为async void(又称“即火即崩溃”)方法。如果发生不可想象的事情并且您的代码有错误,而不是应用程序继续运行并发生未观察到的异常,可能导致应用程序状态损坏,整个应用程序将崩溃,迫使您尽快修复错误。

但老实说,以一种方法创建一次性资源并以另一种方法处理它是一种臭名昭著的设计。理想情况下,创建资源的方法应该负责最终处理它。

【讨论】:

  • 能否请您稍微扩展一下异步无效、火灾和崩溃的问题?我对此并不熟悉。
  • @EnricoMassone 你可以阅读async void 方法here。本质上,async void 方法具有事件处理程序语义。如果发生异常,它的行为方式与事件处理程序中未处理的异常相同:它会引发AppDomain.UnhandledException 事件或WinForms 中的Application.ThreadException 等特定于应用程序的事件。然后应用就死掉了。
【解决方案2】:

我认为Theodor has a great answer;我只是要回答你的一些其他问题:

我是否有保证继续执行,即使取消令牌在前面的任务开始执行之前被取消?

ContinueWith 将执行其委托,即使前面的任务已经完成。在这种特定情况下,仅仅因为即发即弃的性质,就没有“保证”。

向 ContinueWith 的调用提供异步委托有什么问题吗?

ContinueWith 不是async-aware,所以ContinueWith 的返回类型对于大多数开发人员来说是令人惊讶的。由于您的代码丢弃了返回类型,因此这不是问题。

异步委托的执行是否按预期完全完成?

在这种情况下,很有可能,但这实际上取决于“预期”的含义。像所有其他即发即弃的代码一样,您不能保证完成。 ContinueWith 有一个额外的问题:它使用TaskScheduler 执行其委托,默认的TaskScheduler 不是TaskScheduler.Default,但实际上是TaskScheduler.Current。因此,如果您确实需要使用ContinueWith,我总是建议您传递明确的TaskScheduler

什么是最好的方法?将 CancellationToken.None 传递给 Task.Run 的调用,还是使用 ContinueWith 依赖延续?

只需将第二个参数放到Task.Run

我会更进一步:Task.Run 可能甚至不应该接受 CancellationToken。我还没有看到它有用的场景。我怀疑 API 的 CancellationToken 部分是从 TaskFactory.StartNew 复制而来的(它很少有用),但由于 Task.Run 总是使用 TaskScheduler.Default,所以提供 CancellationToken 没有用在实践中。

附:我最近在proper solution for fire-and-forget on ASP.NET上写了一个短系列。

【讨论】:

  • 在我的代码示例中,async void(如果有的话)实际上有什么用处?我记得你的书说要避免异步无效。由于您的指导,我总是对此有点紧张。 Theodore 在他的回复中提到了 async void。
  • 丢弃任务的问题(如在您的原始代码中)是该任务的所有异常都被忽略。因此,如果发生了诸如DisposeAsync 投掷之类的不可想象的事情,您永远不会知道 - 应用程序会忽略它并继续运行(或尝试运行)。 Theodor 的回答中async void 背后的想法是,如果发生不可想象的事情,应用程序将崩溃而不是尝试运行。避免 async void 是一个很好的一般规则,但这将是该一般规则的一个例外。
  • 知道了。在这里,我采用了一个简单的解决方案,以避免引入持久队列和后端服务,这是(正如您在文章中提到的)最干净的方法。一个不错的解决方法可能是使用Task.Run 和顶级try catch 语句来捕获所有内容并进行日志记录。通过这样做,我至少会有一些日志条目,并且我会避免使用async void。这样我可以避免由async void 引起的应用程序崩溃(在生产环境中,应用程序崩溃会引起很多人的警觉)。在这种情况下,Task.Run 只是一种伪造持久队列的方法。
  • @EnricoMassone:它们都不是为即发即弃而开发的。 async void 是为事件处理程序设计的,Task.Run 用于将工作排队到线程池。
  • 任何缺少持久队列和后端服务的东西在某些时候都会变得不可靠。如果您对此感到满意,那么这就是您的决定。例如,使用带有日志记录的catch 通常可以工作,但有时可能不会记录任何内容。如果您对整个委托执行完整的try/catch,那么使用async voidasync Task 并没有太大的区别。就个人而言,如果我不得不进行即发即弃,那么我会使用 Theodor 的答案,因为如果发生意外错误,崩溃(并重新启动)更好试图在未知的状态下蹒跚前行。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2014-10-30
  • 2013-06-28
  • 2019-07-15
  • 1970-01-01
  • 2020-02-26
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多