Task.Delay会启动一个新线程吗?

14

以下代码应该(至少我认为)创建100个Tasks,它们都在并行等待(这就是并发的重点,对吧:D?),并且几乎同时完成。我猜想每个Task.Delay会在内部创建一个Timer对象。

public static async Task MainAsync() {

    var tasks = new List<Task>();
    for (var i = 0; i < 100; i++) {
        Func<Task> func = async () => {
            await Task.Delay(1000);
            Console.WriteLine("Instant");
        };
        tasks.Add(func());
    }
    await Task.WhenAll(tasks);
}

public static void Main(string[] args) {
    MainAsync().Wait();
}

但是!当我在Mono上运行它时,会出现非常奇怪的行为:

  • Tasks不会同时完成,存在巨大的延迟(大约500-600毫秒)
  • 在控制台中,mono显示了许多创建的线程:

已加载程序集:/Users/xxxxx/Programming/xxxxx/xxxxxxxxxx/bin/Release/xxxxx.exe

线程已启动:#2

线程已启动:#3

线程已启动:#4

线程已启动:#5

线程已启动:#6

线程已启动:#7

线程已完成:#3 <--显然1000ms的延迟已经完成了吗?

线程已完成:#2 <-- 显然1000ms的延迟已经完成了吗?

线程已启动:#8

线程已启动:#9

线程已启动:#10

线程已启动:#11

线程已启动:#12

线程已启动:#13

...你懂的。

这实际上是一个错误吗?还是我使用库的方式不对?

[编辑] 我使用计时器测试了自定义的睡眠方法:

    public static async Task MainAsync() {
        Console.WriteLine("Started");
        var tasks = new List<Task>();
        for (var i = 0; i < 100; i++) {
            Func<Task> func = async () => {
                await SleepFast(1000);
                Console.WriteLine("Instant");
            };
            tasks.Add(func());
        }
        await Task.WhenAll(tasks);
        Console.WriteLine("Ready");
    }

    public static Task SleepFast(int amount) {
        var source = new TaskCompletionSource<object>();
        new Timer(state => {
            var oldSrc = (TaskCompletionSource<object>)state;
            oldSrc.SetResult(null);
        }, source, amount, 0);
        return source.Task;
    }

这一次,所有任务都瞬间完成。所以我认为这是一个非常糟糕的实现或者是一个错误。

[编辑2] 只是提供信息:我现在在使用Windows 8.1上测试了原始代码(使用Task.Delay),并且它按预期运行(1000个Tasks,并行等待1秒钟并完成)。

所以答案是:Mono对某些方法的实现并不完美。通常情况下,Task.Delay不会启动线程,即使有很多线程也不应该创建多个线程。


4
Task.Delay 内部使用一个定时器,该定时器内部使用线程池。由于您正在快速创建 100 个任务,因此我猜测您的线程池中的线程已经用完了,所以新的线程将自动创建。为了让内容更加通俗易懂,可以这样翻译:Task.Delay 方法会内部使用一个计时器,而这个计时器会利用线程池来运行。由于您正在迅速创建 100 个任务,我猜测您的线程池中的线程已不足以应付,因此系统会自动创建新的线程来处理任务。 - ken2k
那么计时器没有使用可扩展技术实现吗?我希望有非常高效的解决方案。例如在node.js中,他们使用epoll(适用于Linux),我相信。 - Kr0e
7
Task.Delay操作本身不会创建新线程,但在延迟结束后执行Console.WriteLine的回调方法肯定需要在线程上执行。如果所有延迟都相同,则所有这些回调将大致同时发生。尝试在示例中随机延迟并查看是否使用更少的线程。 - Sam Harwell
我不这么认为。计时器肯定会在某个地方注册自己(在mono源中,计时器通过调度程序注册自己),并保留引用。您所描述的行为会破坏我看到的许多代码。 - Kr0e
1
你是否向Mono项目报告了这个错误?我强烈反对这个错误的概念。“可能创建线程”是一个错误的措辞,因为总会有一个I/O完成线程,在硬件中断后运行代码。在node.js的情况下,它只会将完成回调传递到节点的主线程,然后由事件循环接收。您可以使用自定义任务调度程序轻松实现此模型:https://dev59.com/9WEi5IYBdhLWcg3wuuYk - noseratio - open to work
显示剩余7条评论
2个回答

5

关于.NET Framework桌面应用程序。

简而言之,有一个特殊的VM线程定期检查计时器队列,并在线程池队列上运行计时器的委托。Task.Delay不会创建新的线程,但仍然可能很重,并且不能保证执行顺序或精确的截止日期。据我所知,传递取消Task.Delay可能只是从集合中删除项目,而没有将线程池工作排队。

Task.Delay通过创建新的System.Threading.Timer安排为DelayPromise。所有计时器都存储在TimerQueue的应用程序域单例中。本地VM计时器用于回调.NET以检查是否需要从队列中触发任何计时器。计时器委托通过ThreadPool.UnsafeQueueUserWorkItem安排执行。

从性能角度来看,如果延迟提前结束,则取消延迟似乎更好:

open System.Threading
open System.Threading.Tasks

// takes 0.8% CPU
while true do
  Thread.Sleep(10)
  Task.Delay(50)

// takes 0.4% CPU
let mutable a = new CancellationTokenSource()
while true do
  Thread.Sleep(10)
  a.Cancel()
  a.Dispose()
  a <- new CancellationTokenSource()
  let token = a.Token
  Task.Delay(50,token)

4
Task库的设计更多地用于管理阻塞任务而不会阻塞整个工作流程(任务异步性,被Microsoft混淆地称为“任务并行”),而不是用于执行大量并发计算(并行执行)。
任务库使用调度程序和队列就绪的作业以便执行。当作业运行时,它们将在线程池线程上运行,而这些线程数量非常有限。虽然有扩展线程计数的逻辑,但除非您拥有数百个CPU核心,否则它将保持较低数量。
因此,回答这个问题,你的一些任务排队等待来自池中的线程,而其他延迟的任务已由调度程序发出。
调度程序和线程池逻辑可以在运行时更改,但如果您想快速完成大量计算,则Task不适合该工作。如果您想处理大量缓慢的资源(如磁盘、数据库或互联网资源),则Task可能有助于保持应用程序的响应性。
如果您只想了解Task,请尝试以下内容:

使用Task库编写高性能服务器不可能吗?(因为在服务器场景中,您肯定有许多小型/快速任务。) - Kr0e
5
你可以使用任务库来确保服务器不会因为IO而被阻塞。任务并不能解决性能问题,但它们是一种有用的工具。它们更像是“绿色线程”或Erlang线程--它们不能让你的硬件运行更快,但它们可以更好地利用可用的资源。在优化中,始终要“先测量”。 - Iain Ballard

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