Task.Delay().Wait() 是怎么回事?

What is going on with Task.Delay().Wait()?

我很困惑为什么 Task.Delay().Wait() 需要 4 倍的时间 ,然后 Thread.Sleep()

例如task-00 是 运行 在 只有线程 9 上花费了 2193ms? 我知道,同步等待在任务中很糟糕,因为整个线程都被阻塞了。仅供测试。

控制台应用程序中的简单测试:

bool flag = true;
var sw = Stopwatch.StartNew();
for (int i = 0; i < 10; i++)
{
    var cntr = i;
    {
        var start = sw.ElapsedMilliseconds;
        var wait = flag ? 100 : 300;
        flag = !flag;

        Task.Run(() =>
        {
            Console.WriteLine($"task-{cntr.ToString("00")} \t ThrID: {Thread.CurrentThread.ManagedThreadId.ToString("00")},\t Wait={wait}ms, \t START: {start}ms");                     
            //Thread.Sleep(wait);
            Task.Delay(wait).Wait();
            Console.WriteLine($"task-{cntr.ToString("00")} \t ThrID: {Thread.CurrentThread.ManagedThreadId.ToString("00")},\t Wait={wait}ms, \t END: {sw.ElapsedMilliseconds}ms");
            ;
        });
    }
}
Console.ReadKey();
return;

Task.Delay().Wait():
任务 03 ThrID:05,等待 = 300 毫秒,开始:184 毫秒
任务 04 ThrID:07,等待 = 100 毫秒,开始:184 毫秒
任务 00 ThrID:09,等待 = 100 毫秒,开始:0 毫秒
任务 06 ThrID:04,等待 = 100 毫秒,开始:185 毫秒
任务 01 ThrID:08,等待 = 300 毫秒,开始:183 毫秒
任务 05 ThrID:03,等待 = 300 毫秒,开始:185 毫秒
任务 02 ThrID:06,等待 = 100 毫秒,开始:184 毫秒
任务 07 ThrID:10,等待 = 300 毫秒,开始:209 毫秒
任务 07 ThrID:10,等待 = 300 毫秒,结束:1189 毫秒
任务 08 ThrID:12,等待 = 100 毫秒,开始:226 毫秒
任务 09 ThrID:10,等待 = 300 毫秒,开始:226 毫秒
task-09 ThrID:10,等待=300ms,结束:2192ms
任务 06 ThrID:04,等待 = 100 毫秒,结束:2193 毫秒
任务 08 ThrID:12,等待 = 100 毫秒,结束:2194 毫秒
任务 05 ThrID:03,等待 = 300 毫秒,结束:2193 毫秒
任务 03 ThrID:05,等待 = 300 毫秒,结束:2193 毫秒
任务 00 ThrID:09,等待 = 100 毫秒,结束:2193 毫秒
任务 02 ThrID:06,等待 = 100 毫秒,结束:2193 毫秒
任务 04 ThrID:07,等待 = 100 毫秒,结束:2193 毫秒
任务 01 ThrID:08,等待 = 300 毫秒,结束:2193 毫秒

Thread.Sleep():
任务 00 ThrID:03,等待 = 100 毫秒,开始:0 毫秒
任务 03 ThrID:09,等待 = 300 毫秒,开始:179 毫秒
任务 02 ThrID:06,等待 = 100 毫秒,开始:178 毫秒
任务 04 ThrID:08,等待 = 100 毫秒,开始:179 毫秒
任务 05 ThrID:04,等待 = 300 毫秒,开始:179 毫秒
任务 06 ThrID:07,等待 = 100 毫秒,开始:184 毫秒
任务 01 ThrID:05,等待 = 300 毫秒,开始:178 毫秒
任务 07 ThrID:10,等待 = 300 毫秒,开始:184 毫秒
任务 00 ThrID:03,等待 = 100 毫秒,结束:284 毫秒
任务 08 ThrID:03,等待 = 100 毫秒,开始:184 毫秒
任务 02 ThrID:06,等待 = 100 毫秒,结束:285 毫秒
任务 09 ThrID:06,等待 = 300 毫秒,开始:184 毫秒
任务 04 ThrID:08,等待 = 100 毫秒,结束:286 毫秒
任务 06 ThrID:07,等待 = 100 毫秒,结束:293 毫秒
任务 08 ThrID:03,等待 = 100 毫秒,结束:385 毫秒
任务 03 ThrID:09,等待 = 300 毫秒,结束:485 毫秒
任务 05 ThrID:04,等待 = 300 毫秒,结束:486 毫秒
任务 01 ThrID:05,等待 = 300 毫秒,结束:493 毫秒
任务 07 ThrID:10,等待 = 300 毫秒,结束:494 毫秒
任务 09 ThrID:06,等待 = 300 毫秒,结束:586 毫秒

编辑:
使用 async lambda 和 await Task.Delay()Thread.Sleep() 一样快,也可能更快(511 毫秒)。
编辑 2:
对于 ThreadPool.SetMinThreads(16, 16); Task.Delay().Wait() 在循环中进行 10 次迭代,其工作速度与 Thread.Sleep 一样快。随着更多的迭代,它又变慢了。还有一点有意思,如果不调整我把Thread.Sleep的迭代次数增加到30,还是比较快的,然后10迭代Task.Delay().Wait()
编辑 3:
重载 Task.Delay(wait).Wait(wait)Thread.Sleep()

一样快

Thread.Sleep()Task.Delay() 都不能保证间隔是正确的。

Thread.Sleep()Task.Delay() 的工作方式非常不同。 Thread.Sleep() 阻塞当前线程并阻止它执行任何代码。 Task.Delay() 创建一个计时器,该计时器会在时间到期时计时,并将其分配给在线程池上执行。

您 运行 您的代码使用 Task.Run(),这将创建任务并将它们排入线程池。当你使用 Task.Delay() 时,当前线程被释放回线程池,它可以开始处理另一个任务。这样,多个任务将启动得更快,您将记录所有的启动时间。然后,当延迟计时器开始计时时,它们也会耗尽池,并且某些任务完成的时间比开始后要长得多。这就是为什么你记录了很长时间。

当您使用 Thread.Sleep() 时,您会阻塞池中的当前线程,无法处理更多任务。线程池不会立即增长,因此新任务只能等待。因此,所有任务 运行 大约在同一时间进行,这对您来说似乎更快。

编辑:您使用 Task.Wait()。 在您的情况下, Task.Wait() 尝试在同一线程上内联执行。同时,Task.Delay() 依赖于在线程池上执行的计时器。一旦通过调用 Task.Wait() 阻止池中的工作线程,其次您需要池中的可用线程来完成相同工作方法的操作。当您 await Delay() 时,不需要这样的内联,工作线程可立即用于处理计时器事件。当你 Thread.Sleep 时,你没有定时器来完成 worker 方法。

我相信这就是造成延迟差异巨大的原因。

您的问题是您在不使用 asyncawait 的情况下将异步代码与同步代码混合在一起。不要使用同步调用 .Wait,它会阻塞您的线程,这就是异步代码 Task.Delay() 无法正常工作的原因。

异步代码在同步调用时通常无法正常工作,因为它并非设计为以这种方式工作。你会很幸运,当 运行 同步时,异步代码似乎可以工作。但是,如果您使用的是某个外部库,该库的作者可以更改他们的代码,从而破坏您的代码。异步代码应该一路往下异步。

异步代码通常比同步代码慢。但好处是它 运行 是异步的,例如,如果您的代码正在等待文件加载,则在加载该文件时,其他代码可以 运行 在同一个 CPU 核心上。

您的代码应如下所示,但是对于 async,您无法确定 ManagedThreadId 会保持不变。因为 thread 运行ning 您的代码可以在执行期间更改。如果你因为这个原因使用异步代码,你不应该使用 ManagedThreadId 属性 或 [ThreadStatic] 属性。

Async/Await - Best Practices in Asynchronous Programming

bool flag = true;
var sw = Stopwatch.StartNew();
for (int i = 0; i < 10; i++)
{
    var cntr = i;
    {
        var start = sw.ElapsedMilliseconds;
        var wait = flag ? 100 : 300;
        flag = !flag;

        Task.Run(async () =>
        {
            Console.WriteLine($"task-{cntr.ToString("00")} \t ThrID: {Thread.CurrentThread.ManagedThreadId.ToString("00")},\t Wait={wait}ms, \t START: {start}ms");
            await Task.Delay(wait);
            Console.WriteLine($"task-{cntr.ToString("00")} \t ThrID: {Thread.CurrentThread.ManagedThreadId.ToString("00")},\t Wait={wait}ms, \t END: {sw.ElapsedMilliseconds}ms");
        });
    }
}
Console.ReadKey();
return;

我稍微重写了发布的代码片段,以便更好地排序结果,我全新的笔记本电脑有太多内核,无法很好地解释现有的混乱输出。记录每项任务的开始和结束时间,并在全部完成后显示。并记录Task的实际开始时间。我得到了:

0: 68 - 5031
1: 69 - 5031
2: 68 - 5031
3: 69 - 5031
4: 69 - 1032
5: 68 - 5031
6: 68 - 5031
7: 69 - 5031
8: 1033 - 5031
9: 1033 - 2032
10: 2032 - 5031
11: 2032 - 3030
12: 3030 - 5031
13: 3030 - 4029
14: 4030 - 5031
15: 4030 - 5031

啊,这突然变得很有道理了。处理线程池线程时要始终注意的模式。请注意,每秒钟发生一次重大事件,两个 tp 线程启动 运行,其中一些可以完成。

这是一个死锁场景,与 this Q+A 类似,但除此之外,该用户的代码不会造成更灾难性的后果。原因几乎不可能看到,因为它隐藏在 .NETFramework 代码中,您必须查看 Task.Delay() 的实现方式才能理解它。

相关代码is here,注意它是如何使用System.Threading.Timer来实现延迟的。关于该计时器的一个重要细节是它的回调是在线程池上执行的。 Task.Delay() 实现 "you don't pay for what you don't use" 承诺的基本机制是什么。

具体细节是,如果线程池正忙于处理线程池执行请求,这可能需要一段时间。不是计时器慢,问题是回调方法启动不够快。这个程序的问题,Task.Run() 增加了一堆请求,多于可以同时执行。发生死锁是因为由 Task.Run() 启动的 tp 线程在计时器回调执行之前无法完成 Wait() 调用。

您可以通过将这段代码添加到 Main() 的开头来使其成为使程序永远挂起的硬死锁:

     ThreadPool.SetMaxThreads(Environment.ProcessorCount, 1000);

但正常的最大线程数要高得多。线程池管理器利用它来解决这种死锁。当现有线程未完成时,它每秒允许执行比 "ideal" 数量多的两个线程。这就是您在输出中看到的内容。但一次只有两个,不足以对 Wait() 调用中阻塞的 8 个繁忙线程造成太大影响。

Thread.Sleep() 调用没有这个问题,它不依赖于.NETFramework 代码或线程池来完成。它是 OS 线程调度程序负责处理它,它始终依靠时钟中断运行。因此允许新的 tp 线程每 100 或 300 毫秒开始执行一次,而不是每秒一次。

很难给出避免这种死锁陷阱的具体建议。除了通用建议,始终避免阻塞工作线程。