Неожиданное поведение отмены задачи - PullRequest
5 голосов
/ 31 марта 2019

Я создал простое WPF-приложение .NET Framework 4.7.2 с двумя элементами управления - текстовым полем и кнопкой.Вот мой код:

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 ]

5 голосов
/ 01 апреля 2019

У меня вопрос, почему [7] Task delay has been cancelled. выполняется в том же потоке, где запрашивается аннулирование токена?

Это потому, что await планирует продолжение своих задач с флагом ExecuteSynchronously . Я также думаю, что это поведение удивительно, и первоначально сообщал об этом как об ошибке (закрыто как «по замыслу»).

Более конкретно, await захватывает контекст, и , если этот контекст совместим с текущим контекстом, выполняющим задачу, то продолжение async выполняется непосредственно в потоке, который завершает эту задачу. задача.

Чтобы пройти через это:

  • Некоторые потоки пула потоков "7" запускаются cancellationTokenSource.Cancel().
  • Это заставляет CancellationTokenSource войти в отмененное состояние и запустить его обратные вызовы.
  • Один из этих обратных вызовов является внутренним для Task.Delay. Этот обратный вызов не зависит от потока, поэтому он выполняется в потоке 7.
  • Это приводит к отмене Task, возвращенного с Task.Delay. await запланировал свое продолжение из потока пула потоков, и все потоки пула считаются совместимыми друг с другом , поэтому продолжение async выполняется непосредственно в потоке 7.

Напоминаем, что потоки пула потоков используются только при наличии кода для запуска. Когда вы отправляете асинхронный код, используя await - Task.Run, он может запустить первую часть (до await) в одном потоке, а затем запустить другую часть (после await) в другом потоке.

Таким образом, поскольку потоки пула потоков взаимозаменяемы, для потока 7 не является «неправильным» продолжение выполнения метода async после await; это только проблема, потому что теперь код после Cancel заблокирован на этом async продолжении.

Обратите внимание, что если я выполню cancellationTokenSource.Cancel () из основного потока, результат будет выглядеть как ожидалось

Это связано с тем, что контекст пользовательского интерфейса не считается совместимым с контекстом пула потоков. Поэтому, когда Task, возвращаемый из Task.Delay, отменяется, await увидит, что он находится в контексте пользовательского интерфейса, а не в контексте пула потоков, поэтому он ставит свое продолжение в пул потоков вместо непосредственного его выполнения.

Интересно, что когда я заменил Task.Delay(TimeSpan.FromMinutes(1), cancellationTokenSource.Token) на cancellationTokenSource.Token.ThrowIfCancellationRequested() .NET, этот фоновый поток был занят, и вывод снова, как и ожидалось

Это не потому, что поток "занят". Это потому, что больше нет обратного вызова. Таким образом, метод наблюдения - опрос вместо того, чтобы уведомлять .

Этот код устанавливает таймер (через Task.Delay), а затем возвращает поток в пул потоков. Когда таймер отключается, он захватывает поток из пула потоков и проверяет, отменен ли источник маркера отмены; если нет, он устанавливает другой таймер и снова возвращает поток в пул потоков. Смысл этого параграфа в том, что Task.Run не представляет только «одну нить»; у него есть только поток во время выполнения кода (т.е. не в await), и поток может измениться после любого await.

<Ч />

Общая проблема await с использованием ExecuteSynchronously обычно не является проблемой, если вы не смешиваете блокирующий и асинхронный код. В этом случае лучшее решение - изменить код блокировки на асинхронный. Если вы не можете этого сделать, вам нужно быть осторожным, продолжая свои методы async, которые блокируются после await. Это в первую очередь проблема с TaskCompletionSource<T> и CancellationTokenSource. TaskCompletionSource<T> имеет приятную опцию RunContinuationsAsynchronously, которая переопределяет флаг ExecuteSynchronously; к сожалению, CancellationTokenSource нет; вам нужно будет поставить в очередь ваши Cancel звонки в пул потоков, используя Task.Run.

Бонус: викторина для ваших партнеров по команде .

0 голосов
/ 31 марта 2019

Попробуйте заблокировать внутри метода 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");
        });
    }
}

Обычно это немного опасно, поскольку может привести к тупику.

...