如何使用 CancellationToken 在不在任务内显式检查的情况下取消任务?

7

背景:

我有一个Web应用程序,可以启动长时间运行(无状态)的任务:

var task = Task.Run(() => await DoWork(foo))

task.Wait();

因为它们运行时间较长,我需要能够从单独的 Web 请求中取消它们。
为此,我想使用 CancellationToken,并在令牌被取消后立即抛出异常。但是,根据我所读到的,任务取消是合作式的,这意味着运行任务的代码必须显式地检查令牌,以查看是否已发出取消请求(例如CancellationToken.ThrowIfCancellation())。
我想避免在各处检查CancellationToken.ThrowIfCancellation(),因为任务相当长并且经过许多函数。我认为我可以通过创建一个显式的Thread来完成我想要的操作,但我真的想避免手动线程管理。话虽如此...
问题:
当任务被取消时,是否可能自动抛出异常?如果不行,是否有任何良好的替代方法(模式等)来减少代码中的CancellationToken.ThrowIfCancellation()
我想避免像这样的东西:
async Task<Bar> DoWork(Foo foo)
{
    CancellationToken.ThrowIfCancellation()

    await DoStuff1();

    CancellationToken.ThrowIfCancellation()

    await DoStuff2();

    CancellationToken.ThrowIfCancellation()

    await DoStuff3();
...
}

我认为这个问题与这个足够不同,因为我明确要求一种最小化检查取消令牌调用的方法,而被接受的答案回应道:“在函数内部时不时地调用token.ThrowIfCancellationRequested()”


1
“当任务被取消时,是否可能自动抛出异常?” 不可能。CancellationToken 用于请求“请停止”,而不是强制终止任务。任务必须“听取”这些停止请求。 - Greg
谢谢@Greg - 那么,我是否可以使用任何东西(包括CancellationToken)来取消任务,还是我只能使用这种方法? - schil227
我不确定是否有其他选择,但我建议您遵循微软的模式。这将使与其他使用任务和取消令牌的库(如ASP.NET和System.Data.SqlClient)集成变得更加容易。 - Greg
  1. 在 Web 应用程序中,通过子线程运行长时间计算任务通常不是一个好主意(应用程序池可能会被回收)。
  2. 只为了等待而启动一个 Task 有点违背了它的目的。
  3. 是的,你很遗憾地被“卡住”了。
- user585968
@MickyD 1) 这是一个很好的观点 - 我正在将一个exe转换成Web应用程序的过程中,所以我需要更仔细地分析一下。2) 的确如此,不过我只是举了一个简单的例子。3) ¯\(ツ) - schil227
3个回答

5
当任务被取消时,是否可能在任务中自动抛出异常?如果不行,是否有任何良好的替代方案(模式等),以减少在代码中使用CancellationToken.ThrowIfCancellation()的污染?
不行,所有取消都需要合作。最好的取消代码的方法是让代码响应取消请求。这是唯一好的模式。
我认为创建一个明确的线程可以实现我想要的,这是可行的吗?
不太行。
此时,问题变成“如何取消不可取消的代码?” 这个答案取决于您希望系统的稳定性如何:
1.在单独的Thread中运行代码,并在不再需要它时中止该线程。 这是最容易实现但在应用程序稳定性方面最危险的方法。简而言之,如果您在应用程序中任何地方调用Abort,则还应定期重启该应用程序,除了像心跳/冒烟测试检查之类的标准实践。
2.在单独的AppDomain中运行代码,并在不再需要它时卸载该AppDomain。这更难实现(必须使用远程处理),并且在Core世界中不是一种选择。结果发现,AppDomains甚至不能像预期的那样保护包含应用程序,因此使用此技术的任何应用程序都需要定期重新启动。
3.在单独的进程中运行代码,并在不再需要它时杀死该进程。 这是最复杂的实现,因为您还需要实现某种形式的进程间通信。 但这是取消不可取消代码的唯一可靠解决方案
如果丢弃不稳定的解决方案(1)和(2),则仅剩下的解决方案(3)是大量工作 - 比使代码可取消要多得多。
简而言之:只需按照设计使用取消API即可。这是最简单和最有效的解决方案。

2
我在想,Cleary先生,您是否能够发表一下意见。您提出的观点与我所研究的内容相符,但是您对于实现这些内容的困难和不稳定性的洞察力正是我所需要的。通过阅读您在这里发布的其他帖子,我学到了很多东西,非常感谢您的贡献。 - schil227

1
如果您实际上只是在依次调用一堆方法,那么您可以实现一个方法运行器来按顺序运行它们,并在其中间检查取消操作。类似于这样的代码:
public static void WorkUntilFinishedOrCancelled(CancellationToken token, params Action[] work)
{
    foreach (var workItem in work)
    {
        token.ThrowIfCancellationRequested();
        workItem();
    }
}

您可以像这样使用它:

async Task<Bar> DoWork(Foo foo)
{
    WorkUntilFinishedOrCancelled([YourCancellationToken], DoStuff1, DoStuff2, DoStuff3, ...);        
}

这基本上会做你想要的事情。

是的,我有点沿着这个方向思考(修饰子任务)。这不像我的例子那样直截了当,但仍然是个好答案。 - schil227
如果一个方法包含许多语句,其中也需要常规任务取消检查语句,会发生什么? - user585968
你可以在这个长时间运行的方法中使用WorkUntilFinishedOrCancelled,并将该方法分解为更短的子任务。 WorkUntilFinishedOrCancelled不处理任何线程相关的内容(除了取消),因此您可以递归和重复调用它。 - Chillersanim

1
如果您能接受Thread.Abort影响(未处理的可释放资源,未释放的锁,应用程序状态被破坏),那么您可以通过中止任务专用线程来实现非协作式取消。请参考以下步骤:
private static Task<TResult> RunAbortable<TResult>(Func<TResult> function,
    CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<TResult>();
    var thread = new Thread(() =>
    {
        try
        {
            TResult result;
            using (cancellationToken.Register(Thread.CurrentThread.Abort))
            {
                result = function();
            }
            tcs.SetResult(result);
        }
        catch (ThreadAbortException)
        {
            tcs.TrySetCanceled();
        }
        catch (Exception ex)
        {
            tcs.TrySetException(ex);
        }
    });
    thread.IsBackground = true;
    thread.Start();
    return tcs.Task;
}

使用示例:

var cts = new CancellationTokenSource();
var task = RunAbortable(() => DoWork(foo), cts.Token);
task.Wait();

我知道你可能只是在重复OP的代码,但是启动一个Task只为了Wait()它有点失去了意义。另外,最好使用Task.Run并使用现有的线程池,而不是通过new Thread()创建一个显式线程。 - user585968
@MickyD 中止线程池线程可能会更糟,因为在接收到通知并中止线程时,原始工作项可能已经完成,以至于池的线程已经转移到另一个工作项。 - Theodor Zoulias
那句来自《.NET Matters》的Stephen Toub的引用是在2006年3月写的,早于.NET 4.0公告和2008年、2009年的第一版.NET 4.0公共测试版。.NET 4.0引入了TPL(引入了async/await和Task)。该文章提到了显式类ThreadPool和ThreadPool.QueueUserWorkItem,与.NET 2相符,而不是这里所描述的“线程池”。最重要的是,当线程被中止时,该文章提到了使用QueueUserWorkItem时可能发生的ThreadPool故障。 - user585968
你在生产代码中调用了 Thread.Abort(),并且担心 ThreadPool.QueueUserWorkItem 可能会开始表现不稳定?这样做会像捕获和吞咽未处理的异常一样破坏进程。 - user585968
@MickyD,就我个人而言,我会使用协作式取消,而不是 Thread.Abort。我认为在我的回答中已经给出了足够的提示,说明终止线程是一种危险的做法! - Theodor Zoulias

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