如何让 Task.Delay 提前完成

4

我有一个情况,我的代码正在等待一些时间延迟:

await Task.Delay(10_000, cancellationToken);

在某些情况下,即使超时尚未到期,我也希望立即继续执行(“跳过延迟”)。可以在代码已等待延迟的情况下做出这个决定。

我不想做的事情:根据条件循环

foreach (var i = 0; i < 10 && !continueImmediately; i++)
{
    await Task.Delay(1000, cancellationToken);
}

由于存在一定的延迟(这里是1秒)或者不必要地唤醒,我不希望发生以下行为:

取消

var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
try
{
   await Task.Delay(1000, linkedTokenSource.Token);
}
catch (OperationCancelledException)
{
   // determine if the delay was cancelled by the cancellationToken or by the delay shortcut...
}

因为我想避免异常情况。

我的想法:

TaskCompletionSource tcs = new TaskCompletionSource();
await Task.WhenAny(new[] { tcs.Task, Task.Delay(10_000, cancellationToken) });

为了缩短延迟时间: tcs.SetResult()

这似乎可行,但我不确定是否有任何清理工作遗漏。例如,如果采取了快捷方式(即因 tcs.Task 而完成 WhenAny),Task.Delay 是否会继续消耗资源?

注意:我确实喜欢取消支持,但这不是我的问题,因此您可以忽略我问题中的所有 cancellationTokens。


1
你为什么相信Task.Delay会“运行”呢?它的目的是允许一个任务在稍后变成已发出信号,而不会毫无意义地占用一个线程。 - Damien_The_Unbeliever
@Damien_The_Unbeliever 好的,“运行”这个词不太准确 - 但我的意思是:它是否仍会消耗资源? - Klaus Gütter
1
你要么取消,要么取消,那么再使用另一个CTS和Task.WhenAny有什么意义呢?如果你不想等待,请不要调用Task.Delay()if (!shortcut){ await Task.Delay();}。如果你想要yield,请使用await Task.Yield(),例如await shortcut?Task.Yield():Task.Delay(1000); - Panagiotis Kanavos
@PanagiotisKanavos 我在问题中明确表示,是否采取捷径的决定可能在代码已经等待延迟时被做出。 - Klaus Gütter
1
你是否试图避免由取消引发的异常?你可以使用忽略异常的继续来实现,例如 Task.Delay(...).ContinueWith(_=>{}); - Panagiotis Kanavos
当然,我想要取消支持,但这不是我的问题所在。我只是想要一个可以提前完成的延迟。 - Klaus Gütter
3个回答

3

你可以编写自己的Delay()方法,模仿Task.Delay()的实现方式,并不抛出异常。

以下示例会返回true(如果取消令牌已取消)或者false(如果延迟正常超时)。

// Returns true if cancelled, false if not cancelled.

public static Task<bool> DelayWithoutCancellationException(int delayMilliseconds, CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<bool>();
    var ctr = default(CancellationTokenRegistration);

    Timer timer = null;

    timer = new Timer(_ =>
    {
        ctr.Dispose();
        timer.Dispose();
        tcs.TrySetResult(false);
    }, null, Timeout.Infinite, Timeout.Infinite);

    ctr = cancellationToken.Register(() =>
    {
        timer.Dispose();
        tcs.TrySetResult(true);
    });

    timer.Change(delayMilliseconds, Timeout.Infinite);

    return tcs.Task;
}

然后您可以使用复合取消源以有两种方式取消它:

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace MultitargetedConsole
{
    class Program
    {
        static async Task Main()
        {
            await test();
        }

        static async Task test()
        {
            const int CANCEL1_TIMEOUT =  5000;
            const int CANCEL2_TIMEOUT =  2000;
            const int DELAY_TIMEOUT   = 10000;
            using var tokenSource1 = new CancellationTokenSource(CANCEL1_TIMEOUT);
            using var tokenSource2 = new CancellationTokenSource();
            using var compositeTokenSource = CancellationTokenSource.CreateLinkedTokenSource(tokenSource1.Token, tokenSource2.Token);

            var compositeToken = compositeTokenSource.Token;

            var _ = Task.Run(() => // Simulate something else cancelling tokenSource2 after 2s
            {
                Thread.Sleep(CANCEL2_TIMEOUT);
                tokenSource2.Cancel();
            });

            var sw = Stopwatch.StartNew();

            bool result = await DelayWithoutCancellationException(DELAY_TIMEOUT, compositeToken);
            Console.WriteLine($"Returned {result} after {sw.Elapsed}");
        }
    }
}

这会打印出类似于在00:00:02.0132319之后返回True。

如果您像这样更改超时时间:

const int CANCEL1_TIMEOUT =  5000;
const int CANCEL2_TIMEOUT =  3000;
const int DELAY_TIMEOUT   =  2000;

结果将类似于在00:00:02.0188434后返回False


参考此处为Task.Delay()的源代码


1
点赞。顺便说一下,tokenSource2 是多余的。你可以直接取消 compositeTokenSource - Theodor Zoulias
另外,您可以使用 CancelAfter 方法来安排源的取消,而不是使用 Task.Run+Thread.Sleep 组合:tokenSource2.CancelAfter(CANCEL2_TIMEOUT); - Theodor Zoulias

2
您可以使用自定义的awaiter来避免异常传递,具体请参考任务等待器
public struct SuppressException : ICriticalNotifyCompletion
{
    private Task _task;
    private bool _continueOnCapturedContext;

    /// <summary>
    /// Returns an awaiter that doesn't propagate the exception or cancellation
    /// of the task. The awaiter's result is true if the task completes
    /// successfully; otherwise, false.
    /// </summary>
    public static SuppressException Await(Task task,
        bool continueOnCapturedContext = true) => new SuppressException
        { _task = task, _continueOnCapturedContext = continueOnCapturedContext };

    public SuppressException GetAwaiter() => this;
    public bool IsCompleted => _task.IsCompleted;
    public void OnCompleted(Action action) => _task.ConfigureAwait(
        _continueOnCapturedContext).GetAwaiter().OnCompleted(action);
    public void UnsafeOnCompleted(Action action) => _task.ConfigureAwait(
        _continueOnCapturedContext).GetAwaiter().UnsafeOnCompleted(action);
    public bool GetResult() => _task.Status == TaskStatus.RanToCompletion;
}

请注意,GetResult方法中不包含可能引发异常的代码。
使用示例:
var linkedCTS = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

var task = Task.Delay(1000, linkedCTS.Token);

if (await SuppressException.Await(task))
{
    // The delay was not canceled
}
else if (cancellationToken.IsCancellationRequested)
{
    // The delay was canceled due to the cancellationToken
}
else
{
    // The delay was canceled due to the shortcut
}

SuppressException 结构体是一个稍微增强的裸实现版本,可以在这篇由Stephen Toub发布的GitHub帖子中找到。它也可以在此答案的第一版中找到。


2
你可以自己编写延迟方法:
public class DelayTask
{
    private Timer timer;
    private TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
    private CancellationTokenRegistration registration;
    private int lockObj = 0;

    private DelayTask(TimeSpan delay, CancellationToken cancel)
    {
        timer = new Timer(OnElapsed, null, delay, Timeout.InfiniteTimeSpan);
        registration = cancel.Register(OnCancel);
    }

    public static Task Delay(TimeSpan delay, CancellationToken cancel) => new DelayTask(delay, cancel).tcs.Task;

    private void OnCancel() => SetResult(false);
    private void OnElapsed(object state) => SetResult(true);
    private void SetResult( bool completed)
    {
        if (Interlocked.Exchange(ref lockObj, 1) == 0)
        {
            tcs.SetResult(completed);
            timer.Dispose();
            registration.Dispose();
        }
    }
}

据我所知,这个函数与Task.Delay基本相同,但会返回一个布尔值来表示它是否已完成。请注意,此函数完全没有经过测试。
引用: “这似乎可以工作,但我不确定是否有任何清理步骤遗漏。例如,如果快捷方式被采取(即WhenAny因tcs.Task而完成),那么Task.Delay是否会继续消耗资源?”
可能不会,参见Do I need to dispose of Tasks。在大多数情况下,任务不需要被处理。最坏的情况是,由于需要进行终结操作,它们会导致一些性能损失。即使Task.Delay在稍后触发,其性能影响也应该是最小的。

谢谢你提供的链接,很有意思的阅读! - Klaus Gütter

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