Task.Delay 放在 CPU 密集型循环前后为何耗时不同?
结论
两种写法的总耗时通常接近,但不一定完全相同。Task.Delay 等待时不会占用 ThreadPool 线程;Task.Run 里的代码则会先在线程池线程上同步运行,直到第一次遇到 await。把等待放在循环前,100 个任务很快就会开始计时;放在循环后,每个任务都要先完成 CPU 计算,才开始自己的 5 秒等待。
约 200ms 的差异不能直接归因于某条固定的调度规则。ThreadPool 的线程注入和排队、JIT 与运行时状态、CPU 负载,以及任务中的控制台输出,都可能影响结果。
Task.Run 和 await 分别做什么
Task.Run(async () => ...) 会在线程池上启动异步委托。委托从头开始执行,遇到尚未完成的 await 时才会让出当前线程。执行到 await Task.Delay(5000) 时,异步方法会安排一个延迟完成的任务并返回。等待期间,线程可以处理其他工作。延迟结束后,方法的后续代码会进入调度队列,等待继续执行。
Task.Delay 进行的是异步计时等待,等待时不会像 Thread.Sleep 那样占住线程。不过,await 之前的循环属于同步 CPU 工作,会一直占用执行它的线程,直到循环结束。
两种排列的执行差异
延迟在循环前
每个 Task.Run 启动后,很快就会执行到 await Task.Delay(5000) 并让出线程。100 个延迟大致同时开始计时。延迟结束后,循环代码陆续进入队列,由线程池根据队列情况和可用线程执行。
延迟在循环后
任务启动后会先执行循环。这段时间里,任务占用线程池线程。循环结束并执行到 await Task.Delay(5000) 后,该任务才开始等待。不同任务的延迟会与其他任务尚未完成的循环重叠,因此总时间不等于“所有循环完成后再加 5 秒”。不过,最后一批完成循环的任务仍可能在循环结束后等待接近 5 秒。
把循环严格按“CPU 核数”分成几批,只能作为近似理解。线程池不保证固定的批次数或同时运行数,而是会根据运行时状态调整线程,并处理队列中的工作。
为什么会差约 200ms
每个任务都会调用一次 Console.WriteLine("done"),而计时器要等到 Task.WhenAll(tasks) 完成后才停止,所以这些输出也计入了耗时。控制台输出会带来额外开销和竞争。
线程池调度、系统负载、运行时预热和测量波动也会造成差异。只测一轮,200ms 的差距不足以证明某种排列总是更快。即使多次测量后差异依然稳定,也需要在目标机器和实际负载下继续分析,不能据此推导出适用于所有环境的固定差值。
怎样比较更可靠
先移除任务内部的控制台输出,再多次运行两种排列并记录每轮耗时。分别在新进程和预热后的进程中测量,可以观察启动和 JIT 的影响。若要评估循环本身的性能,可以使用 BenchmarkDotNet;若要观察线程池行为,可以记录每轮耗时和运行时线程池指标,同时控制机器上的其他负载。
var tasks = Enumerable.Range(0, TASKS).Select(_ =>
Task.Run(async () =>
{
await Task.Delay(5000);
var sum = 0;
for (int j = 1; j <= 100000000; j++)
{
sum += j;
}
return sum;
})
).ToArray();
var results = await Task.WhenAll(tasks);
这段改写移除了计时区间内的控制台输出,但仍适合观察整体等待和计算之间的时间关系,不能作为严谨的 CPU 微基准。要单独比较 CPU 循环,应分别测量延迟和循环,并确保基准结果会被使用,以免无关工作影响测量。
备注:内容仅供参考。
