Task.Delay().Wait() 发生了什么?

10
我很困惑为什么 Task.Delay().Wait() 的时间要比 Thread.Sleep() 要长4倍
例如: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 线程ID: 05, 等待时间=300毫秒, 开始时间: 184毫秒
任务-04 线程ID: 07, 等待时间=100毫秒, 开始时间: 184毫秒
任务-00 线程ID: 09, 等待时间=100毫秒, 开始时间: 0毫秒
任务-06 线程ID: 04, 等待时间=100毫秒, 开始时间: 185毫秒
任务-01 线程ID: 08, 等待时间=300毫秒, 开始时间: 183毫秒
任务-05 线程ID: 03, 等待时间=300毫秒, 开始时间: 185毫秒
任务-02 线程ID: 06, 等待时间=100毫秒, 开始时间: 184毫秒
任务-07 线程ID: 10, 等待时间=300毫秒, 开始时间: 209毫秒
任务-07 线程ID: 10, 等待时间=300毫秒, 结束时间: 1189毫秒
任务-08 线程ID: 12, 等待时间=100毫秒, 开始时间: 226毫秒
任务-09 线程ID: 10, 等待时间=300毫秒, 开始时间: 226毫秒
任务-09 线程ID: 10, 等待时间=300毫秒, 结束时间: 2192毫秒
任务-06 线程ID: 04, 等待时间=100毫秒, 结束时间: 2193毫秒
任务-08 线程ID: 12, 等待时间=100毫秒, 结束时间: 2194毫秒
任务-05 线程ID: 03, 等待时间=300毫秒, 结束时间: 2193毫秒
任务-03 线程ID: 05, 等待时间=300毫秒, 结束时间: 2193毫秒
任务-00 线程ID: 09, 等待时间=100毫秒, 结束时间: 2193毫秒
任务-02 线程ID: 06, 等待时间=100毫秒, 结束时间: 2193毫秒
任务-04 线程ID: 07, 等待时间=100毫秒, 结束时间: 2193毫秒
任务-01 线程ID: 08, 等待时间=300毫秒, 结束时间: 2193毫秒

使用Thread.Sleep()
任务-00 ThrID:03,等待= 100ms,开始:0ms
任务-03 ThrID:09,等待= 300ms,开始:179ms
任务-02 ThrID:06,等待= 100ms,开始:178ms
任务-04 ThrID:08,等待= 100ms,开始:179ms
任务-05 ThrID:04,等待= 300ms,开始:179ms
任务-06 ThrID:07,等待= 100ms,开始:184ms
任务-01 ThrID:05,等待= 300ms,开始:178ms
任务-07 ThrID:10,等待= 300ms,开始:184ms
任务-00 ThrID:03,等待= 100ms,结束:284ms
任务-08 ThrID:03,等待= 100ms,开始:184ms
任务-02 ThrID:06,等待= 100ms,结束:285ms
任务-09 ThrID:06,等待= 300ms,开始:184ms
任务-04 ThrID:08,等待= 100ms,结束:286ms
任务-06 ThrID:07,等待= 100ms,结束:293ms
任务-08 ThrID:03,等待= 100ms,结束:385ms
任务-03 ThrID:09,等待= 300ms,结束:485ms
任务-05 ThrID:04,等待= 300ms,结束:486ms
任务-01 ThrID:05,等待= 300ms,结束:493ms
任务-07 ThrID:10,等待= 300ms,结束:494ms
任务-09 ThrID:06,等待= 300ms,结束:586ms
编辑:
使用async lambda和await Task.Delay()Thread.Sleep()一样快,甚至可能更快(511ms)。
编辑2:
使用ThreadPool.SetMinThreads(16, 16);Task.Delay().Wait()在循环的10次迭代中与Thread.Sleep一样快。如果迭代次数更多,则速度会变慢。有趣的是,如果我将Thread.Sleep的迭代次数增加到30而没有进行调整,则仍然比使用Task.Delay().Wait()10次迭代更快。
编辑3:
重载Task.Delay(wait).Wait(wait)Thread.Sleep()一样快。

1
你测试过使用async和await的Task了吗? - Ivan Ičin
4
如果您在代码开头添加ThreadPool.SetMinThreads(16, 16);,您会发现它可以将执行时间缩短到您所期望的。这是因为@Damien_The_Unbeliever的已删除答案中描述的原因(即一旦超过最小线程数,线程池会将每个新线程的启动延迟2毫秒)。 - Matthew Watson
2
@Rekshino Task.Delay().Wait() 是没有意义的。Task.Delay() 会返回一个任务,该任务在 System.Threading.Timer 触发时完成。这个与 async/await 结合使用,可以延迟而不阻塞任何线程。Task.Delay().Wait() 立即阻塞线程,同时等待计时器触发,失去了 Task.Delay() 提供的任何好处。 - Panagiotis Kanavos
2
@Rekshino 这会使得多次比较毫无意义。Task.Delay() 的工作是阻塞。它的工作是在计时器到期后恢复执行一个已经存在的线程池线程,而不是被阻塞。 - Panagiotis Kanavos
1
并不是没有记录的线程池改进。Win10在程序启动时立即创建了自己的线程池,拥有两个不知道彼此存在的池是一个问题。因此,我认为他们在.NET 4.7.x更新中做了一些改进,使它们更好地协同工作。顺便说一句,在CLR中很容易做到这一点,它有一个托管接口(ICorThreadPool),允许主机改变线程池的工作方式。完全是猜测,很难验证。 - Hans Passant
显示剩余19条评论
3个回答

8

无论是使用 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 时,没有定时器来完成工作者方法。

我认为这就是延迟差异的原因。


1
当您使用Task.Delay()时,当前线程会被释放回线程池,并且可以开始处理另一个任务。我非常怀疑!我认为同步的Task.Delay().Wait()会像Thread.Sleep()一样阻塞线程,并且线程不会被重用,但是在使用await Task.Delay()的情况下,线程将被重用。 - Rekshino
对我来说,它似乎并不更快,而是快了__4倍__。为什么await Task.Delay()Thread.Sleep()一样快,即使也会创建新任务?请看我的问题——30次Thread.Sleep()的迭代比10次Task.Delay().Wait()的迭代更快。 - Rekshino
我认为问题出在Thread.Delay()的工作方式上,可以看一下我编辑后的回答。希望能有所帮助。 - Nick

8

我稍微修改了贴出的代码片段,以更好地排序结果。我的全新笔记本电脑拥有太多核心,无法充分解释现有的混乱输出。记录每个任务的开始和结束时间,并在所有任务完成后显示它们。还记录了任务的实际开始时间。我得到了:

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线程并完成其中一些。
这是一个死锁场景,类似于此问答,但没有那个用户代码的更灾难性结果。由于它被埋在.NETFramework代码中,因此几乎不可能看到原因,您必须查看如何实现Task.Delay()才能理解它。
相关代码在这里,请注意它使用System.Threading.Timer来实现延迟。关于该计时器的一个细节是,其回调在线程池上执行。这是Task.Delay()可以实现“您不付出您不使用”的承诺的基本机制。
问题在于,如果线程池正在忙于处理线程池执行请求,则这可能需要一段时间。问题不在于计时器太慢,而在于回调方法没有足够快地启动。在此程序中的问题是,Task.Run()添加了大量请求,超过了同时执行的数量。死锁发生是因为由Task.Run()启动的tp线程在计时器回调执行之前无法完成Wait()调用。
您可以通过在Main()的开头添加此代码来使其成为永久挂起程序的硬死锁:
     ThreadPool.SetMaxThreads(Environment.ProcessorCount, 1000);

但正常的最大线程数要高得多。 线程池管理器利用此来解决此类死锁问题。 每秒钟,它允许比“理想”数量更多的两个线程执行,当现有线程未完成时。 这就是您在输出中看到的内容。 但一次只有两个,不足以对被阻塞在Wait()调用上的8个繁忙线程造成太大影响。
Thread.Sleep()调用没有这个问题,它不依赖于.NET Framework代码或线程池来完成。 是操作系统线程调度程序负责处理它,并且始终通过时钟中断运行。 因此,可以每100或300毫秒而不是每秒启动新的tp线程。
很难给出具体的建议以避免这种死锁陷阱。 除了通用建议之外,始终避免工作线程阻塞。

嗯,答案似乎是可信的。 - Rekshino
@Rekshino:我认为答案是正确的 - 如果你想要“证明”它,考虑订阅线程池事件(https://learn.microsoft.com/en-us/dotnet/framework/performance/thread-pool-etw-events)。我认为在程序继续之前,你会看到一个“ThreadPoolWorkerThreadAdjustmentAdjustment”事件。 - Mads Ravn

2
你的问题是在不使用 asyncawait 的情况下混合使用异步代码和同步代码。不要使用同步调用 .Wait,它会阻塞线程,这就是为什么异步代码 Task.Delay() 无法正常工作的原因。
异步代码通常在同步调用时无法正常工作,因为它没有设计成这样工作。你可能会有运气,在同步运行异步代码时看起来工作正常。但如果你使用的是某些外部库,则该库的作者可以更改他们的代码以破坏您的代码。异步代码应该完全是异步的。
异步代码通常比同步代码慢。但好处是它可以异步运行,例如,如果你的代码正在等待文件加载,那么在加载文件时可以在同一CPU核心上运行其他代码。
你的代码应该像下面这样,但是使用 async 后你不能确定 ManagedThreadId 是否会保持不变。因为在执行过程中运行代码的线程可能会发生变化。如果使用异步代码,无论什么原因都不应该使用 ManagedThreadId 属性或 [ThreadStatic] 特性。
参考链接:异步/等待 - 异步编程的最佳实践
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.Delay().Wait()需要比Thread.Sleep()多4倍的时间。解释将会很有趣。 - Rekshino
1
为什么在使用异步代码时,我“不应该使用ManagedThreadId属性或[ThreadStatic]属性”?如果我想知道任务/子任务在哪个线程池线程上运行,该怎么办? - Rekshino
@Rekshino,问题在于您似乎不理解异步代码的工作原理。当同步调用异步代码时,它通常无法正常工作,因为它并非设计为这样工作的。我在我的答案中添加了一个链接,您应该阅读一下。 - Wanton
我认为将异步代码同步运行并不是问题。例如,请参见我的Edit 2,如果我们调整线程池配置,则它的工作方式与Thread.Sleep()相同。正如我在问题中已经指出的那样-我知道会阻塞线程。 - Rekshino
+1 是给那些“在不使用 async 和 await 的情况下混合异步代码和同步代码”的人的。没有人意识到,一旦遇到 await,线程就会立即返回到线程池中。 - bmiller
显示剩余3条评论

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