意外的任务取消行为

时间:2019-03-30 21:19:39

标签: c# .net async-await task

我创建了一个简单的.NET Framework 4.7.2 WPF应用程序,其中包含两个控件-一个文本框和一个按钮。这是我的代码后面:

private async void StartTest_Click(object sender, RoutedEventArgs e)
{
    Output.Clear();

    var cancellationTokenSource = new CancellationTokenSource();

    // Fire and forget
    Task.Run(async () => {
        try
        {
            await Task.Delay(TimeSpan.FromMinutes(1), cancellationTokenSource.Token);
        }
        catch (OperationCanceledException)
        {
            Task.Delay(TimeSpan.FromSeconds(3)).Wait();
            Print("Task delay has been cancelled.");
        }
    });

    await Task.Delay(TimeSpan.FromSeconds(1));
    await Task.Run(() =>
    {
        Print("Before cancellation.");
        cancellationTokenSource.Cancel();
        Print("After cancellation.");
    });
}

private void Print(string message)
{
    var threadId = Thread.CurrentThread.ManagedThreadId;
    var time = DateTime.Now.ToString("HH:mm:ss.ffff");
    Dispatcher.Invoke(() =>
    {
        Output.AppendText($"{ time } [{ threadId }] { message }\n");
    });
}

按下StartTest按钮后,我在Output文本框中看到以下结果:

12:05:54.1508 [7] Before cancellation.
12:05:57.2431 [7] Task delay has been cancelled.
12:05:57.2440 [7] After cancellation.

我的问题是,为什么[7] Task delay has been cancelled.在请求取消令牌的同一线程中执行?

我希望看到的是[7] Before cancellation.,然后是[7] After cancellation.,然后是Task delay has been cancelled.。或至少Task delay has been cancelled.正在另一个线程中执行。

请注意,如果我从主线程执行cancellationTokenSource.Cancel(),则输出将按预期进行:

12:06:59.5583 [1] Before cancellation.
12:06:59.5603 [1] After cancellation.
12:07:02.5998 [5] Task delay has been cancelled.

更新

有趣的是,当我更换

await Task.Delay(TimeSpan.FromMinutes(1), cancellationTokenSource.Token);

使用

while (true)
{
    await Task.Delay(TimeSpan.FromMilliseconds(100));
    cancellationTokenSource.Token.ThrowIfCancellationRequested();
}

.NET使该后台线程保持忙碌状态,并且输出再次符合预期:

12:08:15.7259 [5] Before cancellation.
12:08:15.7289 [5] After cancellation.
12:08:18.8418 [7] Task delay has been cancelled..

更新2

我已对代码示例进行了稍许更新,以期使内容更加清晰。

请注意,这不是纯粹的假设问题,而是一个实际的问题,我已经花了很多时间在生产代码中进行理解。但是为了简洁起见,我创建了这个极其简化的代码示例,以说明相同的行为。

2 个答案:

答案 0 :(得分:5)

  

我的问题是,为什么[7] Task delay has been cancelled.在请求取消令牌的同一线程中执行?

这是因为await schedules its task continuations with the ExecuteSynchronously flag。我也认为这种行为令人惊讶,并最初将其报告为错误(按“按设计要求封闭”)。

更具体地说,await捕获一个上下文,并且如果该上下文与正在完成任务的当前上下文兼容,那么async延续将直接在完成该任务的线程。

要逐步执行:

  • 某些线程池线程“ 7”运行cancellationTokenSource.Cancel()
  • 这会导致CancellationTokenSource进入取消状态并运行其回调。
  • 这些回调之一在Task.Delay内部。该回调不是特定于线程的,因此它在线程7上执行。
  • 这将导致从Task返回的Task.Delay被取消。 await已从线程池线程安排了其继续,而线程池线程都被认为彼此兼容,因此async延续在线程7上直接执行。

提醒一下,线程池线程仅在有要运行的代码时使用。当您使用awaitTask.Run发送异步代码时,它可以在一个线程上运行第一部分(最多await),然后运行另一部分(在await之后) )在另一个线程上。

因此,由于线程池线程是可互换的,因此线程7在async之后继续执行await方法并不“错误”;这只是一个问题,因为现在Cancel之后的async之后的代码被阻止了。

  

请注意,如果我从主线程执行CancellationTokenSource.Cancel(),则输出将按预期显示

这是因为UI上下文被认为与线程池上下文兼容。因此,当取消从Task返回的Task.Delay时,await将看到它在UI上下文中,而不是在线程池上下文中,因此它将其延续性排队到线程池而不是线程池中。直接执行。

  

有趣的是,当我将Task.Delay(TimeSpan.FromMinutes(1), cancellationTokenSource.Token)替换为cancellationTokenSource.Token.ThrowIfCancellationRequested()时,.NET使该后台线程处于繁忙状态,并且输出再次如预期那样

不是因为线程“忙”。这是因为没有回调了。因此,观察方法是轮询,而不是通知

该代码设置了一个计时器(通过Task.Delay),然后将线程返回到线程池。当计时器关闭时,它将从线程池中获取一个线程,并检查取消令牌源是否已取消;如果不是,它将设置另一个计时器并将线程再次返回到线程池。本段的要点是Task.Run不仅仅代表“一个线程”;它在执行代码时只有一个线程(即不在await中),并且该线程可以在任何await之后更改。


除非您要混合使用阻塞代码和异步代码,否则使用await的{​​{1}}的一般问题通常不会成为问题。在这种情况下,最好的解决方案是将阻塞代码更改为异步代码。如果无法做到这一点,则需要注意如何继续在ExecuteSynchronously之后阻塞的async方法。这是awaitTaskCompletionSource<T>的主要问题。 CancellationTokenSource有一个不错的TaskCompletionSource<T>选项,它会覆盖RunContinuationsAsynchronously标志;不幸的是,ExecuteSynchronously并非如此;您必须使用CancellationTokenSourceCancel的调用排队到线程池中。

奖金:一个quiz for your teammates

答案 1 :(得分:-1)

尝试在Print方法内部进行锁定,以最大程度地减少竞争条件的影响。结果是什么?

private object _locker = new object();

private void Print(string message)
{
    lock (_locker)
    {
        var threadId = Thread.CurrentThread.ManagedThreadId;
        Dispatcher.Invoke(() =>
        {
            Output.AppendText($"{DateTime.Now:HH:mm:ss.fff} [{threadId}] {message}\n");
        });
    }
}

通常这样做有点危险,因为它可能导致死锁。